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
22 changes: 22 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,28 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
process never repeat and always increase, also when the clock steps back, and a forked child
starts its own sequence. The format is unchanged (`<11 hex digits>-<8 hex digits>`), and
nothing in forge parses it.
- **`fluid forge --agent` reports a failure as a `forge.error` event, not a traceback.** An AI
error such as `copilot_no_llm_provider` printed a full Python traceback on stderr, and the
stream said only `forge.done` with `exit_code` 1. A run that fails on an error now sends one
`forge.error` event before `forge.done`, with the error's `code`, `message`, `suggestions`
and, when the error catalog has one, `docs_url` (the shape of `fluid ai test --json`'s
`error`), redacted as the console redacts. An unexpected exception is named by its class only
(`code: forge_unexpected_error`, `exception`), and a failed `--seed-from` by its path and class
(`forge_seed_failed`), because the MCP `forge_run` hands these events to the IDE's model; it
also writes the failure to the server's log, since it discards forge's console output. A usage
error (exit 2) or a stop without an error, such as blank mode finding a contract in place,
still sends only `forge.done`. In every forge mode, interactive runs included, a FLUID error's
traceback now shows only at DEBUG (`--debug` or `FLUID_LOG_LEVEL=DEBUG`); stderr keeps its
message and next steps, and one that reaches forge's last handler prints them instead of its
event code. A crash keeps its traceback.
- **`fluid ai test --json` prints only its report when the call fails.** litellm printed its
"Give Feedback / Get Help" banner to stdout ahead of the JSON. Forge now sets litellm's own
switch, `suppress_debug_info`, wherever it loads litellm (AI calls, `fluid ai setup`'s key
check, the agent loop), so no command prints the banner. The error is reported as before.
- **`fluid forge --agent --blank` reports its contract once.** Blank mode sent its own
`forge.contract_written` and the receipt sent another, so the stream named one file twice,
first by its absolute path and then by its path from the current directory. Only the receipt's
event is left, as in every other mode.

## [0.29.0] — 2026-11-12

Expand Down
144 changes: 140 additions & 4 deletions fluid_build/cli/_forge_mode_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,8 +41,9 @@
from __future__ import annotations

import logging
from contextvars import ContextVar
from types import TracebackType
from typing import Any, Callable, Optional
from typing import Any, Callable, Dict, List, Optional, Tuple

# Render callback: receives ``(console, exc)`` and prints the
# handler-specific failure message. ``console`` may be ``None`` on the
Expand Down Expand Up @@ -73,8 +74,10 @@ class ForgeModeErrorBoundary:
set: ``logger.info(cancel_label)`` then exit code ``130``. When
``cancel_label`` is ``None`` the interrupt propagates unchanged
(the domain-agent handler never caught Ctrl-C).
* Any ``Exception`` logs ``logger.exception(fail_label)``, invokes
``render_error(console, exc)``, and yields exit code ``1``.
* Any ``Exception`` is logged through :func:`log_forge_failure` (a crash
as ``logger.exception(fail_label)``, a FLUID error with its traceback
at DEBUG only), invokes ``render_error(console, exc)``, and yields exit
code ``1``.
* ``SystemExit`` / ``GeneratorExit`` and other non-``Exception``
``BaseException``\\ s propagate untouched — matching a bare
``except Exception`` clause, which never caught them either.
Expand Down Expand Up @@ -130,7 +133,9 @@ def __exit__(
# ``return False`` below — exactly as a bare ``except Exception``
# would leave them uncaught.
if isinstance(exc, Exception):
self._logger.exception(self._fail_label)
# Without a console a renderer may print nothing, so the log line
# is then the only place the failure shows.
log_forge_failure(self._logger, self._fail_label, exc, shown=self._console is not None)
self._render_error(self._console, exc)
self.exit_code = 1
return True
Expand All @@ -154,3 +159,134 @@ def forge_mode_error_boundary(
render_error=render_error,
cancel_label=cancel_label,
)


# ---------------------------------------------------------------------------
# Why a forge run failed: ``log_forge_failure`` and the ``--agent`` event
# ---------------------------------------------------------------------------
#
# A mode handler catches its own failure, prints it and returns an exit code,
# so by the time ``forge.run`` writes ``forge.done`` the exception is gone.
# Each handler that swallows one hands it to :func:`log_forge_failure`, which
# notes it here; ``forge.run`` reads the note back and, under ``--agent``,
# writes it as one ``forge.error`` event. A ``ContextVar``, not a module global:
# the MCP ``forge_run`` runs forge in a worker thread, and a run reads only its
# own note.

_RUN_FAILURE: ContextVar[Optional[BaseException]] = ContextVar(
"fluid_forge_run_failure", default=None
)

#: ``forge.error``'s ``code`` for an exception that is not a FLUID error.
UNEXPECTED_FAILURE_CODE = "forge_unexpected_error"


def note_forge_failure(exc: BaseException) -> None:
"""Remember *exc* as why this forge run is failing; a later failure replaces it."""
_RUN_FAILURE.set(exc)


def take_forge_failure() -> Optional[BaseException]:
"""Return the failure noted for this run, and forget it."""
exc = _RUN_FAILURE.get()
_RUN_FAILURE.set(None)
return exc


def is_typed_forge_error(exc: BaseException) -> bool:
"""Whether *exc* is a FLUID error, written for the user, rather than a crash.

``CLIError`` (``CopilotGenerationError``, ``ForgeError`` and the rest),
``FluidError``, ``FluidUserError`` and a missing optional extra each say what
went wrong and, mostly, what to do next. Anything else is a bug.
"""
from fluid_build._contract_loader import CLIError
from fluid_build._errors import FluidUserError
from fluid_build._optional import MissingOptionalDependency
from fluid_build.errors import FluidError

return isinstance(exc, (CLIError, FluidError, FluidUserError, MissingOptionalDependency))


def _failure_parts(exc: BaseException) -> Tuple[str, str, List[str], Optional[str]]:
"""``(code, message, suggestions, docs_url)`` for *exc*, not yet redacted."""
import re

from fluid_build._contract_loader import CLIError
from fluid_build._errors import FluidUserError
from fluid_build._optional import MissingOptionalDependency
from fluid_build.errors import FluidError

if isinstance(exc, MissingOptionalDependency):
# ``str(exc)`` runs the message, the install line and any alternatives
# together as paragraphs; the event keeps them apart.
hints = [f"pip install 'data-product-forge[{exc.extra}]'"]
if exc.alternatives:
hints.append(exc.alternatives)
message = f"{exc.feature} needs {exc.package}, which is not installed."
return exc.event, message, hints, None
if isinstance(exc, CLIError):
return exc.event, str(exc.message), list(exc.suggestions or []), exc.docs_url
if isinstance(exc, FluidUserError):
return exc.code, exc.what, [exc.fix] if exc.fix else [], exc.doc or None
if isinstance(exc, FluidError):
# No event key on this family: the class name, snake_cased like one.
code = re.sub(r"(?<!^)(?=[A-Z])", "_", type(exc).__name__).lower()
return code, exc.message, list(exc.suggestions or []), None
return (
UNEXPECTED_FAILURE_CODE,
f"forge stopped on an unexpected {type(exc).__name__}; stderr has the detail",
["Run 'fluid doctor' to check the environment", "Re-run with --debug for the full log"],
None,
)


def describe_forge_failure(exc: BaseException) -> Dict[str, Any]:
"""``forge.error``'s fields for *exc*: ``code``, ``message``, ``suggestions``.

The shape of ``fluid ai test --json``'s ``error`` object, plus ``docs_url``
when the error catalog has one. The text is redacted as the console redacts
it, since the events go to stdout directly, past the logging redactor. An
exception that is not a FLUID error is named by its class (``exception``),
never by its text: that can carry paths or provider detail, and the MCP
``forge_run`` returns these events to the IDE's model, where the copilot
tools' typed-error rule (SECURITY_REVIEW I7) applies. stderr has the detail.
"""
from fluid_build._console import _redact_error_str

code, message, suggestions, docs_url = _failure_parts(exc)
fields: Dict[str, Any] = {
"code": code,
"message": _redact_error_str(message),
"suggestions": [_redact_error_str(s) for s in suggestions],
}
if docs_url:
fields["docs_url"] = docs_url
if not is_typed_forge_error(exc):
fields["exception"] = type(exc).__name__
return fields


def log_forge_failure(
logger: logging.Logger, label: str, exc: BaseException, *, shown: bool = True
) -> None:
"""Log a failure that its caller prints for the user, and note it for ``--agent``.

A FLUID error (:func:`is_typed_forge_error`) is an outcome with its own
message and next steps, not a crash. It gets one ERROR record (*label* and
its code) with no traceback, and the traceback at DEBUG. The ERROR record
reaches ``--log-file`` but skips the console when the caller prints the
message there itself (*shown*): the CLI's console handler drops a record
marked ``fluid_console_shown``. Anything else keeps ``logger.exception``,
whose traceback is the bug report.

Call it while *exc* is being handled: in its ``except`` block, or in the
``__exit__`` of a context manager that it is unwinding.
"""
note_forge_failure(exc)
if not is_typed_forge_error(exc):
logger.exception(label)
return
code = _failure_parts(exc)[0]
logger.error("%s: %s", label, code, extra={"fluid_console_shown": shown})
logger.debug("%s: %s", label, code, exc_info=exc)
32 changes: 8 additions & 24 deletions fluid_build/cli/_template_mode.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
# that this module references via ``_fm.<name>`` so test patches that
# target ``fluid_build.cli.forge_modes.<name>`` resolve at call time.
from fluid_build.cli import forge_modes as _fm # noqa: E402
from fluid_build.cli._forge_mode_errors import log_forge_failure
from fluid_build.cli.console import cprint, success
from fluid_build.cli.console import error as console_error
from fluid_build.cli.forge_copilot_llm_providers import (
Expand Down Expand Up @@ -151,7 +152,7 @@ def run_template_mode(
logger.info("3. fluid validate contract.fluid.yaml")
return 0
except Exception as exc: # noqa: BLE001
logger.exception("Template mode failed")
log_forge_failure(logger, "Template mode failed", exc)
if console:
console.print(literal_error_text(f"❌ Template mode failed: {exc}", "red"))
else:
Expand Down Expand Up @@ -273,7 +274,7 @@ def _create_project_agent_loop(
return True

except CopilotGenerationError as exc:
logger.exception("Agent loop failed")
log_forge_failure(logger, "Agent loop failed", exc, shown=console is not None)
if console:
try:
console.print(literal_error_text(exc.message, "red"))
Expand All @@ -283,7 +284,7 @@ def _create_project_agent_loop(
pass
return False
except Exception as exc: # noqa: BLE001
logger.exception("Agent loop crashed")
log_forge_failure(logger, "Agent loop crashed", exc, shown=console is not None)
if console:
try:
console.print(literal_error_text(f"Agent loop failed: {exc}", "red"))
Expand All @@ -292,20 +293,6 @@ def _create_project_agent_loop(
return False


def _is_missing_optional(exc: BaseException) -> bool:
"""True when *exc* is, or was caused by, a missing optional dependency."""
from fluid_build._optional import MissingOptionalDependency

cur: Optional[BaseException] = exc
for _ in range(8):
if cur is None:
return False
if isinstance(cur, MissingOptionalDependency):
return True
cur = cur.__cause__ or cur.__context__
return False


def _emit_incomplete_forge_span(logger: logging.Logger) -> None:
"""Emit a ``run_completed=False`` ``forge.invocation`` span on failure.

Expand Down Expand Up @@ -1023,12 +1010,9 @@ def _serialize_logical_model(value: Any) -> Optional[str]:
return True

except CopilotGenerationError as exc:
if _is_missing_optional(exc):
# An absent optional package is an install problem, not a crash:
# the message and hint are the whole story, so no traceback.
logger.error("AI Copilot minimal flow unavailable: %s", exc.message)
else:
logger.exception("AI Copilot minimal flow failed")
# The message and its suggestions, printed below, are the whole story
# (a missing extra's install line included): the traceback is DEBUG.
log_forge_failure(logger, "AI Copilot minimal flow failed", exc)
_emit_incomplete_forge_span(logger)
if console:
try:
Expand All @@ -1041,7 +1025,7 @@ def _serialize_logical_model(value: Any) -> Optional[str]:
console_error(exc.message)
return False
except (OSError, ValueError, KeyError, TypeError, RuntimeError) as exc:
logger.exception("AI Copilot minimal flow crashed")
log_forge_failure(logger, "AI Copilot minimal flow crashed", exc)
_emit_incomplete_forge_span(logger)
if console:
try:
Expand Down
3 changes: 3 additions & 0 deletions fluid_build/cli/ai_setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -247,8 +247,11 @@ def _validate_api_key(provider: Any, api_key: str) -> Optional[str]:

from fluid_build.cli.forge_copilot_llm_litellm import (
_LITELLM_PREFIX_BY_PROVIDER,
quiet_litellm,
)

quiet_litellm(litellm)

prefix = _LITELLM_PREFIX_BY_PROVIDER.get(provider.name.lower(), provider.name.lower())
model = (
provider.default_model
Expand Down
Loading
Loading