fix(config): unify config loading chain and protect ${VAR} references

- Settings API: reverse-resolve env vars to preserve ${VAR} refs in yaml,
  write new API keys to .env instead of agentkit.yaml, extract env_key
  from existing ${VAR} reference when updating providers
- Onboarding: merge-update instead of overwrite when config exists,
  use config_arg to determine output path, .env merge instead of overwrite
- Unified templates: bailian-coding provider name, full model_aliases,
  docker-compose with postgres, expanded .env.example
- Optional ruamel.yaml for comment/format preservation in Settings API
- clients.yaml: add _deep_resolve for ${VAR} env var references
- All CLI commands use load_config_with_dotenv() consistently
- Tests: mock find_config_path and CWD auto-discovery to avoid env leaks
This commit is contained in:
chiguyong 2026-06-16 00:26:54 +08:00
parent dcdbfd85f2
commit a27eed3714
15 changed files with 1315 additions and 3539 deletions

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"lastAnalyzedAt": "2026-06-14T10:00:00.000000+00:00", "lastAnalyzedAt": "2026-06-15T06:01:34.200955+00:00",
"gitCommitHash": "d9d1b16e5911ad958cd8ae38958058bea13f3fcc", "gitCommitHash": "64d62a2b60c57fbb1844c1f46c541234c8f9d871",
"version": "1.0.0", "version": "1.0.0",
"analyzedFiles": 208 "analyzedFiles": 2416
} }

View File

@ -5,12 +5,22 @@ server:
rate_limit: 60 rate_limit: 60
llm: llm:
providers: providers:
test: bailian-coding:
type: openai type: openai
base_url: '' base_url: https://coding.dashscope.aliyuncs.com/v1
max_tokens: 4096 api_key: "${DASHSCOPE_API_KEY}"
timeout: 120.0 models:
api_key: '' qwen3.7-plus: {alias: default}
qwen3-coder-plus: {alias: coding}
qwen3-max-2026-01-23: {alias: powerful}
qwen-turbo: {alias: fast}
deepseek:
type: openai
base_url: https://api.deepseek.com/v1
api_key: "${DEEPSEEK_API_KEY}"
models:
deepseek-chat: {alias: chat}
deepseek-reasoner: {alias: reasoning}
model_aliases: model_aliases:
default: bailian-coding/qwen3.7-plus default: bailian-coding/qwen3.7-plus
fast: bailian-coding/qwen-turbo fast: bailian-coding/qwen-turbo
@ -18,23 +28,9 @@ llm:
coding: bailian-coding/qwen3-coder-plus coding: bailian-coding/qwen3-coder-plus
chat: deepseek/deepseek-chat chat: deepseek/deepseek-chat
reasoning: deepseek/deepseek-reasoner reasoning: deepseek/deepseek-reasoner
session: session: {backend: memory}
backend: memory bus: {backend: memory}
bus: task_store: {backend: memory}
backend: memory skills: {auto_discover: true, paths: ["./configs/skills"]}
task_store: logging: {level: INFO, format: text}
backend: memory router: {classifier: heuristic, auction_enabled: false}
skills:
auto_discover: true
paths:
- ./configs/skills
logging:
level: INFO
format: text
router:
classifier: heuristic
auction_enabled: false
semantic:
enabled: true
similarity_high: 0.85
similarity_low: 0.4

View File

@ -15,7 +15,7 @@ import os
from fastapi import FastAPI from fastapi import FastAPI
from agentkit.server.app import _build_llm_gateway, create_app from agentkit.server.app import _build_llm_gateway, create_app
from agentkit.server.config import ServerConfig from agentkit.server.config import ServerConfig, find_config_path, load_config_with_dotenv
from agentkit.skills.loader import SkillLoader from agentkit.skills.loader import SkillLoader
from agentkit.skills.registry import SkillRegistry from agentkit.skills.registry import SkillRegistry
from agentkit.tools.registry import ToolRegistry from agentkit.tools.registry import ToolRegistry
@ -27,16 +27,13 @@ logger = logging.getLogger(__name__)
CONFIGS_DIR = os.path.dirname(os.path.abspath(__file__)) CONFIGS_DIR = os.path.dirname(os.path.abspath(__file__))
SKILLS_DIR = os.path.join(CONFIGS_DIR, "skills") SKILLS_DIR = os.path.join(CONFIGS_DIR, "skills")
# 查找 agentkit.yaml项目根目录 > configs 目录
_PROJECT_ROOT = os.path.dirname(CONFIGS_DIR)
_AGENTKIT_YAML = os.path.join(_PROJECT_ROOT, "agentkit.yaml")
def _load_server_config() -> ServerConfig: def _load_server_config() -> ServerConfig:
"""Load ServerConfig from agentkit.yaml with env var resolution.""" """Load ServerConfig from agentkit.yaml with .env resolution."""
if os.path.isfile(_AGENTKIT_YAML): config_path = find_config_path()
return ServerConfig.from_yaml(_AGENTKIT_YAML) if config_path:
raise FileNotFoundError(f"agentkit.yaml not found at {_AGENTKIT_YAML}") return load_config_with_dotenv(config_path)
raise FileNotFoundError("agentkit.yaml not found (searched CWD and ~/.agentkit/)")
def _init_tool_registry() -> ToolRegistry: def _init_tool_registry() -> ToolRegistry:

View File

@ -46,7 +46,7 @@ async def _chat_async(
) -> None: ) -> None:
"""Async implementation of the chat command.""" """Async implementation of the chat command."""
from agentkit.cli.onboarding import run_onboarding from agentkit.cli.onboarding import run_onboarding
from agentkit.server.config import ServerConfig, find_config_path from agentkit.server.config import find_config_path
# ── Onboarding check ────────────────────────────────────────── # ── Onboarding check ──────────────────────────────────────────
config_path = find_config_path(config_arg) config_path = find_config_path(config_arg)
@ -59,14 +59,9 @@ async def _chat_async(
# ── Load config ─────────────────────────────────────────────── # ── Load config ───────────────────────────────────────────────
rprint(f"[dim]Loading config from {config_path}[/dim]") rprint(f"[dim]Loading config from {config_path}[/dim]")
# Load .env from agentkit.server.config import load_config_with_dotenv
from pathlib import Path
dotenv = Path(config_path).parent / ".env" server_config = load_config_with_dotenv(config_path)
if dotenv.exists():
_load_dotenv(str(dotenv))
server_config = ServerConfig.from_yaml(config_path)
# ── Build in-process components ─────────────────────────────── # ── Build in-process components ───────────────────────────────
from agentkit.session.manager import SessionManager from agentkit.session.manager import SessionManager
@ -380,27 +375,6 @@ def _resolve_default_model(server_config: "ServerConfig") -> str:
return "default" return "default"
def _load_dotenv(dotenv_path: str) -> None:
"""Load .env file into environment."""
from pathlib import Path
path = Path(dotenv_path)
if not path.exists():
return
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip("\"'")
if key and key not in os.environ:
os.environ[key] = value
def _print_help() -> None: def _print_help() -> None:
"""Print chat command help.""" """Print chat command help."""
rprint( rprint(

View File

@ -12,21 +12,27 @@ app = typer.Typer(
) )
from agentkit.cli.task import task_app # noqa: E402 from agentkit.cli.task import task_app # noqa: E402
app.add_typer(task_app, name="task") app.add_typer(task_app, name="task")
from agentkit.cli.skill import skill_app # noqa: E402 from agentkit.cli.skill import skill_app # noqa: E402
app.add_typer(skill_app, name="skill") app.add_typer(skill_app, name="skill")
from agentkit.cli.init import init # noqa: E402 from agentkit.cli.init import init # noqa: E402
app.command(name="init")(init) app.command(name="init")(init)
from agentkit.cli.usage import usage # noqa: E402 from agentkit.cli.usage import usage # noqa: E402
app.command(name="usage")(usage) app.command(name="usage")(usage)
from agentkit.cli.pair import pair # noqa: E402 from agentkit.cli.pair import pair # noqa: E402
app.command(name="pair")(pair) app.command(name="pair")(pair)
from agentkit.cli.chat import chat # noqa: E402 from agentkit.cli.chat import chat # noqa: E402
app.command(name="chat")(chat) app.command(name="chat")(chat)
@ -39,11 +45,10 @@ def gui(
): ):
"""Start AgentKit with a web UI for chatting with your Agent""" """Start AgentKit with a web UI for chatting with your Agent"""
import os import os
import sys
import webbrowser import webbrowser
import uvicorn import uvicorn
from agentkit.server.config import ServerConfig, find_config_path from agentkit.server.config import find_config_path, load_config_with_dotenv
from agentkit.cli.onboarding import run_onboarding from agentkit.cli.onboarding import run_onboarding
# When port=0, default to localhost for security (desktop client mode) # When port=0, default to localhost for security (desktop client mode)
@ -56,6 +61,7 @@ def gui(
if config_path is None: if config_path is None:
rprint("[yellow]No agentkit.yaml found.[/yellow]") rprint("[yellow]No agentkit.yaml found.[/yellow]")
from rich.prompt import Confirm from rich.prompt import Confirm
if Confirm.ask("Would you like to run the setup wizard?", default=True): if Confirm.ask("Would you like to run the setup wizard?", default=True):
config_path = run_onboarding(config_arg=config) config_path = run_onboarding(config_arg=config)
if config_path is None: if config_path is None:
@ -66,12 +72,7 @@ def gui(
server_config = None server_config = None
if config_path: if config_path:
rprint(f"[green]Loading config from {config_path}[/green]") rprint(f"[green]Loading config from {config_path}[/green]")
server_config = ServerConfig.from_yaml(config_path) server_config = load_config_with_dotenv(config_path)
from pathlib import Path
dotenv = Path(config_path).parent / ".env"
server_config.load_dotenv(str(dotenv))
server_config = ServerConfig.from_yaml(config_path)
os.environ["AGENTKIT_CONFIG_PATH"] = config_path os.environ["AGENTKIT_CONFIG_PATH"] = config_path
@ -79,14 +80,15 @@ def gui(
if not server_config.has_llm_provider(): if not server_config.has_llm_provider():
rprint("[yellow]No LLM API key configured.[/yellow]") rprint("[yellow]No LLM API key configured.[/yellow]")
from rich.prompt import Confirm from rich.prompt import Confirm
if Confirm.ask("Would you like to run the setup wizard?", default=True): if Confirm.ask("Would you like to run the setup wizard?", default=True):
config_path = run_onboarding(config_arg=config) config_path = run_onboarding(config_arg=config)
if config_path is None: if config_path is None:
rprint("[red]Setup cancelled. GUI may not function correctly without API key.[/red]") rprint(
"[red]Setup cancelled. GUI may not function correctly without API key.[/red]"
)
else: else:
server_config = ServerConfig.from_yaml(config_path) server_config = load_config_with_dotenv(config_path)
server_config.load_dotenv(str(dotenv))
server_config = ServerConfig.from_yaml(config_path)
os.environ["AGENTKIT_CONFIG_PATH"] = config_path os.environ["AGENTKIT_CONFIG_PATH"] = config_path
else: else:
rprint("[dim]Continuing without LLM provider — chat will not work.[/dim]") rprint("[dim]Continuing without LLM provider — chat will not work.[/dim]")
@ -101,15 +103,19 @@ def gui(
if not no_open and browser_url: if not no_open and browser_url:
import threading import threading
def _open_browser(): def _open_browser():
import time import time
time.sleep(2.0) time.sleep(2.0)
webbrowser.open(browser_url) webbrowser.open(browser_url)
threading.Thread(target=_open_browser, daemon=True).start() threading.Thread(target=_open_browser, daemon=True).start()
# Create app directly (not factory mode) so server_config with resolved API keys # Create app directly (not factory mode) so server_config with resolved API keys
# is passed through without relying on env var inheritance in multiprocessing. # is passed through without relying on env var inheritance in multiprocessing.
from agentkit.server.app import create_app from agentkit.server.app import create_app
app = create_app(server_config=server_config) app = create_app(server_config=server_config)
if port == 0: if port == 0:
@ -152,14 +158,20 @@ def serve(
workers: int = typer.Option(1, "--workers", help="Number of workers"), workers: int = typer.Option(1, "--workers", help="Number of workers"),
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"), reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"), config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
task_store_backend: Optional[str] = typer.Option(None, "--task-store-backend", help="Task store backend: memory or redis"), task_store_backend: Optional[str] = typer.Option(
task_store_redis_url: Optional[str] = typer.Option(None, "--task-store-redis-url", help="Redis URL for task store (only used when backend=redis)"), None, "--task-store-backend", help="Task store backend: memory or redis"
),
task_store_redis_url: Optional[str] = typer.Option(
None,
"--task-store-redis-url",
help="Redis URL for task store (only used when backend=redis)",
),
): ):
"""Start the AgentKit server""" """Start the AgentKit server"""
import uvicorn import uvicorn
from agentkit.server.config import ServerConfig, find_config_path from agentkit.server.config import find_config_path, load_config_with_dotenv
from agentkit.cli.onboarding import needs_onboarding, run_onboarding from agentkit.cli.onboarding import run_onboarding
# Load .env file if present # Load .env file if present
config_path = find_config_path(config) config_path = find_config_path(config)
@ -168,6 +180,7 @@ def serve(
if config_path is None: if config_path is None:
rprint("[yellow]No agentkit.yaml found.[/yellow]") rprint("[yellow]No agentkit.yaml found.[/yellow]")
from rich.prompt import Confirm from rich.prompt import Confirm
if Confirm.ask("Would you like to run the setup wizard?", default=True): if Confirm.ask("Would you like to run the setup wizard?", default=True):
config_path = run_onboarding(config_arg=config) config_path = run_onboarding(config_arg=config)
if config_path is None: if config_path is None:
@ -177,28 +190,21 @@ def serve(
if config_path: if config_path:
rprint(f"[green]Loading config from {config_path}[/green]") rprint(f"[green]Loading config from {config_path}[/green]")
server_config = ServerConfig.from_yaml(config_path) server_config = load_config_with_dotenv(config_path)
# Load .env file for env var resolution
from pathlib import Path
dotenv = Path(config_path).parent / ".env"
server_config.load_dotenv(str(dotenv))
# Re-load config after .env is loaded (env vars now available)
server_config = ServerConfig.from_yaml(config_path)
# Check if LLM API key is configured # Check if LLM API key is configured
if not server_config.has_llm_provider(): if not server_config.has_llm_provider():
rprint("[yellow]No LLM API key configured.[/yellow]") rprint("[yellow]No LLM API key configured.[/yellow]")
from rich.prompt import Confirm from rich.prompt import Confirm
if Confirm.ask("Would you like to run the setup wizard?", default=True): if Confirm.ask("Would you like to run the setup wizard?", default=True):
config_path = run_onboarding(config_arg=config) config_path = run_onboarding(config_arg=config)
if config_path is None: if config_path is None:
rprint("[red]Setup cancelled. Server may not function correctly without API key.[/red]") rprint(
"[red]Setup cancelled. Server may not function correctly without API key.[/red]"
)
else: else:
server_config = ServerConfig.from_yaml(config_path) server_config = load_config_with_dotenv(config_path)
server_config.load_dotenv(str(dotenv))
server_config = ServerConfig.from_yaml(config_path)
else: else:
rprint("[dim]Continuing without LLM provider — API calls will fail.[/dim]") rprint("[dim]Continuing without LLM provider — API calls will fail.[/dim]")
@ -216,6 +222,7 @@ def serve(
# Store config for app factory # Store config for app factory
import os import os
import json as _json import json as _json
os.environ["AGENTKIT_CONFIG_PATH"] = config_path os.environ["AGENTKIT_CONFIG_PATH"] = config_path
# Pass task_store overrides via env var so create_app can read them # Pass task_store overrides via env var so create_app can read them
if server_config.task_store: if server_config.task_store:
@ -233,6 +240,7 @@ def serve(
# Apply CLI task_store overrides even without config file # Apply CLI task_store overrides even without config file
import os import os
import json as _json import json as _json
ts_override: dict = {} ts_override: dict = {}
if task_store_backend is not None: if task_store_backend is not None:
ts_override["backend"] = task_store_backend ts_override["backend"] = task_store_backend
@ -258,6 +266,7 @@ def version():
"""Show AgentKit version""" """Show AgentKit version"""
try: try:
from importlib.metadata import version as get_version from importlib.metadata import version as get_version
v = get_version("fischer-agentkit") v = get_version("fischer-agentkit")
except Exception: except Exception:
v = "0.1.0 (dev)" v = "0.1.0 (dev)"

View File

@ -109,8 +109,18 @@ PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
def needs_onboarding(config_arg: str | None = None) -> bool: def needs_onboarding(config_arg: str | None = None) -> bool:
"""Check if onboarding is needed (no config file found).""" """Check if onboarding is needed (no config file found or no valid LLM provider)."""
return find_config_path(config_arg) is None config_path = find_config_path(config_arg)
if config_path is None:
return True
# Config exists but has no valid LLM provider — needs onboarding to add one
from agentkit.server.config import load_config_with_dotenv
try:
config = load_config_with_dotenv(config_path)
return not config.has_llm_provider()
except Exception:
return True
def run_onboarding( def run_onboarding(
@ -119,20 +129,48 @@ def run_onboarding(
) -> str | None: ) -> str | None:
"""Run the interactive onboarding wizard. """Run the interactive onboarding wizard.
Returns: If agentkit.yaml already exists, only the LLM section is updated
Path to the generated config file, or None if cancelled. (preserving all other settings). If it doesn't exist, a full config
""" is generated.
rprint(Panel(
"[bold]Welcome to AgentKit![/bold]\n\n"
"No configuration file found. Let's set up your first Agent.\n"
"This will create [cyan]agentkit.yaml[/cyan] and [cyan].env[/cyan] for you.",
title="AgentKit Setup",
border_style="bright_blue",
))
output_path = Path(output_dir).resolve() Returns:
Path to the generated/updated config file, or None if cancelled.
"""
# Determine output directory from config_arg if provided
if config_arg:
output_path = Path(config_arg).resolve().parent
else:
output_path = Path(output_dir).resolve()
output_path.mkdir(parents=True, exist_ok=True) output_path.mkdir(parents=True, exist_ok=True)
existing_config_path = find_config_path(config_arg)
existing_config: dict[str, Any] | None = None
if existing_config_path:
with open(existing_config_path, encoding="utf-8") as f:
existing_config = yaml.safe_load(f) or {}
if existing_config:
rprint(
Panel(
"[bold]AgentKit Configuration Update[/bold]\n\n"
"An [cyan]agentkit.yaml[/cyan] already exists but has no valid LLM provider.\n"
"This wizard will [green]update[/green] the LLM section while preserving\n"
"your existing settings.",
title="AgentKit Setup",
border_style="bright_blue",
)
)
else:
rprint(
Panel(
"[bold]Welcome to AgentKit![/bold]\n\n"
"No configuration file found. Let's set up your first Agent.\n"
"This will create [cyan]agentkit.yaml[/cyan] and [cyan].env[/cyan] for you.",
title="AgentKit Setup",
border_style="bright_blue",
)
)
# ── Step 1: Choose LLM provider ────────────────────────────── # ── Step 1: Choose LLM provider ──────────────────────────────
rprint("\n[bold]Step 1: Choose your LLM provider[/bold]") rprint("\n[bold]Step 1: Choose your LLM provider[/bold]")
provider_keys = list(PROVIDER_PRESETS.keys()) provider_keys = list(PROVIDER_PRESETS.keys())
@ -169,12 +207,16 @@ def run_onboarding(
for i, model in enumerate(available_models, 1): for i, model in enumerate(available_models, 1):
alias = preset["models"][model].get("alias", "") alias = preset["models"][model].get("alias", "")
alias_str = f" [dim]({alias})[/dim]" if alias else "" alias_str = f" [dim]({alias})[/dim]" if alias else ""
recommended = " [green]← recommended[/green]" if model == preset.get("default_model") else "" recommended = (
" [green]← recommended[/green]" if model == preset.get("default_model") else ""
)
rprint(f" [cyan]{i}[/cyan]. {model}{alias_str}{recommended}") rprint(f" [cyan]{i}[/cyan]. {model}{alias_str}{recommended}")
model_choice = Prompt.ask( model_choice = Prompt.ask(
"Select default model", "Select default model",
choices=[str(i) for i in range(1, len(available_models) + 1)], choices=[str(i) for i in range(1, len(available_models) + 1)],
default=str(available_models.index(preset.get("default_model", available_models[0])) + 1), default=str(
available_models.index(preset.get("default_model", available_models[0])) + 1
),
) )
selected_model = available_models[int(model_choice) - 1] selected_model = available_models[int(model_choice) - 1]
# Rebuild models dict: selected model gets "default" alias # Rebuild models dict: selected model gets "default" alias
@ -184,7 +226,9 @@ def run_onboarding(
updated_models[model] = {**conf, "alias": "default"} updated_models[model] = {**conf, "alias": "default"}
else: else:
# Remove "default" alias from other models # Remove "default" alias from other models
updated_models[model] = {k: v for k, v in conf.items() if k != "alias" or v != "default"} updated_models[model] = {
k: v for k, v in conf.items() if k != "alias" or v != "default"
}
preset = {**preset, "models": updated_models} preset = {**preset, "models": updated_models}
rprint(f"[green]Selected: {selected_model}[/green]") rprint(f"[green]Selected: {selected_model}[/green]")
else: else:
@ -200,7 +244,11 @@ def run_onboarding(
"models": preset["models"], "models": preset["models"],
} }
} }
model_aliases: dict[str, str] = {alias: f"{selected_key}/{model}" for model, conf in preset["models"].items() if (alias := conf.get("alias"))} model_aliases: dict[str, str] = {
alias: f"{selected_key}/{model}"
for model, conf in preset["models"].items()
if (alias := conf.get("alias"))
}
if Confirm.ask("\nWould you like to add a second LLM provider (for fallback)?", default=False): if Confirm.ask("\nWould you like to add a second LLM provider (for fallback)?", default=False):
remaining = [k for k in provider_keys if k != selected_key] remaining = [k for k in provider_keys if k != selected_key]
@ -228,38 +276,47 @@ def run_onboarding(
if alias and alias not in model_aliases: if alias and alias not in model_aliases:
model_aliases[alias] = f"{key2}/{model}" model_aliases[alias] = f"{key2}/{model}"
# ── Step 4: Generate config files ───────────────────────────── # ── Step 4: Generate/update config files ─────────────────────────
rprint("\n[bold]Step 3: Generating configuration...[/bold]") rprint("\n[bold]Step 3: Generating configuration...[/bold]")
config = { if existing_config:
"server": { # Merge: only update LLM section, preserve everything else
"host": "0.0.0.0", if "llm" not in existing_config:
"port": 8001, existing_config["llm"] = {}
"workers": 1, existing_config["llm"]["providers"] = providers_config
"rate_limit": 60, existing_config["llm"]["model_aliases"] = model_aliases
}, config = existing_config
"llm": { else:
"providers": providers_config, # New config — generate full template
"model_aliases": model_aliases, config = {
}, "server": {
"session": { "host": "0.0.0.0",
"backend": "memory", "port": 8001,
}, "workers": 1,
"bus": { "rate_limit": 60,
"backend": "memory", },
}, "llm": {
"task_store": { "providers": providers_config,
"backend": "memory", "model_aliases": model_aliases,
}, },
"skills": { "session": {
"auto_discover": True, "backend": "memory",
"paths": ["./skills"], },
}, "bus": {
"logging": { "backend": "memory",
"level": "INFO", },
"format": "text", "task_store": {
}, "backend": "memory",
} },
"skills": {
"auto_discover": True,
"paths": ["./configs/skills"],
},
"logging": {
"level": "INFO",
"format": "text",
},
}
# Write agentkit.yaml # Write agentkit.yaml
config_path = output_path / "agentkit.yaml" config_path = output_path / "agentkit.yaml"
@ -267,14 +324,28 @@ def run_onboarding(
yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False) yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
rprint(f" [green]Created:[/green] {config_path}") rprint(f" [green]Created:[/green] {config_path}")
# Write .env # Write .env (merge with existing)
env_path = output_path / ".env" env_path = output_path / ".env"
env_lines = [f"{k}={v}" for k, v in env_vars.items()] existing_env: dict[str, str] = {}
existing_env_lines: list[str] = []
if env_path.exists():
with open(env_path, encoding="utf-8") as f:
existing_env_lines = f.readlines()
for line in existing_env_lines:
stripped = line.strip()
if stripped and not stripped.startswith("#") and "=" in stripped:
k, _, v = stripped.partition("=")
existing_env[k.strip()] = v.strip().strip("\"'")
# Merge new env vars (new values take precedence)
existing_env.update(env_vars)
with open(env_path, "w", encoding="utf-8") as f: with open(env_path, "w", encoding="utf-8") as f:
f.write("# AgentKit Environment Variables\n") f.write("# AgentKit Environment Variables\n")
f.write("# Generated by onboarding wizard\n\n") f.write("# Generated by onboarding wizard\n\n")
f.write("\n".join(env_lines) + "\n") for k, v in existing_env.items():
rprint(f" [green]Created:[/green] {env_path}") f.write(f"{k}={v}\n")
rprint(f" [green]Updated:[/green] {env_path}")
# ── Step 4: Agent personality (optional) ────────────────────── # ── Step 4: Agent personality (optional) ──────────────────────
rprint("\n[bold]Step 4: Customize your Agent (optional)[/bold]") rprint("\n[bold]Step 4: Customize your Agent (optional)[/bold]")
@ -286,6 +357,7 @@ def run_onboarding(
# Create SOUL.md # Create SOUL.md
from agentkit.memory.profile import MemoryStore from agentkit.memory.profile import MemoryStore
memory_store = MemoryStore(base_dir=Path.home() / ".agentkit") memory_store = MemoryStore(base_dir=Path.home() / ".agentkit")
soul_content = f"""## 身份 soul_content = f"""## 身份
我是{agent_name}一个专业的 AI 助手 我是{agent_name}一个专业的 AI 助手
@ -304,12 +376,14 @@ def run_onboarding(
memory_store.get_file("soul").write(soul_content.strip()) memory_store.get_file("soul").write(soul_content.strip())
rprint(" [green]Created:[/green] ~/.agentkit/SOUL.md") rprint(" [green]Created:[/green] ~/.agentkit/SOUL.md")
rprint(Panel( rprint(
"[bold green]Setup complete![/bold green]\n\n" Panel(
"You can now run:\n" "[bold green]Setup complete![/bold green]\n\n"
" [cyan]agentkit chat[/cyan] — Start chatting with your Agent\n" "You can now run:\n"
" [cyan]agentkit serve[/cyan] — Start the API server", " [cyan]agentkit chat[/cyan] — Start chatting with your Agent\n"
border_style="green", " [cyan]agentkit serve[/cyan] — Start the API server",
)) border_style="green",
)
)
return str(config_path) return str(config_path)

View File

@ -14,12 +14,16 @@ task_app = typer.Typer(name="task", help="Task management commands", no_args_is_
@task_app.command("submit") @task_app.command("submit")
def submit( def submit(
input: Optional[str] = typer.Option(None, "--input", "-i", help="Input data as JSON string"), input: Optional[str] = typer.Option(None, "--input", "-i", help="Input data as JSON string"),
input_file: Optional[str] = typer.Option(None, "--input-file", "-f", help="Input data from JSON file"), input_file: Optional[str] = typer.Option(
None, "--input-file", "-f", help="Input data from JSON file"
),
skill: Optional[str] = typer.Option(None, "--skill", "-s", help="Skill name"), skill: Optional[str] = typer.Option(None, "--skill", "-s", help="Skill name"),
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent name"), agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent name"),
mode: str = typer.Option("sync", "--mode", "-m", help="Execution mode: sync or async"), mode: str = typer.Option("sync", "--mode", "-m", help="Execution mode: sync or async"),
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"), server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml (local mode)"), config: Optional[str] = typer.Option(
None, "--config", help="Path to agentkit.yaml (local mode)"
),
): ):
"""Submit a task for execution""" """Submit a task for execution"""
# Parse input data # Parse input data
@ -43,23 +47,28 @@ def submit(
def _submit_remote(input_data, skill, agent, mode, server_url): def _submit_remote(input_data, skill, agent, mode, server_url):
"""Submit task to a remote AgentKit server.""" """Submit task to a remote AgentKit server."""
from agentkit.server.client import AgentKitClient from agentkit.server.client import AgentKitClient
client = AgentKitClient(base_url=server_url) client = AgentKitClient(base_url=server_url)
if mode == "async": if mode == "async":
result = asyncio.run(client.submit_task_async( result = asyncio.run(
input_data=input_data, client.submit_task_async(
skill_name=skill, input_data=input_data,
agent_name=agent, skill_name=skill,
)) agent_name=agent,
)
)
rprint("[green]Task submitted (async)[/green]") rprint("[green]Task submitted (async)[/green]")
rprint(f" Task ID: {result.get('task_id', 'N/A')}") rprint(f" Task ID: {result.get('task_id', 'N/A')}")
rprint(f" Status: {result.get('status', 'N/A')}") rprint(f" Status: {result.get('status', 'N/A')}")
else: else:
result = asyncio.run(client.submit_task( result = asyncio.run(
input_data=input_data, client.submit_task(
skill_name=skill, input_data=input_data,
agent_name=agent, skill_name=skill,
)) agent_name=agent,
)
)
rprint("[green]Task completed[/green]") rprint("[green]Task completed[/green]")
if "output_data" in result: if "output_data" in result:
rprint(json.dumps(result["output_data"], indent=2, ensure_ascii=False)) rprint(json.dumps(result["output_data"], indent=2, ensure_ascii=False))
@ -67,19 +76,18 @@ def _submit_remote(input_data, skill, agent, mode, server_url):
def _submit_local(input_data, skill, agent, mode, config_path): def _submit_local(input_data, skill, agent, mode, config_path):
"""Submit task locally without a running server.""" """Submit task locally without a running server."""
from agentkit.server.config import ServerConfig, find_config_path from agentkit.server.config import find_config_path, load_config_with_dotenv
# Load config # Load config
resolved_path = find_config_path(config_path) resolved_path = find_config_path(config_path)
if resolved_path: if resolved_path:
server_config = ServerConfig.from_yaml(resolved_path) server_config = load_config_with_dotenv(resolved_path)
server_config.load_dotenv()
server_config = ServerConfig.from_yaml(resolved_path)
else: else:
server_config = None server_config = None
# Build app components # Build app components
from agentkit.server.app import create_app from agentkit.server.app import create_app
app = create_app(server_config=server_config) app = create_app(server_config=server_config)
# Execute task through the app's agent pool # Execute task through the app's agent pool
@ -90,7 +98,9 @@ def _submit_local(input_data, skill, agent, mode, config_path):
# Determine which skill/agent to use # Determine which skill/agent to use
if skill: if skill:
if not skill_registry.has_skill(skill): if not skill_registry.has_skill(skill):
rprint(f"[red]Skill '{skill}' not found. Available: {[s.name for s in skill_registry.list_skills()]}[/red]") rprint(
f"[red]Skill '{skill}' not found. Available: {[s.name for s in skill_registry.list_skills()]}[/red]"
)
raise typer.Exit(code=1) raise typer.Exit(code=1)
skill_obj = skill_registry.get(skill) skill_obj = skill_registry.get(skill)
agent_name = skill_obj.name agent_name = skill_obj.name
@ -102,9 +112,10 @@ def _submit_local(input_data, skill, agent, mode, config_path):
# Create agent and execute # Create agent and execute
agent_instance = agent_pool.get_or_create(agent_name) agent_instance = agent_pool.get_or_create(agent_name)
from agentkit.core.protocol import TaskMessage, TaskStatus from agentkit.core.protocol import TaskMessage
from datetime import datetime, timezone from datetime import datetime, timezone
import uuid import uuid
task = TaskMessage( task = TaskMessage(
task_id=str(uuid.uuid4()), task_id=str(uuid.uuid4()),
agent_name=agent_name, agent_name=agent_name,
@ -134,6 +145,7 @@ def status(
raise typer.Exit(code=1) raise typer.Exit(code=1)
from agentkit.server.client import AgentKitClient from agentkit.server.client import AgentKitClient
client = AgentKitClient(base_url=server_url) client = AgentKitClient(base_url=server_url)
result = asyncio.run(client.get_task_status(task_id)) result = asyncio.run(client.get_task_status(task_id))
@ -157,6 +169,7 @@ def list_tasks(
raise typer.Exit(code=1) raise typer.Exit(code=1)
from agentkit.server.client import AgentKitClient from agentkit.server.client import AgentKitClient
client = AgentKitClient(base_url=server_url) client = AgentKitClient(base_url=server_url)
tasks = asyncio.run(client.list_tasks(status=status_filter, limit=limit)) tasks = asyncio.run(client.list_tasks(status=status_filter, limit=limit))
@ -190,6 +203,7 @@ def cancel(
raise typer.Exit(code=1) raise typer.Exit(code=1)
from agentkit.server.client import AgentKitClient from agentkit.server.client import AgentKitClient
client = AgentKitClient(base_url=server_url) client = AgentKitClient(base_url=server_url)
result = asyncio.run(client.cancel_task(task_id)) result = asyncio.run(client.cancel_task(task_id))
rprint(f"[green]Task cancelled[/green]: {result}") rprint(f"[green]Task cancelled[/green]: {result}")

View File

@ -13,18 +13,24 @@ server:
llm: llm:
providers: providers:
dashscope: bailian-coding:
type: openai type: openai
api_key: "${DASHSCOPE_API_KEY}" api_key: "${DASHSCOPE_API_KEY}"
base_url: "https://coding.dashscope.aliyuncs.com/v1" base_url: "https://coding.dashscope.aliyuncs.com/v1"
models: models:
qwen3.7-plus:
alias: default
qwen3-coder-plus: qwen3-coder-plus:
max_tokens: 64000 alias: coding
qwen3-max: qwen3-max-2026-01-23:
max_tokens: 128000 alias: powerful
qwen-turbo:
alias: fast
model_aliases: model_aliases:
default: dashscope/qwen3-coder-plus default: bailian-coding/qwen3.7-plus
powerful: dashscope/qwen3-max coding: bailian-coding/qwen3-coder-plus
powerful: bailian-coding/qwen3-max-2026-01-23
fast: bailian-coding/qwen-turbo
session: session:
backend: memory backend: memory
@ -32,6 +38,9 @@ session:
bus: bus:
backend: memory backend: memory
task_store:
backend: memory
skills: skills:
auto_discover: true auto_discover: true
paths: paths:
@ -40,6 +49,10 @@ skills:
logging: logging:
level: "INFO" level: "INFO"
format: "text" # "text" or "json" format: "text" # "text" or "json"
router:
classifier: heuristic
auction_enabled: false
""" """
ENV_EXAMPLE = """\ ENV_EXAMPLE = """\
@ -49,8 +62,13 @@ ENV_EXAMPLE = """\
# LLM API Keys (at least one required) # LLM API Keys (at least one required)
DASHSCOPE_API_KEY=sk-your-dashscope-key DASHSCOPE_API_KEY=sk-your-dashscope-key
# Optional: additional providers
# DEEPSEEK_API_KEY=sk-your-deepseek-key
# OPENAI_API_KEY=sk-your-openai-key
# ANTHROPIC_API_KEY=sk-your-anthropic-key
# Server (optional) # Server (optional)
AGENTKIT_API_KEY= # Set to enable API key authentication # AGENTKIT_API_KEY= # Set to enable API key authentication
""" """
DOCKER_COMPOSE = """\ DOCKER_COMPOSE = """\
@ -66,6 +84,8 @@ services:
depends_on: depends_on:
redis: redis:
condition: service_healthy condition: service_healthy
postgres:
condition: service_healthy
healthcheck: healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health')"] test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health')"]
interval: 30s interval: 30s
@ -82,6 +102,22 @@ services:
timeout: 5s timeout: 5s
retries: 5 retries: 5
postgres:
image: pgvector/pgvector:pg16
ports:
- "5432:5432"
environment:
POSTGRES_DB: agentkit
POSTGRES_USER: agentkit
POSTGRES_PASSWORD: agentkit
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U agentkit"]
interval: 10s
timeout: 5s
retries: 5
volumes: volumes:
pgdata: pgdata:
""" """

View File

@ -21,7 +21,7 @@ from agentkit.skills.base import Skill
from agentkit.skills.registry import SkillRegistry from agentkit.skills.registry import SkillRegistry
from agentkit.tools.registry import ToolRegistry from agentkit.tools.registry import ToolRegistry
from agentkit.tools.skill_install import SkillInstallTool from agentkit.tools.skill_install import SkillInstallTool
from agentkit.server.config import ServerConfig from agentkit.server.config import ServerConfig, load_dotenv
from agentkit.server.routes import ( from agentkit.server.routes import (
agents, agents,
tasks, tasks,
@ -49,18 +49,6 @@ from agentkit.telemetry.setup import setup_telemetry
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ALLOWED_ENV_PREFIXES = (
"AGENTKIT_",
"DASHSCOPE_",
"OPENAI_",
"ANTHROPIC_",
"GEMINI_",
"TAVILY_",
"SERPER_",
"DEEPSEEK_",
)
_ALLOWED_ENV_EXACT = {"DATABASE_URL", "REDIS_URL"}
def _build_llm_gateway(config: ServerConfig) -> LLMGateway: def _build_llm_gateway(config: ServerConfig) -> LLMGateway:
"""Build LLMGateway from ServerConfig, registering all providers.""" """Build LLMGateway from ServerConfig, registering all providers."""
@ -487,27 +475,10 @@ def create_app(
if config_path and os.path.exists(config_path): if config_path and os.path.exists(config_path):
# Load .env before parsing config (so ${ENV_VAR} substitutions work) # Load .env before parsing config (so ${ENV_VAR} substitutions work)
from pathlib import Path as _P
_dotenv = _P(config_path).parent / ".env" _dotenv = _P(config_path).parent / ".env"
if _dotenv.exists(): load_dotenv(_dotenv)
with open(_dotenv, encoding="utf-8") as _f:
for _line in _f:
_line = _line.strip()
if not _line or _line.startswith("#") or "=" not in _line:
continue
_key, _, _val = _line.partition("=")
_key = _key.strip()
_val = _val.strip().strip("\"'")
if _key and _key not in os.environ:
allowed = (
any(_key.startswith(p) for p in _ALLOWED_ENV_PREFIXES)
or _key in _ALLOWED_ENV_EXACT
)
if not allowed:
logger.warning(
f"Skipping .env variable '{_key}' (not in allowed prefixes)"
)
continue
os.environ[_key] = _val
server_config = ServerConfig.from_yaml(config_path) server_config = ServerConfig.from_yaml(config_path)
app = FastAPI(title="AgentKit Server", version="2.0.0", lifespan=lifespan) app = FastAPI(title="AgentKit Server", version="2.0.0", lifespan=lifespan)

View File

@ -23,7 +23,10 @@ class ClientConfig:
clients_path = os.path.join(self.config_dir, "clients.yaml") clients_path = os.path.join(self.config_dir, "clients.yaml")
if os.path.exists(clients_path): if os.path.exists(clients_path):
with open(clients_path, encoding="utf-8") as f: with open(clients_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
from agentkit.server.config import _deep_resolve
return _deep_resolve(data)
return {} return {}
def reload(self): def reload(self):
@ -49,13 +52,20 @@ class ClientConfig:
return client_info["skills_dir"] return client_info["skills_dir"]
# Fall back to default from agentkit.yaml # Fall back to default from agentkit.yaml
default_config = self._load_default_config() default_config = self._load_default_config()
return default_config.get("skills", {}).get("paths", ["./skills"])[0] if default_config else None return (
default_config.get("skills", {}).get("paths", ["./skills"])[0]
if default_config
else None
)
def _load_default_config(self) -> dict: def _load_default_config(self) -> dict:
config_path = os.path.join(self.config_dir, "agentkit.yaml") config_path = os.path.join(self.config_dir, "agentkit.yaml")
if os.path.exists(config_path): if os.path.exists(config_path):
from agentkit.server.config import _deep_resolve
with open(config_path, encoding="utf-8") as f: with open(config_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {} data = yaml.safe_load(f) or {}
return _deep_resolve(data)
return {} return {}
def validate_api_key(self, api_key: str) -> bool: def validate_api_key(self, api_key: str) -> bool:

View File

@ -4,7 +4,7 @@ import asyncio
import logging import logging
import os import os
import re import re
from dataclasses import dataclass, field from dataclasses import dataclass
from pathlib import Path from pathlib import Path
from typing import Any, Callable from typing import Any, Callable
@ -148,9 +148,9 @@ class ServerConfig:
self._last_mtime: float = 0.0 self._last_mtime: float = 0.0
def has_llm_provider(self) -> bool: def has_llm_provider(self) -> bool:
"""检查是否配置了有效的 LLM ProviderAPI Key 非空)""" """检查是否配置了有效的 LLM ProviderAPI Key 已解析且非空)"""
for name, provider in self.llm_config.providers.items(): for name, provider in self.llm_config.providers.items():
if provider.api_key: if provider.api_key and not provider.api_key.startswith("${"):
return True return True
return False return False
@ -333,25 +333,6 @@ class ServerConfig:
logger.warning(f"Failed to load skill config from {yaml_file}: {e}") logger.warning(f"Failed to load skill config from {yaml_file}: {e}")
return configs return configs
def load_dotenv(self, dotenv_path: str = ".env") -> None:
"""Load environment variables from a .env file (simple key=value format)."""
path = Path(dotenv_path)
if not path.exists():
return
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip("\"'")
if key and key not in os.environ:
os.environ[key] = value
def watch_config(self, config_path: str | None = None) -> None: def watch_config(self, config_path: str | None = None) -> None:
"""Start watching the config file for changes and hot-reload. """Start watching the config file for changes and hot-reload.
@ -376,6 +357,7 @@ class ServerConfig:
try: try:
import watchfiles # noqa: F401 import watchfiles # noqa: F401
self._watcher_task = asyncio.ensure_future(self._watch_with_watchfiles(path)) self._watcher_task = asyncio.ensure_future(self._watch_with_watchfiles(path))
logger.info(f"Config watcher started (watchfiles) for {path}") logger.info(f"Config watcher started (watchfiles) for {path}")
except ImportError: except ImportError:
@ -393,6 +375,7 @@ class ServerConfig:
"""Watch config file using watchfiles library.""" """Watch config file using watchfiles library."""
try: try:
from watchfiles import awatch from watchfiles import awatch
async for changes in awatch(path): async for changes in awatch(path):
for change_type, changed_path in changes: for change_type, changed_path in changes:
logger.info(f"Config file change detected: {change_type} on {changed_path}") logger.info(f"Config file change detected: {change_type} on {changed_path}")
@ -428,7 +411,7 @@ class ServerConfig:
return return
# Validate basic structure: must have at least a server or llm section # Validate basic structure: must have at least a server or llm section
if not hasattr(new_config, 'host') or not hasattr(new_config, 'llm_config'): if not hasattr(new_config, "host") or not hasattr(new_config, "llm_config"):
logger.error(f"Invalid config structure in {path}. Keeping current config.") logger.error(f"Invalid config structure in {path}. Keeping current config.")
return return
@ -464,6 +447,84 @@ class ServerConfig:
logger.error(f"Config on_change callback error: {e}") logger.error(f"Config on_change callback error: {e}")
# ── .env loading ───────────────────────────────────────────────────────
_ALLOWED_ENV_PREFIXES = (
"AGENTKIT_",
"DASHSCOPE_",
"OPENAI_",
"ANTHROPIC_",
"GEMINI_",
"TAVILY_",
"SERPER_",
"DEEPSEEK_",
"DOUBAO_",
)
_ALLOWED_ENV_EXACT = {"DATABASE_URL", "REDIS_URL"}
def load_dotenv(
dotenv_path: str | Path,
*,
allowed_prefixes: tuple[str, ...] | None = None,
allowed_exact: set[str] | None = None,
) -> None:
"""Load environment variables from a .env file.
Only variables matching allowed prefixes or exact names are loaded.
Existing environment variables are never overwritten.
Args:
dotenv_path: Path to the .env file.
allowed_prefixes: Env var prefixes to allow. None = allow all.
allowed_exact: Exact env var names to allow. None = allow all.
"""
path = Path(dotenv_path)
if not path.exists():
return
prefixes = allowed_prefixes
exact = allowed_exact
with open(path, encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
continue
key, _, value = line.partition("=")
key = key.strip()
value = value.strip().strip("\"'")
if not key or key in os.environ:
continue
# Apply allowlist if provided
if prefixes is not None or exact is not None:
allowed = False
if prefixes and any(key.startswith(p) for p in prefixes):
allowed = True
if exact and key in exact:
allowed = True
if not allowed:
logger.warning(f"Skipping .env variable '{key}' (not in allowed list)")
continue
os.environ[key] = value
def load_config_with_dotenv(config_path: str | Path) -> ServerConfig:
"""Load ServerConfig with .env resolution (production-standard loading).
1. Load .env from config file's parent directory
2. Parse agentkit.yaml with env vars resolved
This is the canonical way to load config in all CLI commands and app factory.
"""
config_path = str(config_path)
dotenv = Path(config_path).parent / ".env"
load_dotenv(dotenv)
return ServerConfig.from_yaml(config_path)
def find_config_path(config_arg: str | None = None) -> str | None: def find_config_path(config_arg: str | None = None) -> str | None:
"""Find the agentkit.yaml config file. """Find the agentkit.yaml config file.

View File

@ -1,6 +1,8 @@
"""Settings API routes with config hot-reload support.""" """Settings API routes with config hot-reload support."""
import logging import logging
import os
import re
from typing import Any from typing import Any
import yaml import yaml
@ -16,6 +18,7 @@ router = APIRouter(tags=["settings"])
# Helper: mask API keys (show only last 4 chars) # Helper: mask API keys (show only last 4 chars)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _mask_api_key(key: str | None) -> str: def _mask_api_key(key: str | None) -> str:
"""Mask an API key, showing only the last 4 characters.""" """Mask an API key, showing only the last 4 characters."""
if not key: if not key:
@ -29,6 +32,7 @@ def _mask_api_key(key: str | None) -> str:
# Pydantic models for request/response # Pydantic models for request/response
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
class LlmProviderResponse(BaseModel): class LlmProviderResponse(BaseModel):
name: str name: str
type: str type: str
@ -103,16 +107,88 @@ class GeneralConfigUpdate(BaseModel):
# Helper: read/write config file # Helper: read/write config file
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _read_yaml_config(config_path: str) -> dict: def _read_yaml_config(config_path: str) -> dict:
"""Read the YAML config file and return the parsed dict.""" """Read the YAML config file and return the parsed dict."""
with open(config_path, encoding="utf-8") as f: with open(config_path, encoding="utf-8") as f:
return yaml.safe_load(f) or {} return yaml.safe_load(f) or {}
def _reverse_resolve_env(data: Any, original: Any) -> Any:
"""Reverse-resolve env var references: if original had ${VAR} and current
value matches os.environ[VAR], keep the ${VAR} reference instead of
writing the plaintext value back to YAML."""
if isinstance(original, str) and isinstance(data, str):
# Check if original was an env var reference like ${VAR} or ${VAR:-default}
env_refs = re.findall(r"\$\{([^}]+)\}", original)
for expr in env_refs:
var_name = expr.split(":-")[0] if ":-" in expr else expr
env_val = os.environ.get(var_name)
if env_val is not None and data == env_val:
return original # Keep the ${VAR} reference
# If original was a ${VAR} reference but value doesn't match env, still keep ref
if re.match(r"^\$\{[^}]+\}$", original):
return original
if isinstance(data, dict) and isinstance(original, dict):
result = {}
for k in data:
result[k] = _reverse_resolve_env(data[k], original.get(k))
return result
if isinstance(data, list) and isinstance(original, list):
# For lists: reverse-resolve matching items, keep new items as-is
result = []
for i, item in enumerate(data):
if i < len(original):
result.append(_reverse_resolve_env(item, original[i]))
else:
result.append(item)
return result
# For lists that changed length or type mismatches, return data as-is
return data
def _write_yaml_config(config_path: str, data: dict) -> None: def _write_yaml_config(config_path: str, data: dict) -> None:
"""Write the full config dict back to the YAML file.""" """Write the full config dict back to the YAML file.
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False) Preserves ${VAR} env var references: if the original YAML had ${VAR}
and the resolved value matches os.environ[VAR], the reference is kept
instead of writing the plaintext value.
Uses ruamel.yaml when available to preserve comments and formatting.
Falls back to PyYAML otherwise.
"""
original = _read_yaml_config(config_path)
preserved = _reverse_resolve_env(data, original)
try:
from ruamel.yaml import YAML
yaml_writer = YAML()
yaml_writer.default_flow_style = False
yaml_writer.allow_unicode = True
# Re-read with ruamel to get the commented structure
with open(config_path, encoding="utf-8") as f:
original_data = yaml_writer.load(f)
# Apply preserved values onto the ruamel-parsed structure
_deep_update_ruamel(original_data, preserved)
with open(config_path, "w", encoding="utf-8") as f:
yaml_writer.dump(original_data, f)
except ImportError:
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(preserved, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
def _deep_update_ruamel(target: Any, source: Any) -> None:
"""Deep-update a ruamel.yaml CommentedMap with values from source dict.
Preserves comments and formatting in target while updating values.
"""
if not isinstance(source, dict) or not hasattr(target, "__setitem__"):
return
for key, value in source.items():
if key in target and isinstance(value, dict) and hasattr(target[key], "__setitem__"):
_deep_update_ruamel(target[key], value)
else:
target[key] = value
def _get_config_path(request: Request) -> str: def _get_config_path(request: Request) -> str:
@ -123,10 +199,45 @@ def _get_config_path(request: Request) -> str:
return server_config._config_path return server_config._config_path
def _write_env_var(config_path: str, key: str, value: str) -> None:
"""Write or update an environment variable in the .env file next to config.
If the key already exists in .env, its value is updated in place.
If not, it's appended. Comments and formatting are preserved.
"""
from pathlib import Path
env_path = Path(config_path).parent / ".env"
lines: list[str] = []
found = False
if env_path.exists():
with open(env_path, encoding="utf-8") as f:
lines = f.readlines()
for i, line in enumerate(lines):
stripped = line.strip()
if stripped.startswith(f"{key}="):
lines[i] = f"{key}={value}\n"
found = True
break
else:
lines = ["# AgentKit Environment Variables\n", "\n"]
if not found:
lines.append(f"{key}={value}\n")
with open(env_path, "w", encoding="utf-8") as f:
f.writelines(lines)
# Also set in current process so the next from_yaml resolves correctly
os.environ[key] = value
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# LLM Settings # LLM Settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/settings/llm", response_model=LlmConfigResponse) @router.get("/settings/llm", response_model=LlmConfigResponse)
async def get_llm_settings(request: Request): async def get_llm_settings(request: Request):
"""Return LLM config with masked API keys.""" """Return LLM config with masked API keys."""
@ -137,15 +248,17 @@ async def get_llm_settings(request: Request):
llm_config = config.llm_config llm_config = config.llm_config
providers = [] providers = []
for name, pconf in llm_config.providers.items(): for name, pconf in llm_config.providers.items():
providers.append(LlmProviderResponse( providers.append(
name=name, LlmProviderResponse(
type=pconf.type, name=name,
api_key=_mask_api_key(pconf.api_key), type=pconf.type,
base_url=pconf.base_url or "", api_key=_mask_api_key(pconf.api_key),
models=pconf.models, base_url=pconf.base_url or "",
max_tokens=pconf.max_tokens, models=pconf.models,
timeout=pconf.timeout, max_tokens=pconf.max_tokens,
)) timeout=pconf.timeout,
)
)
return LlmConfigResponse( return LlmConfigResponse(
providers=providers, providers=providers,
@ -156,7 +269,11 @@ async def get_llm_settings(request: Request):
@router.put("/settings/llm", response_model=LlmConfigResponse) @router.put("/settings/llm", response_model=LlmConfigResponse)
async def update_llm_settings(request: Request, update: LlmConfigUpdate): async def update_llm_settings(request: Request, update: LlmConfigUpdate):
"""Update LLM config and trigger hot reload via config file write.""" """Update LLM config and trigger hot reload via config file write.
When a new plaintext API key is provided, it is stored in .env (not
agentkit.yaml) and the yaml reference is set to ${ENV_KEY}.
"""
config_path = _get_config_path(request) config_path = _get_config_path(request)
data = _read_yaml_config(config_path) data = _read_yaml_config(config_path)
@ -174,13 +291,22 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
"timeout": p.timeout, "timeout": p.timeout,
} }
if p.api_key is not None: if p.api_key is not None:
# If the user sends a masked key (****xxxx), don't overwrite
if p.api_key.startswith("****"): if p.api_key.startswith("****"):
# Keep existing key # Masked key — keep existing yaml value (preserves ${VAR} refs)
existing = data.get("llm", {}).get("providers", {}).get(p.name, {}) existing = data.get("llm", {}).get("providers", {}).get(p.name, {})
p_dict["api_key"] = existing.get("api_key", "") p_dict["api_key"] = existing.get("api_key", "")
else: else:
p_dict["api_key"] = p.api_key # New plaintext key — write to .env, keep ${VAR} ref in yaml
# Extract env_key from existing ${VAR} reference if present
existing = data.get("llm", {}).get("providers", {}).get(p.name, {})
existing_key = existing.get("api_key", "")
env_match = re.match(r"^\$\{([^}]+)\}$", str(existing_key))
if env_match:
env_key = env_match.group(1).split(":-")[0]
else:
env_key = f"{p.name.upper().replace('-', '_')}_API_KEY"
_write_env_var(config_path, env_key, p.api_key)
p_dict["api_key"] = f"${{{env_key}}}"
else: else:
# Keep existing key if not provided # Keep existing key if not provided
existing = data.get("llm", {}).get("providers", {}).get(p.name, {}) existing = data.get("llm", {}).get("providers", {}).get(p.name, {})
@ -205,15 +331,17 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
llm_config = config.llm_config llm_config = config.llm_config
providers = [] providers = []
for name, pconf in llm_config.providers.items(): for name, pconf in llm_config.providers.items():
providers.append(LlmProviderResponse( providers.append(
name=name, LlmProviderResponse(
type=pconf.type, name=name,
api_key=_mask_api_key(pconf.api_key), type=pconf.type,
base_url=pconf.base_url or "", api_key=_mask_api_key(pconf.api_key),
models=pconf.models, base_url=pconf.base_url or "",
max_tokens=pconf.max_tokens, models=pconf.models,
timeout=pconf.timeout, max_tokens=pconf.max_tokens,
)) timeout=pconf.timeout,
)
)
return LlmConfigResponse( return LlmConfigResponse(
providers=providers, providers=providers,
@ -226,6 +354,7 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
# Skills Settings # Skills Settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/settings/skills", response_model=SkillsConfigResponse) @router.get("/settings/skills", response_model=SkillsConfigResponse)
async def get_skills_settings(request: Request): async def get_skills_settings(request: Request):
"""Return skill paths config.""" """Return skill paths config."""
@ -266,6 +395,7 @@ async def update_skills_settings(request: Request, update: SkillsConfigUpdate):
# Knowledge Base Settings # Knowledge Base Settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/settings/kb", response_model=KbConfigResponse) @router.get("/settings/kb", response_model=KbConfigResponse)
async def get_kb_settings(request: Request): async def get_kb_settings(request: Request):
"""Return knowledge base connection config.""" """Return knowledge base connection config."""
@ -294,6 +424,7 @@ async def update_kb_settings(request: Request, update: KbConfigUpdate):
# General Settings # General Settings
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@router.get("/settings/general", response_model=GeneralConfigResponse) @router.get("/settings/general", response_model=GeneralConfigResponse)
async def get_general_settings(request: Request): async def get_general_settings(request: Request):
"""Return general settings (log level, server port, etc.).""" """Return general settings (log level, server port, etc.)."""

View File

@ -2,6 +2,9 @@
from __future__ import annotations from __future__ import annotations
import os
import unittest.mock
import pytest import pytest
import yaml import yaml
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
@ -355,7 +358,11 @@ class TestNoConfigPath:
"""When server_config has no _config_path, PUT should return 400.""" """When server_config has no _config_path, PUT should return 400."""
from agentkit.llm.gateway import LLMGateway from agentkit.llm.gateway import LLMGateway
app = create_app(llm_gateway=LLMGateway()) # Mock os.environ to prevent auto-discovery of agentkit.yaml in CWD
with unittest.mock.patch.dict(os.environ, {}, clear=True):
# Also ensure no agentkit.yaml is found in CWD
with unittest.mock.patch("pathlib.Path.exists", return_value=False):
app = create_app(llm_gateway=LLMGateway())
# server_config is None in this case # server_config is None in this case
client = TestClient(app) client = TestClient(app)

View File

@ -66,7 +66,9 @@ class TestServeCommand:
def test_serve_starts_uvicorn(self): def test_serve_starts_uvicorn(self):
"""agentkit serve calls uvicorn.run with correct params""" """agentkit serve calls uvicorn.run with correct params"""
from agentkit.cli.main import app from agentkit.cli.main import app
with patch("uvicorn.run") as mock_run: with patch("uvicorn.run") as mock_run, \
patch("agentkit.server.config.find_config_path", return_value=None), \
patch("rich.prompt.Confirm.ask", return_value=False):
result = runner.invoke(app, ["serve", "--host", "0.0.0.0", "--port", "8001"]) result = runner.invoke(app, ["serve", "--host", "0.0.0.0", "--port", "8001"])
mock_run.assert_called_once() mock_run.assert_called_once()
call_kwargs = mock_run.call_args call_kwargs = mock_run.call_args