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",
"gitCommitHash": "d9d1b16e5911ad958cd8ae38958058bea13f3fcc",
"lastAnalyzedAt": "2026-06-15T06:01:34.200955+00:00",
"gitCommitHash": "64d62a2b60c57fbb1844c1f46c541234c8f9d871",
"version": "1.0.0",
"analyzedFiles": 208
"analyzedFiles": 2416
}

View File

@ -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}

View File

@ -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:

View File

@ -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(

View File

@ -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)"

View File

@ -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,20 +129,48 @@ def run_onboarding(
) -> str | None:
"""Run the interactive onboarding wizard.
Returns:
Path to the generated config file, or None if cancelled.
"""
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",
))
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.
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)
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 ──────────────────────────────
rprint("\n[bold]Step 1: Choose your LLM provider[/bold]")
provider_keys = list(PROVIDER_PRESETS.keys())
@ -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,38 +276,47 @@ 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]")
config = {
"server": {
"host": "0.0.0.0",
"port": 8001,
"workers": 1,
"rate_limit": 60,
},
"llm": {
"providers": providers_config,
"model_aliases": model_aliases,
},
"session": {
"backend": "memory",
},
"bus": {
"backend": "memory",
},
"task_store": {
"backend": "memory",
},
"skills": {
"auto_discover": True,
"paths": ["./skills"],
},
"logging": {
"level": "INFO",
"format": "text",
},
}
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",
"port": 8001,
"workers": 1,
"rate_limit": 60,
},
"llm": {
"providers": providers_config,
"model_aliases": model_aliases,
},
"session": {
"backend": "memory",
},
"bus": {
"backend": "memory",
},
"task_store": {
"backend": "memory",
},
"skills": {
"auto_discover": True,
"paths": ["./configs/skills"],
},
"logging": {
"level": "INFO",
"format": "text",
},
}
# Write 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)
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(
"[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",
))
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)

View File

@ -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(
input_data=input_data,
skill_name=skill,
agent_name=agent,
))
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(
input_data=input_data,
skill_name=skill,
agent_name=agent,
))
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}")

View File

@ -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:
"""

View File

@ -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)

View File

@ -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:

View File

@ -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 ProviderAPI Key 非空)"""
"""检查是否配置了有效的 LLM ProviderAPI 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.

View 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."""
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
"""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_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,15 +248,17 @@ async def get_llm_settings(request: Request):
llm_config = config.llm_config
providers = []
for name, pconf in llm_config.providers.items():
providers.append(LlmProviderResponse(
name=name,
type=pconf.type,
api_key=_mask_api_key(pconf.api_key),
base_url=pconf.base_url or "",
models=pconf.models,
max_tokens=pconf.max_tokens,
timeout=pconf.timeout,
))
providers.append(
LlmProviderResponse(
name=name,
type=pconf.type,
api_key=_mask_api_key(pconf.api_key),
base_url=pconf.base_url or "",
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,15 +331,17 @@ 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(
name=name,
type=pconf.type,
api_key=_mask_api_key(pconf.api_key),
base_url=pconf.base_url or "",
models=pconf.models,
max_tokens=pconf.max_tokens,
timeout=pconf.timeout,
))
providers.append(
LlmProviderResponse(
name=name,
type=pconf.type,
api_key=_mask_api_key(pconf.api_key),
base_url=pconf.base_url or "",
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.)."""

View File

@ -2,6 +2,9 @@
from __future__ import annotations
import os
import unittest.mock
import pytest
import yaml
from fastapi.testclient import TestClient
@ -355,7 +358,11 @@ class TestNoConfigPath:
"""When server_config has no _config_path, PUT should return 400."""
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
client = TestClient(app)

View File

@ -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