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:
parent
dcdbfd85f2
commit
a27eed3714
File diff suppressed because it is too large
Load Diff
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"lastAnalyzedAt": "2026-06-14T10:00:00.000000+00:00",
|
||||
"gitCommitHash": "d9d1b16e5911ad958cd8ae38958058bea13f3fcc",
|
||||
"lastAnalyzedAt": "2026-06-15T06:01:34.200955+00:00",
|
||||
"gitCommitHash": "64d62a2b60c57fbb1844c1f46c541234c8f9d871",
|
||||
"version": "1.0.0",
|
||||
"analyzedFiles": 208
|
||||
"analyzedFiles": 2416
|
||||
}
|
||||
|
|
@ -5,12 +5,22 @@ server:
|
|||
rate_limit: 60
|
||||
llm:
|
||||
providers:
|
||||
test:
|
||||
bailian-coding:
|
||||
type: openai
|
||||
base_url: ''
|
||||
max_tokens: 4096
|
||||
timeout: 120.0
|
||||
api_key: ''
|
||||
base_url: https://coding.dashscope.aliyuncs.com/v1
|
||||
api_key: "${DASHSCOPE_API_KEY}"
|
||||
models:
|
||||
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:
|
||||
default: bailian-coding/qwen3.7-plus
|
||||
fast: bailian-coding/qwen-turbo
|
||||
|
|
@ -18,23 +28,9 @@ llm:
|
|||
coding: bailian-coding/qwen3-coder-plus
|
||||
chat: deepseek/deepseek-chat
|
||||
reasoning: deepseek/deepseek-reasoner
|
||||
session:
|
||||
backend: memory
|
||||
bus:
|
||||
backend: memory
|
||||
task_store:
|
||||
backend: memory
|
||||
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
|
||||
session: {backend: memory}
|
||||
bus: {backend: memory}
|
||||
task_store: {backend: memory}
|
||||
skills: {auto_discover: true, paths: ["./configs/skills"]}
|
||||
logging: {level: INFO, format: text}
|
||||
router: {classifier: heuristic, auction_enabled: false}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ import os
|
|||
from fastapi import FastAPI
|
||||
|
||||
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.registry import SkillRegistry
|
||||
from agentkit.tools.registry import ToolRegistry
|
||||
|
|
@ -27,16 +27,13 @@ logger = logging.getLogger(__name__)
|
|||
CONFIGS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
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:
|
||||
"""Load ServerConfig from agentkit.yaml with env var resolution."""
|
||||
if os.path.isfile(_AGENTKIT_YAML):
|
||||
return ServerConfig.from_yaml(_AGENTKIT_YAML)
|
||||
raise FileNotFoundError(f"agentkit.yaml not found at {_AGENTKIT_YAML}")
|
||||
"""Load ServerConfig from agentkit.yaml with .env resolution."""
|
||||
config_path = find_config_path()
|
||||
if config_path:
|
||||
return load_config_with_dotenv(config_path)
|
||||
raise FileNotFoundError("agentkit.yaml not found (searched CWD and ~/.agentkit/)")
|
||||
|
||||
|
||||
def _init_tool_registry() -> ToolRegistry:
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ async def _chat_async(
|
|||
) -> None:
|
||||
"""Async implementation of the chat command."""
|
||||
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 ──────────────────────────────────────────
|
||||
config_path = find_config_path(config_arg)
|
||||
|
|
@ -59,14 +59,9 @@ async def _chat_async(
|
|||
# ── Load config ───────────────────────────────────────────────
|
||||
rprint(f"[dim]Loading config from {config_path}[/dim]")
|
||||
|
||||
# Load .env
|
||||
from pathlib import Path
|
||||
from agentkit.server.config import load_config_with_dotenv
|
||||
|
||||
dotenv = Path(config_path).parent / ".env"
|
||||
if dotenv.exists():
|
||||
_load_dotenv(str(dotenv))
|
||||
|
||||
server_config = ServerConfig.from_yaml(config_path)
|
||||
server_config = load_config_with_dotenv(config_path)
|
||||
|
||||
# ── Build in-process components ───────────────────────────────
|
||||
from agentkit.session.manager import SessionManager
|
||||
|
|
@ -380,27 +375,6 @@ def _resolve_default_model(server_config: "ServerConfig") -> str:
|
|||
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:
|
||||
"""Print chat command help."""
|
||||
rprint(
|
||||
|
|
|
|||
|
|
@ -12,21 +12,27 @@ app = typer.Typer(
|
|||
)
|
||||
|
||||
from agentkit.cli.task import task_app # noqa: E402
|
||||
|
||||
app.add_typer(task_app, name="task")
|
||||
|
||||
from agentkit.cli.skill import skill_app # noqa: E402
|
||||
|
||||
app.add_typer(skill_app, name="skill")
|
||||
|
||||
from agentkit.cli.init import init # noqa: E402
|
||||
|
||||
app.command(name="init")(init)
|
||||
|
||||
from agentkit.cli.usage import usage # noqa: E402
|
||||
|
||||
app.command(name="usage")(usage)
|
||||
|
||||
from agentkit.cli.pair import pair # noqa: E402
|
||||
|
||||
app.command(name="pair")(pair)
|
||||
|
||||
from agentkit.cli.chat import chat # noqa: E402
|
||||
|
||||
app.command(name="chat")(chat)
|
||||
|
||||
|
||||
|
|
@ -39,11 +45,10 @@ def gui(
|
|||
):
|
||||
"""Start AgentKit with a web UI for chatting with your Agent"""
|
||||
import os
|
||||
import sys
|
||||
import webbrowser
|
||||
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
|
||||
|
||||
# When port=0, default to localhost for security (desktop client mode)
|
||||
|
|
@ -56,6 +61,7 @@ def gui(
|
|||
if config_path is None:
|
||||
rprint("[yellow]No agentkit.yaml found.[/yellow]")
|
||||
from rich.prompt import Confirm
|
||||
|
||||
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
||||
config_path = run_onboarding(config_arg=config)
|
||||
if config_path is None:
|
||||
|
|
@ -66,12 +72,7 @@ def gui(
|
|||
server_config = None
|
||||
if config_path:
|
||||
rprint(f"[green]Loading config from {config_path}[/green]")
|
||||
server_config = ServerConfig.from_yaml(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)
|
||||
server_config = load_config_with_dotenv(config_path)
|
||||
|
||||
os.environ["AGENTKIT_CONFIG_PATH"] = config_path
|
||||
|
||||
|
|
@ -79,14 +80,15 @@ def gui(
|
|||
if not server_config.has_llm_provider():
|
||||
rprint("[yellow]No LLM API key configured.[/yellow]")
|
||||
from rich.prompt import Confirm
|
||||
|
||||
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
||||
config_path = run_onboarding(config_arg=config)
|
||||
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:
|
||||
server_config = ServerConfig.from_yaml(config_path)
|
||||
server_config.load_dotenv(str(dotenv))
|
||||
server_config = ServerConfig.from_yaml(config_path)
|
||||
server_config = load_config_with_dotenv(config_path)
|
||||
os.environ["AGENTKIT_CONFIG_PATH"] = config_path
|
||||
else:
|
||||
rprint("[dim]Continuing without LLM provider — chat will not work.[/dim]")
|
||||
|
|
@ -101,15 +103,19 @@ def gui(
|
|||
|
||||
if not no_open and browser_url:
|
||||
import threading
|
||||
|
||||
def _open_browser():
|
||||
import time
|
||||
|
||||
time.sleep(2.0)
|
||||
webbrowser.open(browser_url)
|
||||
|
||||
threading.Thread(target=_open_browser, daemon=True).start()
|
||||
|
||||
# Create app directly (not factory mode) so server_config with resolved API keys
|
||||
# is passed through without relying on env var inheritance in multiprocessing.
|
||||
from agentkit.server.app import create_app
|
||||
|
||||
app = create_app(server_config=server_config)
|
||||
|
||||
if port == 0:
|
||||
|
|
@ -152,14 +158,20 @@ def serve(
|
|||
workers: int = typer.Option(1, "--workers", help="Number of workers"),
|
||||
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
||||
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_redis_url: Optional[str] = typer.Option(None, "--task-store-redis-url", help="Redis URL for task store (only used when backend=redis)"),
|
||||
task_store_backend: Optional[str] = typer.Option(
|
||||
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"""
|
||||
import uvicorn
|
||||
|
||||
from agentkit.server.config import ServerConfig, find_config_path
|
||||
from agentkit.cli.onboarding import needs_onboarding, run_onboarding
|
||||
from agentkit.server.config import find_config_path, load_config_with_dotenv
|
||||
from agentkit.cli.onboarding import run_onboarding
|
||||
|
||||
# Load .env file if present
|
||||
config_path = find_config_path(config)
|
||||
|
|
@ -168,6 +180,7 @@ def serve(
|
|||
if config_path is None:
|
||||
rprint("[yellow]No agentkit.yaml found.[/yellow]")
|
||||
from rich.prompt import Confirm
|
||||
|
||||
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
||||
config_path = run_onboarding(config_arg=config)
|
||||
if config_path is None:
|
||||
|
|
@ -177,28 +190,21 @@ def serve(
|
|||
|
||||
if config_path:
|
||||
rprint(f"[green]Loading config from {config_path}[/green]")
|
||||
server_config = ServerConfig.from_yaml(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)
|
||||
server_config = load_config_with_dotenv(config_path)
|
||||
|
||||
# Check if LLM API key is configured
|
||||
if not server_config.has_llm_provider():
|
||||
rprint("[yellow]No LLM API key configured.[/yellow]")
|
||||
from rich.prompt import Confirm
|
||||
|
||||
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
||||
config_path = run_onboarding(config_arg=config)
|
||||
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:
|
||||
server_config = ServerConfig.from_yaml(config_path)
|
||||
server_config.load_dotenv(str(dotenv))
|
||||
server_config = ServerConfig.from_yaml(config_path)
|
||||
server_config = load_config_with_dotenv(config_path)
|
||||
else:
|
||||
rprint("[dim]Continuing without LLM provider — API calls will fail.[/dim]")
|
||||
|
||||
|
|
@ -216,6 +222,7 @@ def serve(
|
|||
# Store config for app factory
|
||||
import os
|
||||
import json as _json
|
||||
|
||||
os.environ["AGENTKIT_CONFIG_PATH"] = config_path
|
||||
# Pass task_store overrides via env var so create_app can read them
|
||||
if server_config.task_store:
|
||||
|
|
@ -233,6 +240,7 @@ def serve(
|
|||
# Apply CLI task_store overrides even without config file
|
||||
import os
|
||||
import json as _json
|
||||
|
||||
ts_override: dict = {}
|
||||
if task_store_backend is not None:
|
||||
ts_override["backend"] = task_store_backend
|
||||
|
|
@ -258,6 +266,7 @@ def version():
|
|||
"""Show AgentKit version"""
|
||||
try:
|
||||
from importlib.metadata import version as get_version
|
||||
|
||||
v = get_version("fischer-agentkit")
|
||||
except Exception:
|
||||
v = "0.1.0 (dev)"
|
||||
|
|
|
|||
|
|
@ -109,8 +109,18 @@ PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
|||
|
||||
|
||||
def needs_onboarding(config_arg: str | None = None) -> bool:
|
||||
"""Check if onboarding is needed (no config file found)."""
|
||||
return find_config_path(config_arg) is None
|
||||
"""Check if onboarding is needed (no config file found or no valid LLM provider)."""
|
||||
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(
|
||||
|
|
@ -119,19 +129,47 @@ def run_onboarding(
|
|||
) -> str | None:
|
||||
"""Run the interactive onboarding wizard.
|
||||
|
||||
If agentkit.yaml already exists, only the LLM section is updated
|
||||
(preserving all other settings). If it doesn't exist, a full config
|
||||
is generated.
|
||||
|
||||
Returns:
|
||||
Path to the generated config file, or None if cancelled.
|
||||
Path to the generated/updated config file, or None if cancelled.
|
||||
"""
|
||||
rprint(Panel(
|
||||
# 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)
|
||||
|
||||
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",
|
||||
))
|
||||
|
||||
output_path = Path(output_dir).resolve()
|
||||
output_path.mkdir(parents=True, exist_ok=True)
|
||||
)
|
||||
)
|
||||
|
||||
# ── Step 1: Choose LLM provider ──────────────────────────────
|
||||
rprint("\n[bold]Step 1: Choose your LLM provider[/bold]")
|
||||
|
|
@ -169,12 +207,16 @@ def run_onboarding(
|
|||
for i, model in enumerate(available_models, 1):
|
||||
alias = preset["models"][model].get("alias", "")
|
||||
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}")
|
||||
model_choice = Prompt.ask(
|
||||
"Select default model",
|
||||
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]
|
||||
# Rebuild models dict: selected model gets "default" alias
|
||||
|
|
@ -184,7 +226,9 @@ def run_onboarding(
|
|||
updated_models[model] = {**conf, "alias": "default"}
|
||||
else:
|
||||
# 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}
|
||||
rprint(f"[green]Selected: {selected_model}[/green]")
|
||||
else:
|
||||
|
|
@ -200,7 +244,11 @@ def run_onboarding(
|
|||
"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):
|
||||
remaining = [k for k in provider_keys if k != selected_key]
|
||||
|
|
@ -228,9 +276,18 @@ def run_onboarding(
|
|||
if alias and alias not in model_aliases:
|
||||
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]")
|
||||
|
||||
if existing_config:
|
||||
# Merge: only update LLM section, preserve everything else
|
||||
if "llm" not in existing_config:
|
||||
existing_config["llm"] = {}
|
||||
existing_config["llm"]["providers"] = providers_config
|
||||
existing_config["llm"]["model_aliases"] = model_aliases
|
||||
config = existing_config
|
||||
else:
|
||||
# New config — generate full template
|
||||
config = {
|
||||
"server": {
|
||||
"host": "0.0.0.0",
|
||||
|
|
@ -253,7 +310,7 @@ def run_onboarding(
|
|||
},
|
||||
"skills": {
|
||||
"auto_discover": True,
|
||||
"paths": ["./skills"],
|
||||
"paths": ["./configs/skills"],
|
||||
},
|
||||
"logging": {
|
||||
"level": "INFO",
|
||||
|
|
@ -267,14 +324,28 @@ def run_onboarding(
|
|||
yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
rprint(f" [green]Created:[/green] {config_path}")
|
||||
|
||||
# Write .env
|
||||
# Write .env (merge with existing)
|
||||
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:
|
||||
f.write("# AgentKit Environment Variables\n")
|
||||
f.write("# Generated by onboarding wizard\n\n")
|
||||
f.write("\n".join(env_lines) + "\n")
|
||||
rprint(f" [green]Created:[/green] {env_path}")
|
||||
for k, v in existing_env.items():
|
||||
f.write(f"{k}={v}\n")
|
||||
rprint(f" [green]Updated:[/green] {env_path}")
|
||||
|
||||
# ── Step 4: Agent personality (optional) ──────────────────────
|
||||
rprint("\n[bold]Step 4: Customize your Agent (optional)[/bold]")
|
||||
|
|
@ -286,6 +357,7 @@ def run_onboarding(
|
|||
|
||||
# Create SOUL.md
|
||||
from agentkit.memory.profile import MemoryStore
|
||||
|
||||
memory_store = MemoryStore(base_dir=Path.home() / ".agentkit")
|
||||
soul_content = f"""## 身份
|
||||
我是{agent_name},一个专业的 AI 助手。
|
||||
|
|
@ -304,12 +376,14 @@ def run_onboarding(
|
|||
memory_store.get_file("soul").write(soul_content.strip())
|
||||
rprint(" [green]Created:[/green] ~/.agentkit/SOUL.md")
|
||||
|
||||
rprint(Panel(
|
||||
rprint(
|
||||
Panel(
|
||||
"[bold green]Setup complete![/bold green]\n\n"
|
||||
"You can now run:\n"
|
||||
" [cyan]agentkit chat[/cyan] — Start chatting with your Agent\n"
|
||||
" [cyan]agentkit serve[/cyan] — Start the API server",
|
||||
border_style="green",
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
return str(config_path)
|
||||
|
|
|
|||
|
|
@ -14,12 +14,16 @@ task_app = typer.Typer(name="task", help="Task management commands", no_args_is_
|
|||
@task_app.command("submit")
|
||||
def submit(
|
||||
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"),
|
||||
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent name"),
|
||||
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"),
|
||||
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"""
|
||||
# Parse input data
|
||||
|
|
@ -43,23 +47,28 @@ def submit(
|
|||
def _submit_remote(input_data, skill, agent, mode, server_url):
|
||||
"""Submit task to a remote AgentKit server."""
|
||||
from agentkit.server.client import AgentKitClient
|
||||
|
||||
client = AgentKitClient(base_url=server_url)
|
||||
|
||||
if mode == "async":
|
||||
result = asyncio.run(client.submit_task_async(
|
||||
result = asyncio.run(
|
||||
client.submit_task_async(
|
||||
input_data=input_data,
|
||||
skill_name=skill,
|
||||
agent_name=agent,
|
||||
))
|
||||
)
|
||||
)
|
||||
rprint("[green]Task submitted (async)[/green]")
|
||||
rprint(f" Task ID: {result.get('task_id', 'N/A')}")
|
||||
rprint(f" Status: {result.get('status', 'N/A')}")
|
||||
else:
|
||||
result = asyncio.run(client.submit_task(
|
||||
result = asyncio.run(
|
||||
client.submit_task(
|
||||
input_data=input_data,
|
||||
skill_name=skill,
|
||||
agent_name=agent,
|
||||
))
|
||||
)
|
||||
)
|
||||
rprint("[green]Task completed[/green]")
|
||||
if "output_data" in result:
|
||||
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):
|
||||
"""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
|
||||
resolved_path = find_config_path(config_path)
|
||||
if resolved_path:
|
||||
server_config = ServerConfig.from_yaml(resolved_path)
|
||||
server_config.load_dotenv()
|
||||
server_config = ServerConfig.from_yaml(resolved_path)
|
||||
server_config = load_config_with_dotenv(resolved_path)
|
||||
else:
|
||||
server_config = None
|
||||
|
||||
# Build app components
|
||||
from agentkit.server.app import create_app
|
||||
|
||||
app = create_app(server_config=server_config)
|
||||
|
||||
# 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
|
||||
if 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)
|
||||
skill_obj = skill_registry.get(skill)
|
||||
agent_name = skill_obj.name
|
||||
|
|
@ -102,9 +112,10 @@ def _submit_local(input_data, skill, agent, mode, config_path):
|
|||
|
||||
# Create agent and execute
|
||||
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
|
||||
import uuid
|
||||
|
||||
task = TaskMessage(
|
||||
task_id=str(uuid.uuid4()),
|
||||
agent_name=agent_name,
|
||||
|
|
@ -134,6 +145,7 @@ def status(
|
|||
raise typer.Exit(code=1)
|
||||
|
||||
from agentkit.server.client import AgentKitClient
|
||||
|
||||
client = AgentKitClient(base_url=server_url)
|
||||
result = asyncio.run(client.get_task_status(task_id))
|
||||
|
||||
|
|
@ -157,6 +169,7 @@ def list_tasks(
|
|||
raise typer.Exit(code=1)
|
||||
|
||||
from agentkit.server.client import AgentKitClient
|
||||
|
||||
client = AgentKitClient(base_url=server_url)
|
||||
tasks = asyncio.run(client.list_tasks(status=status_filter, limit=limit))
|
||||
|
||||
|
|
@ -190,6 +203,7 @@ def cancel(
|
|||
raise typer.Exit(code=1)
|
||||
|
||||
from agentkit.server.client import AgentKitClient
|
||||
|
||||
client = AgentKitClient(base_url=server_url)
|
||||
result = asyncio.run(client.cancel_task(task_id))
|
||||
rprint(f"[green]Task cancelled[/green]: {result}")
|
||||
|
|
|
|||
|
|
@ -13,18 +13,24 @@ server:
|
|||
|
||||
llm:
|
||||
providers:
|
||||
dashscope:
|
||||
bailian-coding:
|
||||
type: openai
|
||||
api_key: "${DASHSCOPE_API_KEY}"
|
||||
base_url: "https://coding.dashscope.aliyuncs.com/v1"
|
||||
models:
|
||||
qwen3.7-plus:
|
||||
alias: default
|
||||
qwen3-coder-plus:
|
||||
max_tokens: 64000
|
||||
qwen3-max:
|
||||
max_tokens: 128000
|
||||
alias: coding
|
||||
qwen3-max-2026-01-23:
|
||||
alias: powerful
|
||||
qwen-turbo:
|
||||
alias: fast
|
||||
model_aliases:
|
||||
default: dashscope/qwen3-coder-plus
|
||||
powerful: dashscope/qwen3-max
|
||||
default: bailian-coding/qwen3.7-plus
|
||||
coding: bailian-coding/qwen3-coder-plus
|
||||
powerful: bailian-coding/qwen3-max-2026-01-23
|
||||
fast: bailian-coding/qwen-turbo
|
||||
|
||||
session:
|
||||
backend: memory
|
||||
|
|
@ -32,6 +38,9 @@ session:
|
|||
bus:
|
||||
backend: memory
|
||||
|
||||
task_store:
|
||||
backend: memory
|
||||
|
||||
skills:
|
||||
auto_discover: true
|
||||
paths:
|
||||
|
|
@ -40,6 +49,10 @@ skills:
|
|||
logging:
|
||||
level: "INFO"
|
||||
format: "text" # "text" or "json"
|
||||
|
||||
router:
|
||||
classifier: heuristic
|
||||
auction_enabled: false
|
||||
"""
|
||||
|
||||
ENV_EXAMPLE = """\
|
||||
|
|
@ -49,8 +62,13 @@ ENV_EXAMPLE = """\
|
|||
# LLM API Keys (at least one required)
|
||||
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)
|
||||
AGENTKIT_API_KEY= # Set to enable API key authentication
|
||||
# AGENTKIT_API_KEY= # Set to enable API key authentication
|
||||
"""
|
||||
|
||||
DOCKER_COMPOSE = """\
|
||||
|
|
@ -66,6 +84,8 @@ services:
|
|||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
healthcheck:
|
||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health')"]
|
||||
interval: 30s
|
||||
|
|
@ -82,6 +102,22 @@ services:
|
|||
timeout: 5s
|
||||
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:
|
||||
pgdata:
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from agentkit.skills.base import Skill
|
|||
from agentkit.skills.registry import SkillRegistry
|
||||
from agentkit.tools.registry import ToolRegistry
|
||||
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 (
|
||||
agents,
|
||||
tasks,
|
||||
|
|
@ -49,18 +49,6 @@ from agentkit.telemetry.setup import setup_telemetry
|
|||
|
||||
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:
|
||||
"""Build LLMGateway from ServerConfig, registering all providers."""
|
||||
|
|
@ -487,27 +475,10 @@ def create_app(
|
|||
|
||||
if config_path and os.path.exists(config_path):
|
||||
# Load .env before parsing config (so ${ENV_VAR} substitutions work)
|
||||
from pathlib import Path as _P
|
||||
|
||||
_dotenv = _P(config_path).parent / ".env"
|
||||
if _dotenv.exists():
|
||||
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
|
||||
load_dotenv(_dotenv)
|
||||
server_config = ServerConfig.from_yaml(config_path)
|
||||
app = FastAPI(title="AgentKit Server", version="2.0.0", lifespan=lifespan)
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,10 @@ class ClientConfig:
|
|||
clients_path = os.path.join(self.config_dir, "clients.yaml")
|
||||
if os.path.exists(clients_path):
|
||||
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 {}
|
||||
|
||||
def reload(self):
|
||||
|
|
@ -49,13 +52,20 @@ class ClientConfig:
|
|||
return client_info["skills_dir"]
|
||||
# Fall back to default from agentkit.yaml
|
||||
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:
|
||||
config_path = os.path.join(self.config_dir, "agentkit.yaml")
|
||||
if os.path.exists(config_path):
|
||||
from agentkit.server.config import _deep_resolve
|
||||
|
||||
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 {}
|
||||
|
||||
def validate_api_key(self, api_key: str) -> bool:
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import asyncio
|
|||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
|
|
@ -148,9 +148,9 @@ class ServerConfig:
|
|||
self._last_mtime: float = 0.0
|
||||
|
||||
def has_llm_provider(self) -> bool:
|
||||
"""检查是否配置了有效的 LLM Provider(API Key 非空)"""
|
||||
"""检查是否配置了有效的 LLM Provider(API Key 已解析且非空)"""
|
||||
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 False
|
||||
|
||||
|
|
@ -333,25 +333,6 @@ class ServerConfig:
|
|||
logger.warning(f"Failed to load skill config from {yaml_file}: {e}")
|
||||
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:
|
||||
"""Start watching the config file for changes and hot-reload.
|
||||
|
||||
|
|
@ -376,6 +357,7 @@ class ServerConfig:
|
|||
|
||||
try:
|
||||
import watchfiles # noqa: F401
|
||||
|
||||
self._watcher_task = asyncio.ensure_future(self._watch_with_watchfiles(path))
|
||||
logger.info(f"Config watcher started (watchfiles) for {path}")
|
||||
except ImportError:
|
||||
|
|
@ -393,6 +375,7 @@ class ServerConfig:
|
|||
"""Watch config file using watchfiles library."""
|
||||
try:
|
||||
from watchfiles import awatch
|
||||
|
||||
async for changes in awatch(path):
|
||||
for change_type, changed_path in changes:
|
||||
logger.info(f"Config file change detected: {change_type} on {changed_path}")
|
||||
|
|
@ -428,7 +411,7 @@ class ServerConfig:
|
|||
return
|
||||
|
||||
# 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.")
|
||||
return
|
||||
|
||||
|
|
@ -464,6 +447,84 @@ class ServerConfig:
|
|||
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:
|
||||
"""Find the agentkit.yaml config file.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
"""Settings API routes with config hot-reload support."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import yaml
|
||||
|
|
@ -16,6 +18,7 @@ router = APIRouter(tags=["settings"])
|
|||
# Helper: mask API keys (show only last 4 chars)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _mask_api_key(key: str | None) -> str:
|
||||
"""Mask an API key, showing only the last 4 characters."""
|
||||
if not key:
|
||||
|
|
@ -29,6 +32,7 @@ def _mask_api_key(key: str | None) -> str:
|
|||
# Pydantic models for request/response
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LlmProviderResponse(BaseModel):
|
||||
name: str
|
||||
type: str
|
||||
|
|
@ -103,16 +107,88 @@ class GeneralConfigUpdate(BaseModel):
|
|||
# Helper: read/write config file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _read_yaml_config(config_path: str) -> dict:
|
||||
"""Read the YAML config file and return the parsed dict."""
|
||||
with open(config_path, encoding="utf-8") as f:
|
||||
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:
|
||||
"""Write the full config dict back to the YAML file."""
|
||||
"""Write the full config dict back to the YAML file.
|
||||
|
||||
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.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||
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:
|
||||
|
|
@ -123,10 +199,45 @@ def _get_config_path(request: Request) -> str:
|
|||
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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/settings/llm", response_model=LlmConfigResponse)
|
||||
async def get_llm_settings(request: Request):
|
||||
"""Return LLM config with masked API keys."""
|
||||
|
|
@ -137,7 +248,8 @@ async def get_llm_settings(request: Request):
|
|||
llm_config = config.llm_config
|
||||
providers = []
|
||||
for name, pconf in llm_config.providers.items():
|
||||
providers.append(LlmProviderResponse(
|
||||
providers.append(
|
||||
LlmProviderResponse(
|
||||
name=name,
|
||||
type=pconf.type,
|
||||
api_key=_mask_api_key(pconf.api_key),
|
||||
|
|
@ -145,7 +257,8 @@ async def get_llm_settings(request: Request):
|
|||
models=pconf.models,
|
||||
max_tokens=pconf.max_tokens,
|
||||
timeout=pconf.timeout,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
return LlmConfigResponse(
|
||||
providers=providers,
|
||||
|
|
@ -156,7 +269,11 @@ async def get_llm_settings(request: Request):
|
|||
|
||||
@router.put("/settings/llm", response_model=LlmConfigResponse)
|
||||
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)
|
||||
data = _read_yaml_config(config_path)
|
||||
|
||||
|
|
@ -174,13 +291,22 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
|||
"timeout": p.timeout,
|
||||
}
|
||||
if p.api_key is not None:
|
||||
# If the user sends a masked key (****xxxx), don't overwrite
|
||||
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, {})
|
||||
p_dict["api_key"] = existing.get("api_key", "")
|
||||
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:
|
||||
# Keep existing key if not provided
|
||||
existing = data.get("llm", {}).get("providers", {}).get(p.name, {})
|
||||
|
|
@ -205,7 +331,8 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
|||
llm_config = config.llm_config
|
||||
providers = []
|
||||
for name, pconf in llm_config.providers.items():
|
||||
providers.append(LlmProviderResponse(
|
||||
providers.append(
|
||||
LlmProviderResponse(
|
||||
name=name,
|
||||
type=pconf.type,
|
||||
api_key=_mask_api_key(pconf.api_key),
|
||||
|
|
@ -213,7 +340,8 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
|||
models=pconf.models,
|
||||
max_tokens=pconf.max_tokens,
|
||||
timeout=pconf.timeout,
|
||||
))
|
||||
)
|
||||
)
|
||||
|
||||
return LlmConfigResponse(
|
||||
providers=providers,
|
||||
|
|
@ -226,6 +354,7 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
|||
# Skills Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/settings/skills", response_model=SkillsConfigResponse)
|
||||
async def get_skills_settings(request: Request):
|
||||
"""Return skill paths config."""
|
||||
|
|
@ -266,6 +395,7 @@ async def update_skills_settings(request: Request, update: SkillsConfigUpdate):
|
|||
# Knowledge Base Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/settings/kb", response_model=KbConfigResponse)
|
||||
async def get_kb_settings(request: Request):
|
||||
"""Return knowledge base connection config."""
|
||||
|
|
@ -294,6 +424,7 @@ async def update_kb_settings(request: Request, update: KbConfigUpdate):
|
|||
# General Settings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get("/settings/general", response_model=GeneralConfigResponse)
|
||||
async def get_general_settings(request: Request):
|
||||
"""Return general settings (log level, server port, etc.)."""
|
||||
|
|
|
|||
|
|
@ -2,6 +2,9 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import unittest.mock
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
from fastapi.testclient import TestClient
|
||||
|
|
@ -355,6 +358,10 @@ class TestNoConfigPath:
|
|||
"""When server_config has no _config_path, PUT should return 400."""
|
||||
from agentkit.llm.gateway import 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
|
||||
client = TestClient(app)
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ class TestServeCommand:
|
|||
def test_serve_starts_uvicorn(self):
|
||||
"""agentkit serve calls uvicorn.run with correct params"""
|
||||
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"])
|
||||
mock_run.assert_called_once()
|
||||
call_kwargs = mock_run.call_args
|
||||
|
|
|
|||
Loading…
Reference in New Issue