Skip to content

Commit e71503d

Browse files
committed
Fix(provider): keep provider onboarding active until setup succeeds
1 parent f6a007a commit e71503d

6 files changed

Lines changed: 341 additions & 31 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ All notable changes to this project will be documented in this file.
77
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
88
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
99

10+
## [0.21.9] - 2026-05-10
11+
12+
### Changed
13+
14+
- **Runtime provider onboarding** - when the gateway starts or receives messages without a working LLM provider, it keeps running, answers with a provider-setup hint, reopens Placet onboarding as needed, and hot-loads the validated provider after setup completes.
15+
1016
## [0.21.8] - 2026-05-10
1117

1218
### Changed

facio/agent/loop.py

Lines changed: 124 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from loguru import logger
1515

16+
from facio.agent import status as agent_status
1617
from facio.agent.autocompact import AutoCompact
1718
from facio.agent.channel_context import (
1819
get_configured_keys_context,
@@ -25,29 +26,33 @@
2526
from facio.agent.loop_hook import LoopHook as _LoopHook
2627
from facio.agent.memory import Consolidator, Reflection
2728
from facio.agent.memory_search import MemorySearchDB
29+
from facio.agent.run_context import make_run_context, reset_run_context, set_run_context
2830
from facio.agent.runner import _MAX_INJECTIONS_PER_TURN, AgentRunner, AgentRunSpec
29-
from facio.agent.run_context import make_run_context, set_run_context, reset_run_context
3031
from facio.agent.session_state import (
3132
PENDING_USER_TURN_KEY,
3233
RUNTIME_CHECKPOINT_KEY,
3334
SessionCheckpointManager,
3435
)
35-
from facio.agent import status as agent_status
3636
from facio.agent.stream_session import StreamSession
3737
from facio.agent.subagent import SubagentManager
38-
from facio.agent.turn_executor import TurnExecutor
3938
from facio.agent.tools.file_state import FileStateStore, bind_file_states, reset_file_states
4039
from facio.agent.tools.registry import ToolRegistry
41-
from facio.bus.events import InboundMessage, OutboundMessage
40+
from facio.agent.turn_executor import TurnExecutor
4241
from facio.bus import metadata as bus_meta
42+
from facio.bus.events import InboundMessage, OutboundMessage
4343
from facio.bus.queue import MessageBus
4444
from facio.command import CommandContext, CommandRouter, register_builtin_commands
4545
from facio.config.schema import AgentDefaults
4646
from facio.providers.base import LLMProvider
4747
from facio.session.manager import Session, SessionManager
4848

4949
if TYPE_CHECKING:
50-
from facio.config.schema import ChannelsConfig, ExecToolConfig, WebToolsConfig
50+
from facio.config.schema import (
51+
BrowserToolsConfig,
52+
ChannelsConfig,
53+
ExecToolConfig,
54+
WebToolsConfig,
55+
)
5156
from facio.cron.service import CronService
5257
from facio.providers.pool import ProviderPool
5358

@@ -93,15 +98,15 @@ def __init__(
9398
context_block_limit: int | None = None,
9499
max_tool_result_chars: int | None = None,
95100
provider_retry_mode: str = "standard",
96-
web_config: WebToolsConfig | None = None,
101+
web_config: "WebToolsConfig | None" = None,
97102
browser_config: "BrowserToolsConfig | None" = None,
98-
exec_config: ExecToolConfig | None = None,
103+
exec_config: "ExecToolConfig | None" = None,
99104
tool_timeout: int = 300,
100105
cron_service: CronService | None = None,
101106
restrict_to_workspace: bool = False,
102107
session_manager: SessionManager | None = None,
103108
mcp_servers: dict | None = None,
104-
channels_config: ChannelsConfig | None = None,
109+
channels_config: "ChannelsConfig | None" = None,
105110
timezone: str | None = None,
106111
session_ttl_minutes: int = 0,
107112
hooks: list[AgentHook] | None = None,
@@ -126,10 +131,12 @@ def __init__(
126131
policy_enabled: bool | None = None,
127132
policy_skip_cron: bool | None = None,
128133
credential_store: Any | None = None,
134+
runtime_config: Any | None = None,
135+
runtime_config_path: Path | None = None,
129136
):
130-
from facio.config.schema import BrowserToolsConfig, ExecToolConfig, WebToolsConfig
131137
from facio.agent.credential_store import CredentialStore
132138
from facio.config.paths import get_data_dir
139+
from facio.config.schema import BrowserToolsConfig, ExecToolConfig, WebToolsConfig
133140

134141
defaults = AgentDefaults()
135142
self.bus = bus
@@ -189,6 +196,9 @@ def __init__(
189196
# reaching into the runner's hook context.
190197
self._current_iteration: int = 0
191198
self.provider_pool = provider_pool
199+
self._runtime_config = runtime_config
200+
self._runtime_config_path = runtime_config_path
201+
self._provider_setup_task: asyncio.Task | None = None
192202
self.vision_model = vision_model
193203
self.vision_provider = vision_provider
194204
self.image_model = image_model
@@ -529,6 +539,105 @@ def _effective_session_key(self, msg: InboundMessage) -> str:
529539
return UNIFIED_SESSION_KEY
530540
return msg.session_key
531541

542+
def _load_current_runtime_config(self) -> Any | None:
543+
if self._runtime_config_path is None:
544+
return self._runtime_config
545+
from facio.config.loader import load_config, resolve_config_env_vars
546+
547+
config = resolve_config_env_vars(load_config(self._runtime_config_path))
548+
config.agents.defaults.workspace = str(self.workspace)
549+
self._runtime_config = config
550+
return config
551+
552+
@staticmethod
553+
def _placet_setup_available(config: Any) -> bool:
554+
channels = config.model_dump(mode="json", by_alias=True).get("channels", {})
555+
placet = channels.get("placet", {})
556+
return bool(placet.get("enabled") or placet.get("instanceUrl"))
557+
558+
async def _maybe_handle_provider_setup(self, msg: InboundMessage) -> bool:
559+
config = self._load_current_runtime_config()
560+
if config is None:
561+
return False
562+
563+
from facio.channels.placet_onboarding import (
564+
has_functioning_provider,
565+
provider_setup_message,
566+
)
567+
568+
if has_functioning_provider(config):
569+
if getattr(self.provider, "_facio_unconfigured", False):
570+
await self._reload_provider_from_config(config)
571+
return False
572+
573+
placet_available = self._placet_setup_available(config)
574+
if placet_available:
575+
self._start_provider_setup(config)
576+
await self.bus.publish_outbound(OutboundMessage(
577+
channel=msg.channel,
578+
chat_id=msg.chat_id,
579+
content=provider_setup_message(placet_available=placet_available),
580+
metadata=dict(msg.metadata or {}),
581+
))
582+
return True
583+
584+
def _start_provider_setup(self, config: Any) -> None:
585+
if self._runtime_config_path is None:
586+
return
587+
if self._provider_setup_task is not None and not self._provider_setup_task.done():
588+
return
589+
self._provider_setup_task = asyncio.create_task(self._run_provider_setup(config))
590+
591+
async def _run_provider_setup(self, config: Any) -> None:
592+
if self._runtime_config_path is None:
593+
return
594+
from facio.channels.placet_onboarding import run_onboarding
595+
596+
try:
597+
if await run_onboarding(config, self._runtime_config_path):
598+
await self._reload_provider_from_config()
599+
except asyncio.CancelledError:
600+
raise
601+
except Exception:
602+
logger.warning("runtime provider setup failed", exc_info=True)
603+
604+
async def _reload_provider_from_config(self, config: Any | None = None) -> bool:
605+
config = config or self._load_current_runtime_config()
606+
if config is None:
607+
return False
608+
609+
from facio.channels.placet_onboarding import has_functioning_provider
610+
611+
if not has_functioning_provider(config):
612+
return False
613+
614+
from facio.facio import _make_provider
615+
from facio.providers.pool import ProviderPool
616+
617+
provider = _make_provider(config)
618+
pool = ProviderPool(config)
619+
primary_name = config.get_provider_name(config.agents.defaults.model)
620+
if primary_name:
621+
pool.seed(primary_name, provider)
622+
623+
self.provider = provider
624+
self.model = config.agents.defaults.model
625+
self.runner = AgentRunner(provider)
626+
self.provider_pool = pool
627+
self.subagents.provider = provider
628+
self.subagents.runner = AgentRunner(provider)
629+
self.subagents.model = self.model
630+
self.subagents.subagent_model = config.agents.defaults.subagent_model or self.model
631+
self.subagents.provider_pool = pool
632+
self.consolidator.set_provider(provider, self.model, self.context_window_tokens)
633+
self.reflection.set_provider(provider, self.model)
634+
for tool in self._hitl_tools:
635+
if hasattr(tool, "set_provider_pool"):
636+
tool.set_provider_pool(pool)
637+
self._runtime_config = config
638+
logger.info("runtime provider setup completed with provider {}", primary_name)
639+
return True
640+
532641
def _replay_token_budget(self) -> int:
533642
"""Derive a token budget for session history replay from the context window.
534643
@@ -802,6 +911,9 @@ async def _dispatch(self, msg: InboundMessage) -> None:
802911
self._pending_queues[session_key] = pending
803912

804913
try:
914+
if await self._maybe_handle_provider_setup(msg):
915+
_run_status["stop_reason"] = "provider_setup_required"
916+
return
805917
async with lock, gate:
806918
# Notify the runtime integration that the agent is busy.
807919
await self._runtime.send_status(agent_status.BUSY)
@@ -976,6 +1088,9 @@ async def _dispatch(self, msg: InboundMessage) -> None:
9761088

9771089
async def close_mcp(self) -> None:
9781090
"""Drain pending background archives, then close MCP connections."""
1091+
if self._provider_setup_task is not None and not self._provider_setup_task.done():
1092+
self._provider_setup_task.cancel()
1093+
await asyncio.gather(self._provider_setup_task, return_exceptions=True)
9791094
if self._background_tasks:
9801095
await asyncio.gather(*self._background_tasks, return_exceptions=True)
9811096
self._background_tasks.clear()

facio/channels/placet_onboarding.py

Lines changed: 39 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@
2222
# ---------------------------------------------------------------------------
2323
# Provider catalogue shown in the onboarding form (derived from registry)
2424
# ---------------------------------------------------------------------------
25-
2625
from facio.providers.registry import PROVIDERS as _REGISTRY_PROVIDERS
2726

2827
# OAuth providers offered through Placet relay
@@ -130,18 +129,50 @@
130129
# Public API
131130
# ---------------------------------------------------------------------------
132131

132+
def has_functioning_provider(config: Config) -> bool:
133+
"""Return True when the active model resolves to a usable provider config."""
134+
from facio.providers.registry import find_by_name
135+
136+
model = config.agents.defaults.model
137+
provider_name = config.get_provider_name(model)
138+
if not provider_name:
139+
return False
140+
spec = find_by_name(provider_name)
141+
provider = config.get_provider(model)
142+
if spec is None or provider is None:
143+
return False
144+
if spec.backend == "azure_openai":
145+
return bool(provider.api_key and provider.api_base)
146+
if spec.is_oauth:
147+
return True
148+
if spec.is_local:
149+
return bool(provider.api_base or spec.default_api_base)
150+
if spec.name == "custom":
151+
return bool(provider.api_base)
152+
if spec.is_direct:
153+
return bool(provider.api_key or provider.api_base or spec.default_api_base or spec.name == "bedrock")
154+
return bool(provider.api_key)
155+
156+
133157
def needs_onboarding(config: Config) -> bool:
134-
"""Return True when Placet is enabled but no LLM provider key is set."""
158+
"""Return True when Placet is enabled but no usable LLM provider is set."""
135159
placet = _get_placet_cfg(config)
136160
if not (placet.get("enabled") or placet.get("instanceUrl")):
137161
return False
162+
return not has_functioning_provider(config)
138163

139-
# Check every provider field for an existing api_key
140-
for field_name in config.providers.model_fields:
141-
p = getattr(config.providers, field_name, None)
142-
if p and getattr(p, "api_key", ""):
143-
return False
144-
return True
164+
165+
def provider_setup_message(*, placet_available: bool) -> str:
166+
"""Build the fallback message shown while provider setup is incomplete."""
167+
lines = [
168+
"No working LLM provider is configured yet.",
169+
"Please complete the provider setup before I can answer normally.",
170+
]
171+
if placet_available:
172+
lines.append("I opened the Placet setup form for you. Submit a valid provider there, then send your message again.")
173+
else:
174+
lines.append("Configure a provider in Facio settings or config, then send your message again.")
175+
return "\n\n".join(lines)
145176

146177

147178
async def run_onboarding(config: Config, config_path: Path) -> bool:

0 commit comments

Comments
 (0)