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",
|
"lastAnalyzedAt": "2026-06-15T06:01:34.200955+00:00",
|
||||||
"gitCommitHash": "d9d1b16e5911ad958cd8ae38958058bea13f3fcc",
|
"gitCommitHash": "64d62a2b60c57fbb1844c1f46c541234c8f9d871",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"analyzedFiles": 208
|
"analyzedFiles": 2416
|
||||||
}
|
}
|
||||||
|
|
@ -5,12 +5,22 @@ server:
|
||||||
rate_limit: 60
|
rate_limit: 60
|
||||||
llm:
|
llm:
|
||||||
providers:
|
providers:
|
||||||
test:
|
bailian-coding:
|
||||||
type: openai
|
type: openai
|
||||||
base_url: ''
|
base_url: https://coding.dashscope.aliyuncs.com/v1
|
||||||
max_tokens: 4096
|
api_key: "${DASHSCOPE_API_KEY}"
|
||||||
timeout: 120.0
|
models:
|
||||||
api_key: ''
|
qwen3.7-plus: {alias: default}
|
||||||
|
qwen3-coder-plus: {alias: coding}
|
||||||
|
qwen3-max-2026-01-23: {alias: powerful}
|
||||||
|
qwen-turbo: {alias: fast}
|
||||||
|
deepseek:
|
||||||
|
type: openai
|
||||||
|
base_url: https://api.deepseek.com/v1
|
||||||
|
api_key: "${DEEPSEEK_API_KEY}"
|
||||||
|
models:
|
||||||
|
deepseek-chat: {alias: chat}
|
||||||
|
deepseek-reasoner: {alias: reasoning}
|
||||||
model_aliases:
|
model_aliases:
|
||||||
default: bailian-coding/qwen3.7-plus
|
default: bailian-coding/qwen3.7-plus
|
||||||
fast: bailian-coding/qwen-turbo
|
fast: bailian-coding/qwen-turbo
|
||||||
|
|
@ -18,23 +28,9 @@ llm:
|
||||||
coding: bailian-coding/qwen3-coder-plus
|
coding: bailian-coding/qwen3-coder-plus
|
||||||
chat: deepseek/deepseek-chat
|
chat: deepseek/deepseek-chat
|
||||||
reasoning: deepseek/deepseek-reasoner
|
reasoning: deepseek/deepseek-reasoner
|
||||||
session:
|
session: {backend: memory}
|
||||||
backend: memory
|
bus: {backend: memory}
|
||||||
bus:
|
task_store: {backend: memory}
|
||||||
backend: memory
|
skills: {auto_discover: true, paths: ["./configs/skills"]}
|
||||||
task_store:
|
logging: {level: INFO, format: text}
|
||||||
backend: memory
|
router: {classifier: heuristic, auction_enabled: false}
|
||||||
skills:
|
|
||||||
auto_discover: true
|
|
||||||
paths:
|
|
||||||
- ./configs/skills
|
|
||||||
logging:
|
|
||||||
level: INFO
|
|
||||||
format: text
|
|
||||||
router:
|
|
||||||
classifier: heuristic
|
|
||||||
auction_enabled: false
|
|
||||||
semantic:
|
|
||||||
enabled: true
|
|
||||||
similarity_high: 0.85
|
|
||||||
similarity_low: 0.4
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ import os
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI
|
||||||
|
|
||||||
from agentkit.server.app import _build_llm_gateway, create_app
|
from agentkit.server.app import _build_llm_gateway, create_app
|
||||||
from agentkit.server.config import ServerConfig
|
from agentkit.server.config import ServerConfig, find_config_path, load_config_with_dotenv
|
||||||
from agentkit.skills.loader import SkillLoader
|
from agentkit.skills.loader import SkillLoader
|
||||||
from agentkit.skills.registry import SkillRegistry
|
from agentkit.skills.registry import SkillRegistry
|
||||||
from agentkit.tools.registry import ToolRegistry
|
from agentkit.tools.registry import ToolRegistry
|
||||||
|
|
@ -27,16 +27,13 @@ logger = logging.getLogger(__name__)
|
||||||
CONFIGS_DIR = os.path.dirname(os.path.abspath(__file__))
|
CONFIGS_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
SKILLS_DIR = os.path.join(CONFIGS_DIR, "skills")
|
SKILLS_DIR = os.path.join(CONFIGS_DIR, "skills")
|
||||||
|
|
||||||
# 查找 agentkit.yaml:项目根目录 > configs 目录
|
|
||||||
_PROJECT_ROOT = os.path.dirname(CONFIGS_DIR)
|
|
||||||
_AGENTKIT_YAML = os.path.join(_PROJECT_ROOT, "agentkit.yaml")
|
|
||||||
|
|
||||||
|
|
||||||
def _load_server_config() -> ServerConfig:
|
def _load_server_config() -> ServerConfig:
|
||||||
"""Load ServerConfig from agentkit.yaml with env var resolution."""
|
"""Load ServerConfig from agentkit.yaml with .env resolution."""
|
||||||
if os.path.isfile(_AGENTKIT_YAML):
|
config_path = find_config_path()
|
||||||
return ServerConfig.from_yaml(_AGENTKIT_YAML)
|
if config_path:
|
||||||
raise FileNotFoundError(f"agentkit.yaml not found at {_AGENTKIT_YAML}")
|
return load_config_with_dotenv(config_path)
|
||||||
|
raise FileNotFoundError("agentkit.yaml not found (searched CWD and ~/.agentkit/)")
|
||||||
|
|
||||||
|
|
||||||
def _init_tool_registry() -> ToolRegistry:
|
def _init_tool_registry() -> ToolRegistry:
|
||||||
|
|
|
||||||
|
|
@ -46,7 +46,7 @@ async def _chat_async(
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Async implementation of the chat command."""
|
"""Async implementation of the chat command."""
|
||||||
from agentkit.cli.onboarding import run_onboarding
|
from agentkit.cli.onboarding import run_onboarding
|
||||||
from agentkit.server.config import ServerConfig, find_config_path
|
from agentkit.server.config import find_config_path
|
||||||
|
|
||||||
# ── Onboarding check ──────────────────────────────────────────
|
# ── Onboarding check ──────────────────────────────────────────
|
||||||
config_path = find_config_path(config_arg)
|
config_path = find_config_path(config_arg)
|
||||||
|
|
@ -59,14 +59,9 @@ async def _chat_async(
|
||||||
# ── Load config ───────────────────────────────────────────────
|
# ── Load config ───────────────────────────────────────────────
|
||||||
rprint(f"[dim]Loading config from {config_path}[/dim]")
|
rprint(f"[dim]Loading config from {config_path}[/dim]")
|
||||||
|
|
||||||
# Load .env
|
from agentkit.server.config import load_config_with_dotenv
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
dotenv = Path(config_path).parent / ".env"
|
server_config = load_config_with_dotenv(config_path)
|
||||||
if dotenv.exists():
|
|
||||||
_load_dotenv(str(dotenv))
|
|
||||||
|
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
|
||||||
|
|
||||||
# ── Build in-process components ───────────────────────────────
|
# ── Build in-process components ───────────────────────────────
|
||||||
from agentkit.session.manager import SessionManager
|
from agentkit.session.manager import SessionManager
|
||||||
|
|
@ -380,27 +375,6 @@ def _resolve_default_model(server_config: "ServerConfig") -> str:
|
||||||
return "default"
|
return "default"
|
||||||
|
|
||||||
|
|
||||||
def _load_dotenv(dotenv_path: str) -> None:
|
|
||||||
"""Load .env file into environment."""
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
path = Path(dotenv_path)
|
|
||||||
if not path.exists():
|
|
||||||
return
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
if "=" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip().strip("\"'")
|
|
||||||
if key and key not in os.environ:
|
|
||||||
os.environ[key] = value
|
|
||||||
|
|
||||||
|
|
||||||
def _print_help() -> None:
|
def _print_help() -> None:
|
||||||
"""Print chat command help."""
|
"""Print chat command help."""
|
||||||
rprint(
|
rprint(
|
||||||
|
|
|
||||||
|
|
@ -12,21 +12,27 @@ app = typer.Typer(
|
||||||
)
|
)
|
||||||
|
|
||||||
from agentkit.cli.task import task_app # noqa: E402
|
from agentkit.cli.task import task_app # noqa: E402
|
||||||
|
|
||||||
app.add_typer(task_app, name="task")
|
app.add_typer(task_app, name="task")
|
||||||
|
|
||||||
from agentkit.cli.skill import skill_app # noqa: E402
|
from agentkit.cli.skill import skill_app # noqa: E402
|
||||||
|
|
||||||
app.add_typer(skill_app, name="skill")
|
app.add_typer(skill_app, name="skill")
|
||||||
|
|
||||||
from agentkit.cli.init import init # noqa: E402
|
from agentkit.cli.init import init # noqa: E402
|
||||||
|
|
||||||
app.command(name="init")(init)
|
app.command(name="init")(init)
|
||||||
|
|
||||||
from agentkit.cli.usage import usage # noqa: E402
|
from agentkit.cli.usage import usage # noqa: E402
|
||||||
|
|
||||||
app.command(name="usage")(usage)
|
app.command(name="usage")(usage)
|
||||||
|
|
||||||
from agentkit.cli.pair import pair # noqa: E402
|
from agentkit.cli.pair import pair # noqa: E402
|
||||||
|
|
||||||
app.command(name="pair")(pair)
|
app.command(name="pair")(pair)
|
||||||
|
|
||||||
from agentkit.cli.chat import chat # noqa: E402
|
from agentkit.cli.chat import chat # noqa: E402
|
||||||
|
|
||||||
app.command(name="chat")(chat)
|
app.command(name="chat")(chat)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -39,11 +45,10 @@ def gui(
|
||||||
):
|
):
|
||||||
"""Start AgentKit with a web UI for chatting with your Agent"""
|
"""Start AgentKit with a web UI for chatting with your Agent"""
|
||||||
import os
|
import os
|
||||||
import sys
|
|
||||||
import webbrowser
|
import webbrowser
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from agentkit.server.config import ServerConfig, find_config_path
|
from agentkit.server.config import find_config_path, load_config_with_dotenv
|
||||||
from agentkit.cli.onboarding import run_onboarding
|
from agentkit.cli.onboarding import run_onboarding
|
||||||
|
|
||||||
# When port=0, default to localhost for security (desktop client mode)
|
# When port=0, default to localhost for security (desktop client mode)
|
||||||
|
|
@ -56,6 +61,7 @@ def gui(
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
rprint("[yellow]No agentkit.yaml found.[/yellow]")
|
rprint("[yellow]No agentkit.yaml found.[/yellow]")
|
||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
|
|
||||||
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
||||||
config_path = run_onboarding(config_arg=config)
|
config_path = run_onboarding(config_arg=config)
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
|
|
@ -66,12 +72,7 @@ def gui(
|
||||||
server_config = None
|
server_config = None
|
||||||
if config_path:
|
if config_path:
|
||||||
rprint(f"[green]Loading config from {config_path}[/green]")
|
rprint(f"[green]Loading config from {config_path}[/green]")
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
server_config = load_config_with_dotenv(config_path)
|
||||||
|
|
||||||
from pathlib import Path
|
|
||||||
dotenv = Path(config_path).parent / ".env"
|
|
||||||
server_config.load_dotenv(str(dotenv))
|
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
|
||||||
|
|
||||||
os.environ["AGENTKIT_CONFIG_PATH"] = config_path
|
os.environ["AGENTKIT_CONFIG_PATH"] = config_path
|
||||||
|
|
||||||
|
|
@ -79,14 +80,15 @@ def gui(
|
||||||
if not server_config.has_llm_provider():
|
if not server_config.has_llm_provider():
|
||||||
rprint("[yellow]No LLM API key configured.[/yellow]")
|
rprint("[yellow]No LLM API key configured.[/yellow]")
|
||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
|
|
||||||
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
||||||
config_path = run_onboarding(config_arg=config)
|
config_path = run_onboarding(config_arg=config)
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
rprint("[red]Setup cancelled. GUI may not function correctly without API key.[/red]")
|
rprint(
|
||||||
|
"[red]Setup cancelled. GUI may not function correctly without API key.[/red]"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
server_config = load_config_with_dotenv(config_path)
|
||||||
server_config.load_dotenv(str(dotenv))
|
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
|
||||||
os.environ["AGENTKIT_CONFIG_PATH"] = config_path
|
os.environ["AGENTKIT_CONFIG_PATH"] = config_path
|
||||||
else:
|
else:
|
||||||
rprint("[dim]Continuing without LLM provider — chat will not work.[/dim]")
|
rprint("[dim]Continuing without LLM provider — chat will not work.[/dim]")
|
||||||
|
|
@ -101,15 +103,19 @@ def gui(
|
||||||
|
|
||||||
if not no_open and browser_url:
|
if not no_open and browser_url:
|
||||||
import threading
|
import threading
|
||||||
|
|
||||||
def _open_browser():
|
def _open_browser():
|
||||||
import time
|
import time
|
||||||
|
|
||||||
time.sleep(2.0)
|
time.sleep(2.0)
|
||||||
webbrowser.open(browser_url)
|
webbrowser.open(browser_url)
|
||||||
|
|
||||||
threading.Thread(target=_open_browser, daemon=True).start()
|
threading.Thread(target=_open_browser, daemon=True).start()
|
||||||
|
|
||||||
# Create app directly (not factory mode) so server_config with resolved API keys
|
# Create app directly (not factory mode) so server_config with resolved API keys
|
||||||
# is passed through without relying on env var inheritance in multiprocessing.
|
# is passed through without relying on env var inheritance in multiprocessing.
|
||||||
from agentkit.server.app import create_app
|
from agentkit.server.app import create_app
|
||||||
|
|
||||||
app = create_app(server_config=server_config)
|
app = create_app(server_config=server_config)
|
||||||
|
|
||||||
if port == 0:
|
if port == 0:
|
||||||
|
|
@ -152,14 +158,20 @@ def serve(
|
||||||
workers: int = typer.Option(1, "--workers", help="Number of workers"),
|
workers: int = typer.Option(1, "--workers", help="Number of workers"),
|
||||||
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
reload: bool = typer.Option(False, "--reload", help="Enable auto-reload"),
|
||||||
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
|
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml"),
|
||||||
task_store_backend: Optional[str] = typer.Option(None, "--task-store-backend", help="Task store backend: memory or redis"),
|
task_store_backend: Optional[str] = typer.Option(
|
||||||
task_store_redis_url: Optional[str] = typer.Option(None, "--task-store-redis-url", help="Redis URL for task store (only used when backend=redis)"),
|
None, "--task-store-backend", help="Task store backend: memory or redis"
|
||||||
|
),
|
||||||
|
task_store_redis_url: Optional[str] = typer.Option(
|
||||||
|
None,
|
||||||
|
"--task-store-redis-url",
|
||||||
|
help="Redis URL for task store (only used when backend=redis)",
|
||||||
|
),
|
||||||
):
|
):
|
||||||
"""Start the AgentKit server"""
|
"""Start the AgentKit server"""
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
||||||
from agentkit.server.config import ServerConfig, find_config_path
|
from agentkit.server.config import find_config_path, load_config_with_dotenv
|
||||||
from agentkit.cli.onboarding import needs_onboarding, run_onboarding
|
from agentkit.cli.onboarding import run_onboarding
|
||||||
|
|
||||||
# Load .env file if present
|
# Load .env file if present
|
||||||
config_path = find_config_path(config)
|
config_path = find_config_path(config)
|
||||||
|
|
@ -168,6 +180,7 @@ def serve(
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
rprint("[yellow]No agentkit.yaml found.[/yellow]")
|
rprint("[yellow]No agentkit.yaml found.[/yellow]")
|
||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
|
|
||||||
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
||||||
config_path = run_onboarding(config_arg=config)
|
config_path = run_onboarding(config_arg=config)
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
|
|
@ -177,28 +190,21 @@ def serve(
|
||||||
|
|
||||||
if config_path:
|
if config_path:
|
||||||
rprint(f"[green]Loading config from {config_path}[/green]")
|
rprint(f"[green]Loading config from {config_path}[/green]")
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
server_config = load_config_with_dotenv(config_path)
|
||||||
|
|
||||||
# Load .env file for env var resolution
|
|
||||||
from pathlib import Path
|
|
||||||
dotenv = Path(config_path).parent / ".env"
|
|
||||||
server_config.load_dotenv(str(dotenv))
|
|
||||||
|
|
||||||
# Re-load config after .env is loaded (env vars now available)
|
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
|
||||||
|
|
||||||
# Check if LLM API key is configured
|
# Check if LLM API key is configured
|
||||||
if not server_config.has_llm_provider():
|
if not server_config.has_llm_provider():
|
||||||
rprint("[yellow]No LLM API key configured.[/yellow]")
|
rprint("[yellow]No LLM API key configured.[/yellow]")
|
||||||
from rich.prompt import Confirm
|
from rich.prompt import Confirm
|
||||||
|
|
||||||
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
if Confirm.ask("Would you like to run the setup wizard?", default=True):
|
||||||
config_path = run_onboarding(config_arg=config)
|
config_path = run_onboarding(config_arg=config)
|
||||||
if config_path is None:
|
if config_path is None:
|
||||||
rprint("[red]Setup cancelled. Server may not function correctly without API key.[/red]")
|
rprint(
|
||||||
|
"[red]Setup cancelled. Server may not function correctly without API key.[/red]"
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
server_config = load_config_with_dotenv(config_path)
|
||||||
server_config.load_dotenv(str(dotenv))
|
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
|
||||||
else:
|
else:
|
||||||
rprint("[dim]Continuing without LLM provider — API calls will fail.[/dim]")
|
rprint("[dim]Continuing without LLM provider — API calls will fail.[/dim]")
|
||||||
|
|
||||||
|
|
@ -216,6 +222,7 @@ def serve(
|
||||||
# Store config for app factory
|
# Store config for app factory
|
||||||
import os
|
import os
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
os.environ["AGENTKIT_CONFIG_PATH"] = config_path
|
os.environ["AGENTKIT_CONFIG_PATH"] = config_path
|
||||||
# Pass task_store overrides via env var so create_app can read them
|
# Pass task_store overrides via env var so create_app can read them
|
||||||
if server_config.task_store:
|
if server_config.task_store:
|
||||||
|
|
@ -233,6 +240,7 @@ def serve(
|
||||||
# Apply CLI task_store overrides even without config file
|
# Apply CLI task_store overrides even without config file
|
||||||
import os
|
import os
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
ts_override: dict = {}
|
ts_override: dict = {}
|
||||||
if task_store_backend is not None:
|
if task_store_backend is not None:
|
||||||
ts_override["backend"] = task_store_backend
|
ts_override["backend"] = task_store_backend
|
||||||
|
|
@ -258,6 +266,7 @@ def version():
|
||||||
"""Show AgentKit version"""
|
"""Show AgentKit version"""
|
||||||
try:
|
try:
|
||||||
from importlib.metadata import version as get_version
|
from importlib.metadata import version as get_version
|
||||||
|
|
||||||
v = get_version("fischer-agentkit")
|
v = get_version("fischer-agentkit")
|
||||||
except Exception:
|
except Exception:
|
||||||
v = "0.1.0 (dev)"
|
v = "0.1.0 (dev)"
|
||||||
|
|
|
||||||
|
|
@ -109,8 +109,18 @@ PROVIDER_PRESETS: dict[str, dict[str, Any]] = {
|
||||||
|
|
||||||
|
|
||||||
def needs_onboarding(config_arg: str | None = None) -> bool:
|
def needs_onboarding(config_arg: str | None = None) -> bool:
|
||||||
"""Check if onboarding is needed (no config file found)."""
|
"""Check if onboarding is needed (no config file found or no valid LLM provider)."""
|
||||||
return find_config_path(config_arg) is None
|
config_path = find_config_path(config_arg)
|
||||||
|
if config_path is None:
|
||||||
|
return True
|
||||||
|
# Config exists but has no valid LLM provider — needs onboarding to add one
|
||||||
|
from agentkit.server.config import load_config_with_dotenv
|
||||||
|
|
||||||
|
try:
|
||||||
|
config = load_config_with_dotenv(config_path)
|
||||||
|
return not config.has_llm_provider()
|
||||||
|
except Exception:
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def run_onboarding(
|
def run_onboarding(
|
||||||
|
|
@ -119,20 +129,48 @@ def run_onboarding(
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
"""Run the interactive onboarding wizard.
|
"""Run the interactive onboarding wizard.
|
||||||
|
|
||||||
Returns:
|
If agentkit.yaml already exists, only the LLM section is updated
|
||||||
Path to the generated config file, or None if cancelled.
|
(preserving all other settings). If it doesn't exist, a full config
|
||||||
"""
|
is generated.
|
||||||
rprint(Panel(
|
|
||||||
"[bold]Welcome to AgentKit![/bold]\n\n"
|
|
||||||
"No configuration file found. Let's set up your first Agent.\n"
|
|
||||||
"This will create [cyan]agentkit.yaml[/cyan] and [cyan].env[/cyan] for you.",
|
|
||||||
title="AgentKit Setup",
|
|
||||||
border_style="bright_blue",
|
|
||||||
))
|
|
||||||
|
|
||||||
output_path = Path(output_dir).resolve()
|
Returns:
|
||||||
|
Path to the generated/updated config file, or None if cancelled.
|
||||||
|
"""
|
||||||
|
# Determine output directory from config_arg if provided
|
||||||
|
if config_arg:
|
||||||
|
output_path = Path(config_arg).resolve().parent
|
||||||
|
else:
|
||||||
|
output_path = Path(output_dir).resolve()
|
||||||
output_path.mkdir(parents=True, exist_ok=True)
|
output_path.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
existing_config_path = find_config_path(config_arg)
|
||||||
|
existing_config: dict[str, Any] | None = None
|
||||||
|
if existing_config_path:
|
||||||
|
with open(existing_config_path, encoding="utf-8") as f:
|
||||||
|
existing_config = yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
if existing_config:
|
||||||
|
rprint(
|
||||||
|
Panel(
|
||||||
|
"[bold]AgentKit Configuration Update[/bold]\n\n"
|
||||||
|
"An [cyan]agentkit.yaml[/cyan] already exists but has no valid LLM provider.\n"
|
||||||
|
"This wizard will [green]update[/green] the LLM section while preserving\n"
|
||||||
|
"your existing settings.",
|
||||||
|
title="AgentKit Setup",
|
||||||
|
border_style="bright_blue",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
rprint(
|
||||||
|
Panel(
|
||||||
|
"[bold]Welcome to AgentKit![/bold]\n\n"
|
||||||
|
"No configuration file found. Let's set up your first Agent.\n"
|
||||||
|
"This will create [cyan]agentkit.yaml[/cyan] and [cyan].env[/cyan] for you.",
|
||||||
|
title="AgentKit Setup",
|
||||||
|
border_style="bright_blue",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# ── Step 1: Choose LLM provider ──────────────────────────────
|
# ── Step 1: Choose LLM provider ──────────────────────────────
|
||||||
rprint("\n[bold]Step 1: Choose your LLM provider[/bold]")
|
rprint("\n[bold]Step 1: Choose your LLM provider[/bold]")
|
||||||
provider_keys = list(PROVIDER_PRESETS.keys())
|
provider_keys = list(PROVIDER_PRESETS.keys())
|
||||||
|
|
@ -169,12 +207,16 @@ def run_onboarding(
|
||||||
for i, model in enumerate(available_models, 1):
|
for i, model in enumerate(available_models, 1):
|
||||||
alias = preset["models"][model].get("alias", "")
|
alias = preset["models"][model].get("alias", "")
|
||||||
alias_str = f" [dim]({alias})[/dim]" if alias else ""
|
alias_str = f" [dim]({alias})[/dim]" if alias else ""
|
||||||
recommended = " [green]← recommended[/green]" if model == preset.get("default_model") else ""
|
recommended = (
|
||||||
|
" [green]← recommended[/green]" if model == preset.get("default_model") else ""
|
||||||
|
)
|
||||||
rprint(f" [cyan]{i}[/cyan]. {model}{alias_str}{recommended}")
|
rprint(f" [cyan]{i}[/cyan]. {model}{alias_str}{recommended}")
|
||||||
model_choice = Prompt.ask(
|
model_choice = Prompt.ask(
|
||||||
"Select default model",
|
"Select default model",
|
||||||
choices=[str(i) for i in range(1, len(available_models) + 1)],
|
choices=[str(i) for i in range(1, len(available_models) + 1)],
|
||||||
default=str(available_models.index(preset.get("default_model", available_models[0])) + 1),
|
default=str(
|
||||||
|
available_models.index(preset.get("default_model", available_models[0])) + 1
|
||||||
|
),
|
||||||
)
|
)
|
||||||
selected_model = available_models[int(model_choice) - 1]
|
selected_model = available_models[int(model_choice) - 1]
|
||||||
# Rebuild models dict: selected model gets "default" alias
|
# Rebuild models dict: selected model gets "default" alias
|
||||||
|
|
@ -184,7 +226,9 @@ def run_onboarding(
|
||||||
updated_models[model] = {**conf, "alias": "default"}
|
updated_models[model] = {**conf, "alias": "default"}
|
||||||
else:
|
else:
|
||||||
# Remove "default" alias from other models
|
# Remove "default" alias from other models
|
||||||
updated_models[model] = {k: v for k, v in conf.items() if k != "alias" or v != "default"}
|
updated_models[model] = {
|
||||||
|
k: v for k, v in conf.items() if k != "alias" or v != "default"
|
||||||
|
}
|
||||||
preset = {**preset, "models": updated_models}
|
preset = {**preset, "models": updated_models}
|
||||||
rprint(f"[green]Selected: {selected_model}[/green]")
|
rprint(f"[green]Selected: {selected_model}[/green]")
|
||||||
else:
|
else:
|
||||||
|
|
@ -200,7 +244,11 @@ def run_onboarding(
|
||||||
"models": preset["models"],
|
"models": preset["models"],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
model_aliases: dict[str, str] = {alias: f"{selected_key}/{model}" for model, conf in preset["models"].items() if (alias := conf.get("alias"))}
|
model_aliases: dict[str, str] = {
|
||||||
|
alias: f"{selected_key}/{model}"
|
||||||
|
for model, conf in preset["models"].items()
|
||||||
|
if (alias := conf.get("alias"))
|
||||||
|
}
|
||||||
|
|
||||||
if Confirm.ask("\nWould you like to add a second LLM provider (for fallback)?", default=False):
|
if Confirm.ask("\nWould you like to add a second LLM provider (for fallback)?", default=False):
|
||||||
remaining = [k for k in provider_keys if k != selected_key]
|
remaining = [k for k in provider_keys if k != selected_key]
|
||||||
|
|
@ -228,38 +276,47 @@ def run_onboarding(
|
||||||
if alias and alias not in model_aliases:
|
if alias and alias not in model_aliases:
|
||||||
model_aliases[alias] = f"{key2}/{model}"
|
model_aliases[alias] = f"{key2}/{model}"
|
||||||
|
|
||||||
# ── Step 4: Generate config files ─────────────────────────────
|
# ── Step 4: Generate/update config files ─────────────────────────
|
||||||
rprint("\n[bold]Step 3: Generating configuration...[/bold]")
|
rprint("\n[bold]Step 3: Generating configuration...[/bold]")
|
||||||
|
|
||||||
config = {
|
if existing_config:
|
||||||
"server": {
|
# Merge: only update LLM section, preserve everything else
|
||||||
"host": "0.0.0.0",
|
if "llm" not in existing_config:
|
||||||
"port": 8001,
|
existing_config["llm"] = {}
|
||||||
"workers": 1,
|
existing_config["llm"]["providers"] = providers_config
|
||||||
"rate_limit": 60,
|
existing_config["llm"]["model_aliases"] = model_aliases
|
||||||
},
|
config = existing_config
|
||||||
"llm": {
|
else:
|
||||||
"providers": providers_config,
|
# New config — generate full template
|
||||||
"model_aliases": model_aliases,
|
config = {
|
||||||
},
|
"server": {
|
||||||
"session": {
|
"host": "0.0.0.0",
|
||||||
"backend": "memory",
|
"port": 8001,
|
||||||
},
|
"workers": 1,
|
||||||
"bus": {
|
"rate_limit": 60,
|
||||||
"backend": "memory",
|
},
|
||||||
},
|
"llm": {
|
||||||
"task_store": {
|
"providers": providers_config,
|
||||||
"backend": "memory",
|
"model_aliases": model_aliases,
|
||||||
},
|
},
|
||||||
"skills": {
|
"session": {
|
||||||
"auto_discover": True,
|
"backend": "memory",
|
||||||
"paths": ["./skills"],
|
},
|
||||||
},
|
"bus": {
|
||||||
"logging": {
|
"backend": "memory",
|
||||||
"level": "INFO",
|
},
|
||||||
"format": "text",
|
"task_store": {
|
||||||
},
|
"backend": "memory",
|
||||||
}
|
},
|
||||||
|
"skills": {
|
||||||
|
"auto_discover": True,
|
||||||
|
"paths": ["./configs/skills"],
|
||||||
|
},
|
||||||
|
"logging": {
|
||||||
|
"level": "INFO",
|
||||||
|
"format": "text",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
# Write agentkit.yaml
|
# Write agentkit.yaml
|
||||||
config_path = output_path / "agentkit.yaml"
|
config_path = output_path / "agentkit.yaml"
|
||||||
|
|
@ -267,14 +324,28 @@ def run_onboarding(
|
||||||
yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
yaml.dump(config, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||||
rprint(f" [green]Created:[/green] {config_path}")
|
rprint(f" [green]Created:[/green] {config_path}")
|
||||||
|
|
||||||
# Write .env
|
# Write .env (merge with existing)
|
||||||
env_path = output_path / ".env"
|
env_path = output_path / ".env"
|
||||||
env_lines = [f"{k}={v}" for k, v in env_vars.items()]
|
existing_env: dict[str, str] = {}
|
||||||
|
existing_env_lines: list[str] = []
|
||||||
|
if env_path.exists():
|
||||||
|
with open(env_path, encoding="utf-8") as f:
|
||||||
|
existing_env_lines = f.readlines()
|
||||||
|
for line in existing_env_lines:
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped and not stripped.startswith("#") and "=" in stripped:
|
||||||
|
k, _, v = stripped.partition("=")
|
||||||
|
existing_env[k.strip()] = v.strip().strip("\"'")
|
||||||
|
|
||||||
|
# Merge new env vars (new values take precedence)
|
||||||
|
existing_env.update(env_vars)
|
||||||
|
|
||||||
with open(env_path, "w", encoding="utf-8") as f:
|
with open(env_path, "w", encoding="utf-8") as f:
|
||||||
f.write("# AgentKit Environment Variables\n")
|
f.write("# AgentKit Environment Variables\n")
|
||||||
f.write("# Generated by onboarding wizard\n\n")
|
f.write("# Generated by onboarding wizard\n\n")
|
||||||
f.write("\n".join(env_lines) + "\n")
|
for k, v in existing_env.items():
|
||||||
rprint(f" [green]Created:[/green] {env_path}")
|
f.write(f"{k}={v}\n")
|
||||||
|
rprint(f" [green]Updated:[/green] {env_path}")
|
||||||
|
|
||||||
# ── Step 4: Agent personality (optional) ──────────────────────
|
# ── Step 4: Agent personality (optional) ──────────────────────
|
||||||
rprint("\n[bold]Step 4: Customize your Agent (optional)[/bold]")
|
rprint("\n[bold]Step 4: Customize your Agent (optional)[/bold]")
|
||||||
|
|
@ -286,6 +357,7 @@ def run_onboarding(
|
||||||
|
|
||||||
# Create SOUL.md
|
# Create SOUL.md
|
||||||
from agentkit.memory.profile import MemoryStore
|
from agentkit.memory.profile import MemoryStore
|
||||||
|
|
||||||
memory_store = MemoryStore(base_dir=Path.home() / ".agentkit")
|
memory_store = MemoryStore(base_dir=Path.home() / ".agentkit")
|
||||||
soul_content = f"""## 身份
|
soul_content = f"""## 身份
|
||||||
我是{agent_name},一个专业的 AI 助手。
|
我是{agent_name},一个专业的 AI 助手。
|
||||||
|
|
@ -304,12 +376,14 @@ def run_onboarding(
|
||||||
memory_store.get_file("soul").write(soul_content.strip())
|
memory_store.get_file("soul").write(soul_content.strip())
|
||||||
rprint(" [green]Created:[/green] ~/.agentkit/SOUL.md")
|
rprint(" [green]Created:[/green] ~/.agentkit/SOUL.md")
|
||||||
|
|
||||||
rprint(Panel(
|
rprint(
|
||||||
"[bold green]Setup complete![/bold green]\n\n"
|
Panel(
|
||||||
"You can now run:\n"
|
"[bold green]Setup complete![/bold green]\n\n"
|
||||||
" [cyan]agentkit chat[/cyan] — Start chatting with your Agent\n"
|
"You can now run:\n"
|
||||||
" [cyan]agentkit serve[/cyan] — Start the API server",
|
" [cyan]agentkit chat[/cyan] — Start chatting with your Agent\n"
|
||||||
border_style="green",
|
" [cyan]agentkit serve[/cyan] — Start the API server",
|
||||||
))
|
border_style="green",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return str(config_path)
|
return str(config_path)
|
||||||
|
|
|
||||||
|
|
@ -14,12 +14,16 @@ task_app = typer.Typer(name="task", help="Task management commands", no_args_is_
|
||||||
@task_app.command("submit")
|
@task_app.command("submit")
|
||||||
def submit(
|
def submit(
|
||||||
input: Optional[str] = typer.Option(None, "--input", "-i", help="Input data as JSON string"),
|
input: Optional[str] = typer.Option(None, "--input", "-i", help="Input data as JSON string"),
|
||||||
input_file: Optional[str] = typer.Option(None, "--input-file", "-f", help="Input data from JSON file"),
|
input_file: Optional[str] = typer.Option(
|
||||||
|
None, "--input-file", "-f", help="Input data from JSON file"
|
||||||
|
),
|
||||||
skill: Optional[str] = typer.Option(None, "--skill", "-s", help="Skill name"),
|
skill: Optional[str] = typer.Option(None, "--skill", "-s", help="Skill name"),
|
||||||
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent name"),
|
agent: Optional[str] = typer.Option(None, "--agent", "-a", help="Agent name"),
|
||||||
mode: str = typer.Option("sync", "--mode", "-m", help="Execution mode: sync or async"),
|
mode: str = typer.Option("sync", "--mode", "-m", help="Execution mode: sync or async"),
|
||||||
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
|
server_url: Optional[str] = typer.Option(None, "--server-url", help="AgentKit server URL"),
|
||||||
config: Optional[str] = typer.Option(None, "--config", help="Path to agentkit.yaml (local mode)"),
|
config: Optional[str] = typer.Option(
|
||||||
|
None, "--config", help="Path to agentkit.yaml (local mode)"
|
||||||
|
),
|
||||||
):
|
):
|
||||||
"""Submit a task for execution"""
|
"""Submit a task for execution"""
|
||||||
# Parse input data
|
# Parse input data
|
||||||
|
|
@ -43,23 +47,28 @@ def submit(
|
||||||
def _submit_remote(input_data, skill, agent, mode, server_url):
|
def _submit_remote(input_data, skill, agent, mode, server_url):
|
||||||
"""Submit task to a remote AgentKit server."""
|
"""Submit task to a remote AgentKit server."""
|
||||||
from agentkit.server.client import AgentKitClient
|
from agentkit.server.client import AgentKitClient
|
||||||
|
|
||||||
client = AgentKitClient(base_url=server_url)
|
client = AgentKitClient(base_url=server_url)
|
||||||
|
|
||||||
if mode == "async":
|
if mode == "async":
|
||||||
result = asyncio.run(client.submit_task_async(
|
result = asyncio.run(
|
||||||
input_data=input_data,
|
client.submit_task_async(
|
||||||
skill_name=skill,
|
input_data=input_data,
|
||||||
agent_name=agent,
|
skill_name=skill,
|
||||||
))
|
agent_name=agent,
|
||||||
|
)
|
||||||
|
)
|
||||||
rprint("[green]Task submitted (async)[/green]")
|
rprint("[green]Task submitted (async)[/green]")
|
||||||
rprint(f" Task ID: {result.get('task_id', 'N/A')}")
|
rprint(f" Task ID: {result.get('task_id', 'N/A')}")
|
||||||
rprint(f" Status: {result.get('status', 'N/A')}")
|
rprint(f" Status: {result.get('status', 'N/A')}")
|
||||||
else:
|
else:
|
||||||
result = asyncio.run(client.submit_task(
|
result = asyncio.run(
|
||||||
input_data=input_data,
|
client.submit_task(
|
||||||
skill_name=skill,
|
input_data=input_data,
|
||||||
agent_name=agent,
|
skill_name=skill,
|
||||||
))
|
agent_name=agent,
|
||||||
|
)
|
||||||
|
)
|
||||||
rprint("[green]Task completed[/green]")
|
rprint("[green]Task completed[/green]")
|
||||||
if "output_data" in result:
|
if "output_data" in result:
|
||||||
rprint(json.dumps(result["output_data"], indent=2, ensure_ascii=False))
|
rprint(json.dumps(result["output_data"], indent=2, ensure_ascii=False))
|
||||||
|
|
@ -67,19 +76,18 @@ def _submit_remote(input_data, skill, agent, mode, server_url):
|
||||||
|
|
||||||
def _submit_local(input_data, skill, agent, mode, config_path):
|
def _submit_local(input_data, skill, agent, mode, config_path):
|
||||||
"""Submit task locally without a running server."""
|
"""Submit task locally without a running server."""
|
||||||
from agentkit.server.config import ServerConfig, find_config_path
|
from agentkit.server.config import find_config_path, load_config_with_dotenv
|
||||||
|
|
||||||
# Load config
|
# Load config
|
||||||
resolved_path = find_config_path(config_path)
|
resolved_path = find_config_path(config_path)
|
||||||
if resolved_path:
|
if resolved_path:
|
||||||
server_config = ServerConfig.from_yaml(resolved_path)
|
server_config = load_config_with_dotenv(resolved_path)
|
||||||
server_config.load_dotenv()
|
|
||||||
server_config = ServerConfig.from_yaml(resolved_path)
|
|
||||||
else:
|
else:
|
||||||
server_config = None
|
server_config = None
|
||||||
|
|
||||||
# Build app components
|
# Build app components
|
||||||
from agentkit.server.app import create_app
|
from agentkit.server.app import create_app
|
||||||
|
|
||||||
app = create_app(server_config=server_config)
|
app = create_app(server_config=server_config)
|
||||||
|
|
||||||
# Execute task through the app's agent pool
|
# Execute task through the app's agent pool
|
||||||
|
|
@ -90,7 +98,9 @@ def _submit_local(input_data, skill, agent, mode, config_path):
|
||||||
# Determine which skill/agent to use
|
# Determine which skill/agent to use
|
||||||
if skill:
|
if skill:
|
||||||
if not skill_registry.has_skill(skill):
|
if not skill_registry.has_skill(skill):
|
||||||
rprint(f"[red]Skill '{skill}' not found. Available: {[s.name for s in skill_registry.list_skills()]}[/red]")
|
rprint(
|
||||||
|
f"[red]Skill '{skill}' not found. Available: {[s.name for s in skill_registry.list_skills()]}[/red]"
|
||||||
|
)
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
skill_obj = skill_registry.get(skill)
|
skill_obj = skill_registry.get(skill)
|
||||||
agent_name = skill_obj.name
|
agent_name = skill_obj.name
|
||||||
|
|
@ -102,9 +112,10 @@ def _submit_local(input_data, skill, agent, mode, config_path):
|
||||||
|
|
||||||
# Create agent and execute
|
# Create agent and execute
|
||||||
agent_instance = agent_pool.get_or_create(agent_name)
|
agent_instance = agent_pool.get_or_create(agent_name)
|
||||||
from agentkit.core.protocol import TaskMessage, TaskStatus
|
from agentkit.core.protocol import TaskMessage
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import uuid
|
import uuid
|
||||||
|
|
||||||
task = TaskMessage(
|
task = TaskMessage(
|
||||||
task_id=str(uuid.uuid4()),
|
task_id=str(uuid.uuid4()),
|
||||||
agent_name=agent_name,
|
agent_name=agent_name,
|
||||||
|
|
@ -134,6 +145,7 @@ def status(
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
from agentkit.server.client import AgentKitClient
|
from agentkit.server.client import AgentKitClient
|
||||||
|
|
||||||
client = AgentKitClient(base_url=server_url)
|
client = AgentKitClient(base_url=server_url)
|
||||||
result = asyncio.run(client.get_task_status(task_id))
|
result = asyncio.run(client.get_task_status(task_id))
|
||||||
|
|
||||||
|
|
@ -157,6 +169,7 @@ def list_tasks(
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
from agentkit.server.client import AgentKitClient
|
from agentkit.server.client import AgentKitClient
|
||||||
|
|
||||||
client = AgentKitClient(base_url=server_url)
|
client = AgentKitClient(base_url=server_url)
|
||||||
tasks = asyncio.run(client.list_tasks(status=status_filter, limit=limit))
|
tasks = asyncio.run(client.list_tasks(status=status_filter, limit=limit))
|
||||||
|
|
||||||
|
|
@ -190,6 +203,7 @@ def cancel(
|
||||||
raise typer.Exit(code=1)
|
raise typer.Exit(code=1)
|
||||||
|
|
||||||
from agentkit.server.client import AgentKitClient
|
from agentkit.server.client import AgentKitClient
|
||||||
|
|
||||||
client = AgentKitClient(base_url=server_url)
|
client = AgentKitClient(base_url=server_url)
|
||||||
result = asyncio.run(client.cancel_task(task_id))
|
result = asyncio.run(client.cancel_task(task_id))
|
||||||
rprint(f"[green]Task cancelled[/green]: {result}")
|
rprint(f"[green]Task cancelled[/green]: {result}")
|
||||||
|
|
|
||||||
|
|
@ -13,18 +13,24 @@ server:
|
||||||
|
|
||||||
llm:
|
llm:
|
||||||
providers:
|
providers:
|
||||||
dashscope:
|
bailian-coding:
|
||||||
type: openai
|
type: openai
|
||||||
api_key: "${DASHSCOPE_API_KEY}"
|
api_key: "${DASHSCOPE_API_KEY}"
|
||||||
base_url: "https://coding.dashscope.aliyuncs.com/v1"
|
base_url: "https://coding.dashscope.aliyuncs.com/v1"
|
||||||
models:
|
models:
|
||||||
|
qwen3.7-plus:
|
||||||
|
alias: default
|
||||||
qwen3-coder-plus:
|
qwen3-coder-plus:
|
||||||
max_tokens: 64000
|
alias: coding
|
||||||
qwen3-max:
|
qwen3-max-2026-01-23:
|
||||||
max_tokens: 128000
|
alias: powerful
|
||||||
|
qwen-turbo:
|
||||||
|
alias: fast
|
||||||
model_aliases:
|
model_aliases:
|
||||||
default: dashscope/qwen3-coder-plus
|
default: bailian-coding/qwen3.7-plus
|
||||||
powerful: dashscope/qwen3-max
|
coding: bailian-coding/qwen3-coder-plus
|
||||||
|
powerful: bailian-coding/qwen3-max-2026-01-23
|
||||||
|
fast: bailian-coding/qwen-turbo
|
||||||
|
|
||||||
session:
|
session:
|
||||||
backend: memory
|
backend: memory
|
||||||
|
|
@ -32,6 +38,9 @@ session:
|
||||||
bus:
|
bus:
|
||||||
backend: memory
|
backend: memory
|
||||||
|
|
||||||
|
task_store:
|
||||||
|
backend: memory
|
||||||
|
|
||||||
skills:
|
skills:
|
||||||
auto_discover: true
|
auto_discover: true
|
||||||
paths:
|
paths:
|
||||||
|
|
@ -40,6 +49,10 @@ skills:
|
||||||
logging:
|
logging:
|
||||||
level: "INFO"
|
level: "INFO"
|
||||||
format: "text" # "text" or "json"
|
format: "text" # "text" or "json"
|
||||||
|
|
||||||
|
router:
|
||||||
|
classifier: heuristic
|
||||||
|
auction_enabled: false
|
||||||
"""
|
"""
|
||||||
|
|
||||||
ENV_EXAMPLE = """\
|
ENV_EXAMPLE = """\
|
||||||
|
|
@ -49,8 +62,13 @@ ENV_EXAMPLE = """\
|
||||||
# LLM API Keys (at least one required)
|
# LLM API Keys (at least one required)
|
||||||
DASHSCOPE_API_KEY=sk-your-dashscope-key
|
DASHSCOPE_API_KEY=sk-your-dashscope-key
|
||||||
|
|
||||||
|
# Optional: additional providers
|
||||||
|
# DEEPSEEK_API_KEY=sk-your-deepseek-key
|
||||||
|
# OPENAI_API_KEY=sk-your-openai-key
|
||||||
|
# ANTHROPIC_API_KEY=sk-your-anthropic-key
|
||||||
|
|
||||||
# Server (optional)
|
# Server (optional)
|
||||||
AGENTKIT_API_KEY= # Set to enable API key authentication
|
# AGENTKIT_API_KEY= # Set to enable API key authentication
|
||||||
"""
|
"""
|
||||||
|
|
||||||
DOCKER_COMPOSE = """\
|
DOCKER_COMPOSE = """\
|
||||||
|
|
@ -66,6 +84,8 @@ services:
|
||||||
depends_on:
|
depends_on:
|
||||||
redis:
|
redis:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
healthcheck:
|
healthcheck:
|
||||||
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health')"]
|
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8001/api/v1/health')"]
|
||||||
interval: 30s
|
interval: 30s
|
||||||
|
|
@ -82,6 +102,22 @@ services:
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 5
|
retries: 5
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: pgvector/pgvector:pg16
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: agentkit
|
||||||
|
POSTGRES_USER: agentkit
|
||||||
|
POSTGRES_PASSWORD: agentkit
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U agentkit"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
pgdata:
|
pgdata:
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,7 @@ from agentkit.skills.base import Skill
|
||||||
from agentkit.skills.registry import SkillRegistry
|
from agentkit.skills.registry import SkillRegistry
|
||||||
from agentkit.tools.registry import ToolRegistry
|
from agentkit.tools.registry import ToolRegistry
|
||||||
from agentkit.tools.skill_install import SkillInstallTool
|
from agentkit.tools.skill_install import SkillInstallTool
|
||||||
from agentkit.server.config import ServerConfig
|
from agentkit.server.config import ServerConfig, load_dotenv
|
||||||
from agentkit.server.routes import (
|
from agentkit.server.routes import (
|
||||||
agents,
|
agents,
|
||||||
tasks,
|
tasks,
|
||||||
|
|
@ -49,18 +49,6 @@ from agentkit.telemetry.setup import setup_telemetry
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_ALLOWED_ENV_PREFIXES = (
|
|
||||||
"AGENTKIT_",
|
|
||||||
"DASHSCOPE_",
|
|
||||||
"OPENAI_",
|
|
||||||
"ANTHROPIC_",
|
|
||||||
"GEMINI_",
|
|
||||||
"TAVILY_",
|
|
||||||
"SERPER_",
|
|
||||||
"DEEPSEEK_",
|
|
||||||
)
|
|
||||||
_ALLOWED_ENV_EXACT = {"DATABASE_URL", "REDIS_URL"}
|
|
||||||
|
|
||||||
|
|
||||||
def _build_llm_gateway(config: ServerConfig) -> LLMGateway:
|
def _build_llm_gateway(config: ServerConfig) -> LLMGateway:
|
||||||
"""Build LLMGateway from ServerConfig, registering all providers."""
|
"""Build LLMGateway from ServerConfig, registering all providers."""
|
||||||
|
|
@ -487,27 +475,10 @@ def create_app(
|
||||||
|
|
||||||
if config_path and os.path.exists(config_path):
|
if config_path and os.path.exists(config_path):
|
||||||
# Load .env before parsing config (so ${ENV_VAR} substitutions work)
|
# Load .env before parsing config (so ${ENV_VAR} substitutions work)
|
||||||
|
from pathlib import Path as _P
|
||||||
|
|
||||||
_dotenv = _P(config_path).parent / ".env"
|
_dotenv = _P(config_path).parent / ".env"
|
||||||
if _dotenv.exists():
|
load_dotenv(_dotenv)
|
||||||
with open(_dotenv, encoding="utf-8") as _f:
|
|
||||||
for _line in _f:
|
|
||||||
_line = _line.strip()
|
|
||||||
if not _line or _line.startswith("#") or "=" not in _line:
|
|
||||||
continue
|
|
||||||
_key, _, _val = _line.partition("=")
|
|
||||||
_key = _key.strip()
|
|
||||||
_val = _val.strip().strip("\"'")
|
|
||||||
if _key and _key not in os.environ:
|
|
||||||
allowed = (
|
|
||||||
any(_key.startswith(p) for p in _ALLOWED_ENV_PREFIXES)
|
|
||||||
or _key in _ALLOWED_ENV_EXACT
|
|
||||||
)
|
|
||||||
if not allowed:
|
|
||||||
logger.warning(
|
|
||||||
f"Skipping .env variable '{_key}' (not in allowed prefixes)"
|
|
||||||
)
|
|
||||||
continue
|
|
||||||
os.environ[_key] = _val
|
|
||||||
server_config = ServerConfig.from_yaml(config_path)
|
server_config = ServerConfig.from_yaml(config_path)
|
||||||
app = FastAPI(title="AgentKit Server", version="2.0.0", lifespan=lifespan)
|
app = FastAPI(title="AgentKit Server", version="2.0.0", lifespan=lifespan)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,10 @@ class ClientConfig:
|
||||||
clients_path = os.path.join(self.config_dir, "clients.yaml")
|
clients_path = os.path.join(self.config_dir, "clients.yaml")
|
||||||
if os.path.exists(clients_path):
|
if os.path.exists(clients_path):
|
||||||
with open(clients_path, encoding="utf-8") as f:
|
with open(clients_path, encoding="utf-8") as f:
|
||||||
return yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
|
from agentkit.server.config import _deep_resolve
|
||||||
|
|
||||||
|
return _deep_resolve(data)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
|
|
@ -49,13 +52,20 @@ class ClientConfig:
|
||||||
return client_info["skills_dir"]
|
return client_info["skills_dir"]
|
||||||
# Fall back to default from agentkit.yaml
|
# Fall back to default from agentkit.yaml
|
||||||
default_config = self._load_default_config()
|
default_config = self._load_default_config()
|
||||||
return default_config.get("skills", {}).get("paths", ["./skills"])[0] if default_config else None
|
return (
|
||||||
|
default_config.get("skills", {}).get("paths", ["./skills"])[0]
|
||||||
|
if default_config
|
||||||
|
else None
|
||||||
|
)
|
||||||
|
|
||||||
def _load_default_config(self) -> dict:
|
def _load_default_config(self) -> dict:
|
||||||
config_path = os.path.join(self.config_dir, "agentkit.yaml")
|
config_path = os.path.join(self.config_dir, "agentkit.yaml")
|
||||||
if os.path.exists(config_path):
|
if os.path.exists(config_path):
|
||||||
|
from agentkit.server.config import _deep_resolve
|
||||||
|
|
||||||
with open(config_path, encoding="utf-8") as f:
|
with open(config_path, encoding="utf-8") as f:
|
||||||
return yaml.safe_load(f) or {}
|
data = yaml.safe_load(f) or {}
|
||||||
|
return _deep_resolve(data)
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
def validate_api_key(self, api_key: str) -> bool:
|
def validate_api_key(self, api_key: str) -> bool:
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
|
|
@ -148,9 +148,9 @@ class ServerConfig:
|
||||||
self._last_mtime: float = 0.0
|
self._last_mtime: float = 0.0
|
||||||
|
|
||||||
def has_llm_provider(self) -> bool:
|
def has_llm_provider(self) -> bool:
|
||||||
"""检查是否配置了有效的 LLM Provider(API Key 非空)"""
|
"""检查是否配置了有效的 LLM Provider(API Key 已解析且非空)"""
|
||||||
for name, provider in self.llm_config.providers.items():
|
for name, provider in self.llm_config.providers.items():
|
||||||
if provider.api_key:
|
if provider.api_key and not provider.api_key.startswith("${"):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
@ -333,25 +333,6 @@ class ServerConfig:
|
||||||
logger.warning(f"Failed to load skill config from {yaml_file}: {e}")
|
logger.warning(f"Failed to load skill config from {yaml_file}: {e}")
|
||||||
return configs
|
return configs
|
||||||
|
|
||||||
def load_dotenv(self, dotenv_path: str = ".env") -> None:
|
|
||||||
"""Load environment variables from a .env file (simple key=value format)."""
|
|
||||||
path = Path(dotenv_path)
|
|
||||||
if not path.exists():
|
|
||||||
return
|
|
||||||
|
|
||||||
with open(path, encoding="utf-8") as f:
|
|
||||||
for line in f:
|
|
||||||
line = line.strip()
|
|
||||||
if not line or line.startswith("#"):
|
|
||||||
continue
|
|
||||||
if "=" not in line:
|
|
||||||
continue
|
|
||||||
key, _, value = line.partition("=")
|
|
||||||
key = key.strip()
|
|
||||||
value = value.strip().strip("\"'")
|
|
||||||
if key and key not in os.environ:
|
|
||||||
os.environ[key] = value
|
|
||||||
|
|
||||||
def watch_config(self, config_path: str | None = None) -> None:
|
def watch_config(self, config_path: str | None = None) -> None:
|
||||||
"""Start watching the config file for changes and hot-reload.
|
"""Start watching the config file for changes and hot-reload.
|
||||||
|
|
||||||
|
|
@ -376,6 +357,7 @@ class ServerConfig:
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import watchfiles # noqa: F401
|
import watchfiles # noqa: F401
|
||||||
|
|
||||||
self._watcher_task = asyncio.ensure_future(self._watch_with_watchfiles(path))
|
self._watcher_task = asyncio.ensure_future(self._watch_with_watchfiles(path))
|
||||||
logger.info(f"Config watcher started (watchfiles) for {path}")
|
logger.info(f"Config watcher started (watchfiles) for {path}")
|
||||||
except ImportError:
|
except ImportError:
|
||||||
|
|
@ -393,6 +375,7 @@ class ServerConfig:
|
||||||
"""Watch config file using watchfiles library."""
|
"""Watch config file using watchfiles library."""
|
||||||
try:
|
try:
|
||||||
from watchfiles import awatch
|
from watchfiles import awatch
|
||||||
|
|
||||||
async for changes in awatch(path):
|
async for changes in awatch(path):
|
||||||
for change_type, changed_path in changes:
|
for change_type, changed_path in changes:
|
||||||
logger.info(f"Config file change detected: {change_type} on {changed_path}")
|
logger.info(f"Config file change detected: {change_type} on {changed_path}")
|
||||||
|
|
@ -428,7 +411,7 @@ class ServerConfig:
|
||||||
return
|
return
|
||||||
|
|
||||||
# Validate basic structure: must have at least a server or llm section
|
# Validate basic structure: must have at least a server or llm section
|
||||||
if not hasattr(new_config, 'host') or not hasattr(new_config, 'llm_config'):
|
if not hasattr(new_config, "host") or not hasattr(new_config, "llm_config"):
|
||||||
logger.error(f"Invalid config structure in {path}. Keeping current config.")
|
logger.error(f"Invalid config structure in {path}. Keeping current config.")
|
||||||
return
|
return
|
||||||
|
|
||||||
|
|
@ -464,6 +447,84 @@ class ServerConfig:
|
||||||
logger.error(f"Config on_change callback error: {e}")
|
logger.error(f"Config on_change callback error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── .env loading ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_ALLOWED_ENV_PREFIXES = (
|
||||||
|
"AGENTKIT_",
|
||||||
|
"DASHSCOPE_",
|
||||||
|
"OPENAI_",
|
||||||
|
"ANTHROPIC_",
|
||||||
|
"GEMINI_",
|
||||||
|
"TAVILY_",
|
||||||
|
"SERPER_",
|
||||||
|
"DEEPSEEK_",
|
||||||
|
"DOUBAO_",
|
||||||
|
)
|
||||||
|
_ALLOWED_ENV_EXACT = {"DATABASE_URL", "REDIS_URL"}
|
||||||
|
|
||||||
|
|
||||||
|
def load_dotenv(
|
||||||
|
dotenv_path: str | Path,
|
||||||
|
*,
|
||||||
|
allowed_prefixes: tuple[str, ...] | None = None,
|
||||||
|
allowed_exact: set[str] | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Load environment variables from a .env file.
|
||||||
|
|
||||||
|
Only variables matching allowed prefixes or exact names are loaded.
|
||||||
|
Existing environment variables are never overwritten.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
dotenv_path: Path to the .env file.
|
||||||
|
allowed_prefixes: Env var prefixes to allow. None = allow all.
|
||||||
|
allowed_exact: Exact env var names to allow. None = allow all.
|
||||||
|
"""
|
||||||
|
path = Path(dotenv_path)
|
||||||
|
if not path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
prefixes = allowed_prefixes
|
||||||
|
exact = allowed_exact
|
||||||
|
|
||||||
|
with open(path, encoding="utf-8") as f:
|
||||||
|
for line in f:
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "=" not in line:
|
||||||
|
continue
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
key = key.strip()
|
||||||
|
value = value.strip().strip("\"'")
|
||||||
|
if not key or key in os.environ:
|
||||||
|
continue
|
||||||
|
# Apply allowlist if provided
|
||||||
|
if prefixes is not None or exact is not None:
|
||||||
|
allowed = False
|
||||||
|
if prefixes and any(key.startswith(p) for p in prefixes):
|
||||||
|
allowed = True
|
||||||
|
if exact and key in exact:
|
||||||
|
allowed = True
|
||||||
|
if not allowed:
|
||||||
|
logger.warning(f"Skipping .env variable '{key}' (not in allowed list)")
|
||||||
|
continue
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
|
def load_config_with_dotenv(config_path: str | Path) -> ServerConfig:
|
||||||
|
"""Load ServerConfig with .env resolution (production-standard loading).
|
||||||
|
|
||||||
|
1. Load .env from config file's parent directory
|
||||||
|
2. Parse agentkit.yaml with env vars resolved
|
||||||
|
|
||||||
|
This is the canonical way to load config in all CLI commands and app factory.
|
||||||
|
"""
|
||||||
|
config_path = str(config_path)
|
||||||
|
dotenv = Path(config_path).parent / ".env"
|
||||||
|
load_dotenv(dotenv)
|
||||||
|
return ServerConfig.from_yaml(config_path)
|
||||||
|
|
||||||
|
|
||||||
def find_config_path(config_arg: str | None = None) -> str | None:
|
def find_config_path(config_arg: str | None = None) -> str | None:
|
||||||
"""Find the agentkit.yaml config file.
|
"""Find the agentkit.yaml config file.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
"""Settings API routes with config hot-reload support."""
|
"""Settings API routes with config hot-reload support."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import os
|
||||||
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import yaml
|
import yaml
|
||||||
|
|
@ -16,6 +18,7 @@ router = APIRouter(tags=["settings"])
|
||||||
# Helper: mask API keys (show only last 4 chars)
|
# Helper: mask API keys (show only last 4 chars)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _mask_api_key(key: str | None) -> str:
|
def _mask_api_key(key: str | None) -> str:
|
||||||
"""Mask an API key, showing only the last 4 characters."""
|
"""Mask an API key, showing only the last 4 characters."""
|
||||||
if not key:
|
if not key:
|
||||||
|
|
@ -29,6 +32,7 @@ def _mask_api_key(key: str | None) -> str:
|
||||||
# Pydantic models for request/response
|
# Pydantic models for request/response
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
class LlmProviderResponse(BaseModel):
|
class LlmProviderResponse(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
type: str
|
type: str
|
||||||
|
|
@ -103,16 +107,88 @@ class GeneralConfigUpdate(BaseModel):
|
||||||
# Helper: read/write config file
|
# Helper: read/write config file
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
def _read_yaml_config(config_path: str) -> dict:
|
def _read_yaml_config(config_path: str) -> dict:
|
||||||
"""Read the YAML config file and return the parsed dict."""
|
"""Read the YAML config file and return the parsed dict."""
|
||||||
with open(config_path, encoding="utf-8") as f:
|
with open(config_path, encoding="utf-8") as f:
|
||||||
return yaml.safe_load(f) or {}
|
return yaml.safe_load(f) or {}
|
||||||
|
|
||||||
|
|
||||||
|
def _reverse_resolve_env(data: Any, original: Any) -> Any:
|
||||||
|
"""Reverse-resolve env var references: if original had ${VAR} and current
|
||||||
|
value matches os.environ[VAR], keep the ${VAR} reference instead of
|
||||||
|
writing the plaintext value back to YAML."""
|
||||||
|
if isinstance(original, str) and isinstance(data, str):
|
||||||
|
# Check if original was an env var reference like ${VAR} or ${VAR:-default}
|
||||||
|
env_refs = re.findall(r"\$\{([^}]+)\}", original)
|
||||||
|
for expr in env_refs:
|
||||||
|
var_name = expr.split(":-")[0] if ":-" in expr else expr
|
||||||
|
env_val = os.environ.get(var_name)
|
||||||
|
if env_val is not None and data == env_val:
|
||||||
|
return original # Keep the ${VAR} reference
|
||||||
|
# If original was a ${VAR} reference but value doesn't match env, still keep ref
|
||||||
|
if re.match(r"^\$\{[^}]+\}$", original):
|
||||||
|
return original
|
||||||
|
if isinstance(data, dict) and isinstance(original, dict):
|
||||||
|
result = {}
|
||||||
|
for k in data:
|
||||||
|
result[k] = _reverse_resolve_env(data[k], original.get(k))
|
||||||
|
return result
|
||||||
|
if isinstance(data, list) and isinstance(original, list):
|
||||||
|
# For lists: reverse-resolve matching items, keep new items as-is
|
||||||
|
result = []
|
||||||
|
for i, item in enumerate(data):
|
||||||
|
if i < len(original):
|
||||||
|
result.append(_reverse_resolve_env(item, original[i]))
|
||||||
|
else:
|
||||||
|
result.append(item)
|
||||||
|
return result
|
||||||
|
# For lists that changed length or type mismatches, return data as-is
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _write_yaml_config(config_path: str, data: dict) -> None:
|
def _write_yaml_config(config_path: str, data: dict) -> None:
|
||||||
"""Write the full config dict back to the YAML file."""
|
"""Write the full config dict back to the YAML file.
|
||||||
with open(config_path, "w", encoding="utf-8") as f:
|
|
||||||
yaml.dump(data, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
Preserves ${VAR} env var references: if the original YAML had ${VAR}
|
||||||
|
and the resolved value matches os.environ[VAR], the reference is kept
|
||||||
|
instead of writing the plaintext value.
|
||||||
|
|
||||||
|
Uses ruamel.yaml when available to preserve comments and formatting.
|
||||||
|
Falls back to PyYAML otherwise.
|
||||||
|
"""
|
||||||
|
original = _read_yaml_config(config_path)
|
||||||
|
preserved = _reverse_resolve_env(data, original)
|
||||||
|
try:
|
||||||
|
from ruamel.yaml import YAML
|
||||||
|
|
||||||
|
yaml_writer = YAML()
|
||||||
|
yaml_writer.default_flow_style = False
|
||||||
|
yaml_writer.allow_unicode = True
|
||||||
|
# Re-read with ruamel to get the commented structure
|
||||||
|
with open(config_path, encoding="utf-8") as f:
|
||||||
|
original_data = yaml_writer.load(f)
|
||||||
|
# Apply preserved values onto the ruamel-parsed structure
|
||||||
|
_deep_update_ruamel(original_data, preserved)
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml_writer.dump(original_data, f)
|
||||||
|
except ImportError:
|
||||||
|
with open(config_path, "w", encoding="utf-8") as f:
|
||||||
|
yaml.dump(preserved, f, default_flow_style=False, allow_unicode=True, sort_keys=False)
|
||||||
|
|
||||||
|
|
||||||
|
def _deep_update_ruamel(target: Any, source: Any) -> None:
|
||||||
|
"""Deep-update a ruamel.yaml CommentedMap with values from source dict.
|
||||||
|
|
||||||
|
Preserves comments and formatting in target while updating values.
|
||||||
|
"""
|
||||||
|
if not isinstance(source, dict) or not hasattr(target, "__setitem__"):
|
||||||
|
return
|
||||||
|
for key, value in source.items():
|
||||||
|
if key in target and isinstance(value, dict) and hasattr(target[key], "__setitem__"):
|
||||||
|
_deep_update_ruamel(target[key], value)
|
||||||
|
else:
|
||||||
|
target[key] = value
|
||||||
|
|
||||||
|
|
||||||
def _get_config_path(request: Request) -> str:
|
def _get_config_path(request: Request) -> str:
|
||||||
|
|
@ -123,10 +199,45 @@ def _get_config_path(request: Request) -> str:
|
||||||
return server_config._config_path
|
return server_config._config_path
|
||||||
|
|
||||||
|
|
||||||
|
def _write_env_var(config_path: str, key: str, value: str) -> None:
|
||||||
|
"""Write or update an environment variable in the .env file next to config.
|
||||||
|
|
||||||
|
If the key already exists in .env, its value is updated in place.
|
||||||
|
If not, it's appended. Comments and formatting are preserved.
|
||||||
|
"""
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
env_path = Path(config_path).parent / ".env"
|
||||||
|
lines: list[str] = []
|
||||||
|
found = False
|
||||||
|
|
||||||
|
if env_path.exists():
|
||||||
|
with open(env_path, encoding="utf-8") as f:
|
||||||
|
lines = f.readlines()
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
stripped = line.strip()
|
||||||
|
if stripped.startswith(f"{key}="):
|
||||||
|
lines[i] = f"{key}={value}\n"
|
||||||
|
found = True
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
lines = ["# AgentKit Environment Variables\n", "\n"]
|
||||||
|
|
||||||
|
if not found:
|
||||||
|
lines.append(f"{key}={value}\n")
|
||||||
|
|
||||||
|
with open(env_path, "w", encoding="utf-8") as f:
|
||||||
|
f.writelines(lines)
|
||||||
|
|
||||||
|
# Also set in current process so the next from_yaml resolves correctly
|
||||||
|
os.environ[key] = value
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# LLM Settings
|
# LLM Settings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/llm", response_model=LlmConfigResponse)
|
@router.get("/settings/llm", response_model=LlmConfigResponse)
|
||||||
async def get_llm_settings(request: Request):
|
async def get_llm_settings(request: Request):
|
||||||
"""Return LLM config with masked API keys."""
|
"""Return LLM config with masked API keys."""
|
||||||
|
|
@ -137,15 +248,17 @@ async def get_llm_settings(request: Request):
|
||||||
llm_config = config.llm_config
|
llm_config = config.llm_config
|
||||||
providers = []
|
providers = []
|
||||||
for name, pconf in llm_config.providers.items():
|
for name, pconf in llm_config.providers.items():
|
||||||
providers.append(LlmProviderResponse(
|
providers.append(
|
||||||
name=name,
|
LlmProviderResponse(
|
||||||
type=pconf.type,
|
name=name,
|
||||||
api_key=_mask_api_key(pconf.api_key),
|
type=pconf.type,
|
||||||
base_url=pconf.base_url or "",
|
api_key=_mask_api_key(pconf.api_key),
|
||||||
models=pconf.models,
|
base_url=pconf.base_url or "",
|
||||||
max_tokens=pconf.max_tokens,
|
models=pconf.models,
|
||||||
timeout=pconf.timeout,
|
max_tokens=pconf.max_tokens,
|
||||||
))
|
timeout=pconf.timeout,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return LlmConfigResponse(
|
return LlmConfigResponse(
|
||||||
providers=providers,
|
providers=providers,
|
||||||
|
|
@ -156,7 +269,11 @@ async def get_llm_settings(request: Request):
|
||||||
|
|
||||||
@router.put("/settings/llm", response_model=LlmConfigResponse)
|
@router.put("/settings/llm", response_model=LlmConfigResponse)
|
||||||
async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
||||||
"""Update LLM config and trigger hot reload via config file write."""
|
"""Update LLM config and trigger hot reload via config file write.
|
||||||
|
|
||||||
|
When a new plaintext API key is provided, it is stored in .env (not
|
||||||
|
agentkit.yaml) and the yaml reference is set to ${ENV_KEY}.
|
||||||
|
"""
|
||||||
config_path = _get_config_path(request)
|
config_path = _get_config_path(request)
|
||||||
data = _read_yaml_config(config_path)
|
data = _read_yaml_config(config_path)
|
||||||
|
|
||||||
|
|
@ -174,13 +291,22 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
||||||
"timeout": p.timeout,
|
"timeout": p.timeout,
|
||||||
}
|
}
|
||||||
if p.api_key is not None:
|
if p.api_key is not None:
|
||||||
# If the user sends a masked key (****xxxx), don't overwrite
|
|
||||||
if p.api_key.startswith("****"):
|
if p.api_key.startswith("****"):
|
||||||
# Keep existing key
|
# Masked key — keep existing yaml value (preserves ${VAR} refs)
|
||||||
existing = data.get("llm", {}).get("providers", {}).get(p.name, {})
|
existing = data.get("llm", {}).get("providers", {}).get(p.name, {})
|
||||||
p_dict["api_key"] = existing.get("api_key", "")
|
p_dict["api_key"] = existing.get("api_key", "")
|
||||||
else:
|
else:
|
||||||
p_dict["api_key"] = p.api_key
|
# New plaintext key — write to .env, keep ${VAR} ref in yaml
|
||||||
|
# Extract env_key from existing ${VAR} reference if present
|
||||||
|
existing = data.get("llm", {}).get("providers", {}).get(p.name, {})
|
||||||
|
existing_key = existing.get("api_key", "")
|
||||||
|
env_match = re.match(r"^\$\{([^}]+)\}$", str(existing_key))
|
||||||
|
if env_match:
|
||||||
|
env_key = env_match.group(1).split(":-")[0]
|
||||||
|
else:
|
||||||
|
env_key = f"{p.name.upper().replace('-', '_')}_API_KEY"
|
||||||
|
_write_env_var(config_path, env_key, p.api_key)
|
||||||
|
p_dict["api_key"] = f"${{{env_key}}}"
|
||||||
else:
|
else:
|
||||||
# Keep existing key if not provided
|
# Keep existing key if not provided
|
||||||
existing = data.get("llm", {}).get("providers", {}).get(p.name, {})
|
existing = data.get("llm", {}).get("providers", {}).get(p.name, {})
|
||||||
|
|
@ -205,15 +331,17 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
||||||
llm_config = config.llm_config
|
llm_config = config.llm_config
|
||||||
providers = []
|
providers = []
|
||||||
for name, pconf in llm_config.providers.items():
|
for name, pconf in llm_config.providers.items():
|
||||||
providers.append(LlmProviderResponse(
|
providers.append(
|
||||||
name=name,
|
LlmProviderResponse(
|
||||||
type=pconf.type,
|
name=name,
|
||||||
api_key=_mask_api_key(pconf.api_key),
|
type=pconf.type,
|
||||||
base_url=pconf.base_url or "",
|
api_key=_mask_api_key(pconf.api_key),
|
||||||
models=pconf.models,
|
base_url=pconf.base_url or "",
|
||||||
max_tokens=pconf.max_tokens,
|
models=pconf.models,
|
||||||
timeout=pconf.timeout,
|
max_tokens=pconf.max_tokens,
|
||||||
))
|
timeout=pconf.timeout,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
return LlmConfigResponse(
|
return LlmConfigResponse(
|
||||||
providers=providers,
|
providers=providers,
|
||||||
|
|
@ -226,6 +354,7 @@ async def update_llm_settings(request: Request, update: LlmConfigUpdate):
|
||||||
# Skills Settings
|
# Skills Settings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/skills", response_model=SkillsConfigResponse)
|
@router.get("/settings/skills", response_model=SkillsConfigResponse)
|
||||||
async def get_skills_settings(request: Request):
|
async def get_skills_settings(request: Request):
|
||||||
"""Return skill paths config."""
|
"""Return skill paths config."""
|
||||||
|
|
@ -266,6 +395,7 @@ async def update_skills_settings(request: Request, update: SkillsConfigUpdate):
|
||||||
# Knowledge Base Settings
|
# Knowledge Base Settings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/kb", response_model=KbConfigResponse)
|
@router.get("/settings/kb", response_model=KbConfigResponse)
|
||||||
async def get_kb_settings(request: Request):
|
async def get_kb_settings(request: Request):
|
||||||
"""Return knowledge base connection config."""
|
"""Return knowledge base connection config."""
|
||||||
|
|
@ -294,6 +424,7 @@ async def update_kb_settings(request: Request, update: KbConfigUpdate):
|
||||||
# General Settings
|
# General Settings
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
@router.get("/settings/general", response_model=GeneralConfigResponse)
|
@router.get("/settings/general", response_model=GeneralConfigResponse)
|
||||||
async def get_general_settings(request: Request):
|
async def get_general_settings(request: Request):
|
||||||
"""Return general settings (log level, server port, etc.)."""
|
"""Return general settings (log level, server port, etc.)."""
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,9 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
import unittest.mock
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
import yaml
|
import yaml
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
|
|
@ -355,7 +358,11 @@ class TestNoConfigPath:
|
||||||
"""When server_config has no _config_path, PUT should return 400."""
|
"""When server_config has no _config_path, PUT should return 400."""
|
||||||
from agentkit.llm.gateway import LLMGateway
|
from agentkit.llm.gateway import LLMGateway
|
||||||
|
|
||||||
app = create_app(llm_gateway=LLMGateway())
|
# Mock os.environ to prevent auto-discovery of agentkit.yaml in CWD
|
||||||
|
with unittest.mock.patch.dict(os.environ, {}, clear=True):
|
||||||
|
# Also ensure no agentkit.yaml is found in CWD
|
||||||
|
with unittest.mock.patch("pathlib.Path.exists", return_value=False):
|
||||||
|
app = create_app(llm_gateway=LLMGateway())
|
||||||
# server_config is None in this case
|
# server_config is None in this case
|
||||||
client = TestClient(app)
|
client = TestClient(app)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,9 @@ class TestServeCommand:
|
||||||
def test_serve_starts_uvicorn(self):
|
def test_serve_starts_uvicorn(self):
|
||||||
"""agentkit serve calls uvicorn.run with correct params"""
|
"""agentkit serve calls uvicorn.run with correct params"""
|
||||||
from agentkit.cli.main import app
|
from agentkit.cli.main import app
|
||||||
with patch("uvicorn.run") as mock_run:
|
with patch("uvicorn.run") as mock_run, \
|
||||||
|
patch("agentkit.server.config.find_config_path", return_value=None), \
|
||||||
|
patch("rich.prompt.Confirm.ask", return_value=False):
|
||||||
result = runner.invoke(app, ["serve", "--host", "0.0.0.0", "--port", "8001"])
|
result = runner.invoke(app, ["serve", "--host", "0.0.0.0", "--port", "8001"])
|
||||||
mock_run.assert_called_once()
|
mock_run.assert_called_once()
|
||||||
call_kwargs = mock_run.call_args
|
call_kwargs = mock_run.call_args
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue