423 lines
17 KiB
Python
423 lines
17 KiB
Python
"""Chat command — interactive terminal chat with an Agent.
|
||
|
||
Runs a lightweight in-process server and opens a REPL-style chat session.
|
||
No external server or Docker needed.
|
||
|
||
Usage:
|
||
agentkit chat # Start chatting (auto-onboard if no config)
|
||
agentkit chat --model deepseek/deepseek-chat # Use specific model
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import os
|
||
from typing import Any
|
||
|
||
import typer
|
||
from rich import print as rprint
|
||
from rich.panel import Panel
|
||
from rich.prompt import Prompt
|
||
from rich.markdown import Markdown
|
||
from rich.live import Live
|
||
from rich.text import Text
|
||
from rich.console import Group
|
||
|
||
|
||
def chat(
|
||
model: str = typer.Option("default", "--model", "-m", help="LLM model to use (e.g. deepseek/deepseek-chat)"),
|
||
agent_name: str = typer.Option("default", "--agent", "-a", help="Agent name to chat with"),
|
||
config: str | None = typer.Option(None, "--config", "-c", help="Path to agentkit.yaml"),
|
||
system_prompt: str | None = typer.Option(None, "--system-prompt", "-s", help="Custom system prompt"),
|
||
no_stream: bool = typer.Option(False, "--no-stream", help="Disable token streaming"),
|
||
):
|
||
"""Start an interactive chat session with an Agent."""
|
||
asyncio.run(_chat_async(model, agent_name, config, system_prompt, no_stream))
|
||
|
||
|
||
async def _chat_async(
|
||
model: str,
|
||
agent_name: str,
|
||
config_arg: str | None,
|
||
system_prompt: str | None,
|
||
no_stream: bool,
|
||
) -> None:
|
||
"""Async implementation of the chat command."""
|
||
from agentkit.cli.onboarding import run_onboarding
|
||
from agentkit.server.config import ServerConfig, find_config_path
|
||
|
||
# ── Onboarding check ──────────────────────────────────────────
|
||
config_path = find_config_path(config_arg)
|
||
if config_path is None:
|
||
config_path = run_onboarding(config_arg=config_arg)
|
||
if config_path is None:
|
||
rprint("[red]Onboarding cancelled. Cannot start chat without configuration.[/red]")
|
||
raise typer.Exit(code=1)
|
||
|
||
# ── Load config ───────────────────────────────────────────────
|
||
rprint(f"[dim]Loading config from {config_path}[/dim]")
|
||
|
||
# Load .env
|
||
from pathlib import Path
|
||
dotenv = Path(config_path).parent / ".env"
|
||
if dotenv.exists():
|
||
_load_dotenv(str(dotenv))
|
||
|
||
server_config = ServerConfig.from_yaml(config_path)
|
||
|
||
# ── Build in-process components ───────────────────────────────
|
||
from agentkit.session.manager import SessionManager
|
||
from agentkit.session.store import InMemorySessionStore
|
||
from agentkit.session.models import MessageRole
|
||
from agentkit.core.react import ReActEngine
|
||
from agentkit.tools.base import Tool
|
||
from agentkit.memory.profile import MemoryStore
|
||
from agentkit.tools.memory_tool import MemoryTool
|
||
from agentkit.tools.shell import ShellTool
|
||
from agentkit.tools.web_search import WebSearchTool
|
||
from agentkit.tools.web_crawl import WebCrawlTool
|
||
|
||
# Build LLM Gateway
|
||
gateway = _build_gateway(server_config)
|
||
|
||
# Initialize memory store
|
||
memory_store = MemoryStore()
|
||
memory_store.ensure_defaults()
|
||
memory_snapshot = memory_store.load_all()
|
||
|
||
# Create session
|
||
session_manager = SessionManager(store=InMemorySessionStore())
|
||
session = await session_manager.create_session(agent_name=agent_name)
|
||
|
||
# Build tools list — all available tools for chat mode
|
||
search_api_keys = _extract_search_keys(server_config)
|
||
tools: list[Tool] = [
|
||
MemoryTool(memory_store=memory_store),
|
||
ShellTool(working_dir=os.getcwd()),
|
||
WebSearchTool(**search_api_keys),
|
||
WebCrawlTool(),
|
||
]
|
||
|
||
# ── Load skills and build IntentRouter ───────────────────────
|
||
from agentkit.tools.registry import ToolRegistry
|
||
from agentkit.skills.registry import SkillRegistry
|
||
from agentkit.skills.loader import SkillLoader
|
||
from agentkit.router.intent import IntentRouter
|
||
|
||
tool_registry = ToolRegistry()
|
||
for tool in tools:
|
||
tool_registry.register(tool)
|
||
|
||
skill_registry = SkillRegistry()
|
||
if server_config.skill_paths:
|
||
loader = SkillLoader(skill_registry=skill_registry, tool_registry=tool_registry)
|
||
for skill_path in server_config.skill_paths:
|
||
from pathlib import Path as _P
|
||
p = _P(skill_path)
|
||
if p.is_dir():
|
||
loaded = loader.load_from_directory(str(p))
|
||
if loaded:
|
||
rprint(f"[dim]Loaded {len(loaded)} skills from {p}[/dim]")
|
||
elif p.is_file() and p.suffix in (".yaml", ".yml"):
|
||
try:
|
||
loader.load_from_file(str(p))
|
||
except Exception:
|
||
pass
|
||
|
||
intent_router = IntentRouter(llm_gateway=gateway) if skill_registry.list_skills() else None
|
||
|
||
# Build system prompt — inject memory into system prompt
|
||
base_prompt = system_prompt or (
|
||
"你是一个有帮助的AI助手。请记住我们对话的上下文,并在后续对话中引用之前的内容。回答要清晰简洁,请使用中文回复。"
|
||
)
|
||
effective_system_prompt = memory_store.build_system_prompt(memory_snapshot, base_prompt)
|
||
|
||
# Resolve agent display name from SOUL.md
|
||
agent_display_name = memory_store.get_file("soul").read_section("身份") or agent_name
|
||
# Extract just the name (first line after "我是")
|
||
for prefix in ["我是", "我叫", "我的名字是"]:
|
||
if prefix in agent_display_name:
|
||
name_part = agent_display_name.split(prefix, 1)[1].strip()
|
||
# Take first meaningful token (before comma, period, etc.)
|
||
for sep in [",", "。", "、", ",", ".", " "]:
|
||
if sep in name_part:
|
||
name_part = name_part.split(sep)[0]
|
||
break
|
||
agent_display_name = name_part
|
||
break
|
||
|
||
# ── Welcome banner ────────────────────────────────────────────
|
||
effective_model = model if model != "default" else _resolve_default_model(server_config)
|
||
rprint(Panel(
|
||
f"[bold]AgentKit Chat[/bold]\n\n"
|
||
f" Model: [cyan]{effective_model}[/cyan]\n"
|
||
f" Agent: [cyan]{agent_display_name}[/cyan]\n"
|
||
f" Session: [dim]{session.session_id[:8]}...[/dim]\n\n"
|
||
f" Type your message and press Enter.\n"
|
||
f" [dim]/help[/dim] — Show commands\n"
|
||
f" [dim]/clear[/dim] — Clear conversation\n"
|
||
f" [dim]/model <name>[/dim] — Switch model\n"
|
||
f" [dim]/quit[/dim] — Exit chat",
|
||
title="AgentKit",
|
||
border_style="bright_blue",
|
||
))
|
||
|
||
# ── Chat loop ─────────────────────────────────────────────────
|
||
react_engine = ReActEngine(llm_gateway=gateway)
|
||
current_model = effective_model
|
||
conversation_had_messages = False
|
||
|
||
while True:
|
||
try:
|
||
user_input = Prompt.ask("\n[bold green]You[/bold green]")
|
||
except (EOFError, KeyboardInterrupt):
|
||
rprint("\n[dim]Goodbye![/dim]")
|
||
break
|
||
|
||
if not user_input.strip():
|
||
continue
|
||
|
||
# Handle commands
|
||
if user_input.startswith("/"):
|
||
cmd = user_input.strip().lower()
|
||
if cmd in ("/quit", "/q", "/exit"):
|
||
rprint("[dim]Goodbye![/dim]")
|
||
break
|
||
elif cmd == "/help":
|
||
_print_help()
|
||
continue
|
||
elif cmd == "/clear":
|
||
# Create a new session (memory files persist)
|
||
session = await session_manager.create_session(agent_name=agent_name)
|
||
rprint("[dim]Conversation cleared. New session started.[/dim]")
|
||
continue
|
||
elif cmd.startswith("/model "):
|
||
current_model = cmd.split(" ", 1)[1].strip()
|
||
rprint(f"[dim]Switched to model: {current_model}[/dim]")
|
||
continue
|
||
else:
|
||
rprint(f"[yellow]Unknown command: {cmd}[/yellow]")
|
||
continue
|
||
|
||
conversation_had_messages = True
|
||
|
||
# Append user message to session
|
||
await session_manager.append_message(
|
||
session_id=session.session_id,
|
||
role=MessageRole.USER,
|
||
content=user_input,
|
||
)
|
||
|
||
# Get full conversation history (includes all previous turns)
|
||
chat_messages = await session_manager.get_chat_messages(session.session_id)
|
||
|
||
# ── Skill routing ─────────────────────────────────────────
|
||
from agentkit.chat.skill_routing import resolve_skill_routing
|
||
|
||
routing = await resolve_skill_routing(
|
||
content=user_input,
|
||
skill_registry=skill_registry,
|
||
intent_router=intent_router,
|
||
default_tools=tools,
|
||
default_system_prompt=effective_system_prompt,
|
||
default_model=current_model,
|
||
default_agent_name=agent_name,
|
||
session_id=session.session_id,
|
||
)
|
||
|
||
if routing.matched:
|
||
rprint(f"[dim]Skill: {routing.skill_name} ({routing.match_method}, {int(routing.match_confidence * 100)}%)[/dim]")
|
||
|
||
exec_system_prompt = routing.system_prompt
|
||
exec_tools = routing.tools
|
||
exec_model = routing.model
|
||
|
||
# Print Agent label before streaming
|
||
rprint(f"\n[bold blue]{agent_display_name}[/bold blue]: ", end="")
|
||
|
||
# Execute Agent
|
||
try:
|
||
if no_stream:
|
||
# Non-streaming mode
|
||
result = await react_engine.execute(
|
||
messages=chat_messages,
|
||
tools=exec_tools,
|
||
model=exec_model,
|
||
agent_name=routing.skill_name or agent_name,
|
||
system_prompt=exec_system_prompt,
|
||
)
|
||
output = result.output if hasattr(result, "output") else str(result)
|
||
rprint(output)
|
||
|
||
await session_manager.append_message(
|
||
session_id=session.session_id,
|
||
role=MessageRole.ASSISTANT,
|
||
content=output,
|
||
agent_name=agent_name,
|
||
)
|
||
else:
|
||
# Streaming mode — Live displays under the "Agent:" label
|
||
full_content = ""
|
||
with Live(
|
||
Text(""),
|
||
refresh_per_second=15,
|
||
vertical_overflow="visible",
|
||
transient=False, # Keep final output on screen
|
||
) as live:
|
||
async for event in react_engine.execute_stream(
|
||
messages=chat_messages,
|
||
tools=exec_tools,
|
||
model=exec_model,
|
||
agent_name=routing.skill_name or agent_name,
|
||
system_prompt=exec_system_prompt,
|
||
):
|
||
if event.event_type == "token":
|
||
token = event.data.get("content", "")
|
||
full_content += token
|
||
live.update(Text(full_content))
|
||
elif event.event_type == "final_answer":
|
||
# Use final_answer output (may differ slightly from accumulated tokens)
|
||
full_content = event.data.get("output", full_content)
|
||
live.update(Markdown(full_content))
|
||
elif event.event_type == "tool_call":
|
||
tool_name = event.data.get("tool_name", "unknown")
|
||
live.update(Text(f"[calling tool: {tool_name}...]"))
|
||
elif event.event_type == "tool_result":
|
||
# After tool result, show accumulated content again
|
||
if full_content:
|
||
live.update(Text(full_content))
|
||
|
||
# Live already displayed the final content, no need to rprint again
|
||
|
||
await session_manager.append_message(
|
||
session_id=session.session_id,
|
||
role=MessageRole.ASSISTANT,
|
||
content=full_content,
|
||
agent_name=agent_name,
|
||
)
|
||
|
||
except Exception as e:
|
||
rprint(f"\n[red]Error: {e}[/red]")
|
||
|
||
# ── Session end: generate daily log ────────────────────────────
|
||
if conversation_had_messages:
|
||
try:
|
||
messages = await session_manager.get_messages(session.session_id)
|
||
if messages:
|
||
# Build a brief summary of the conversation
|
||
summary_parts = []
|
||
for msg in messages[-10:]: # Last 10 messages
|
||
role = msg.role.value if hasattr(msg.role, "value") else str(msg.role)
|
||
summary_parts.append(f"{role}: {msg.content[:100]}")
|
||
summary = "\n".join(summary_parts)
|
||
|
||
daily = memory_store.get_file("daily")
|
||
existing = daily.read()
|
||
new_entry = f"## 会话摘要\n{summary}"
|
||
if existing:
|
||
daily.write(f"{existing}\n\n{new_entry}")
|
||
else:
|
||
daily.write(new_entry)
|
||
|
||
# Archive old daily logs
|
||
memory_store.archive_old_dailies(keep_days=2)
|
||
except Exception:
|
||
pass # Daily log generation is best-effort
|
||
|
||
|
||
def _extract_search_keys(server_config: "ServerConfig") -> dict[str, str]:
|
||
"""Extract search API keys from server config environment."""
|
||
return {
|
||
"tavily_api_key": os.environ.get("TAVILY_API_KEY"),
|
||
"serper_api_key": os.environ.get("SERPER_API_KEY"),
|
||
}
|
||
|
||
|
||
def _build_gateway(server_config: "ServerConfig") -> "LLMGateway":
|
||
"""Build LLMGateway from ServerConfig, same logic as app.py."""
|
||
from agentkit.llm.gateway import LLMGateway
|
||
from agentkit.llm.providers.anthropic import AnthropicProvider
|
||
from agentkit.llm.providers.gemini import GeminiProvider
|
||
from agentkit.llm.providers.openai import OpenAICompatibleProvider
|
||
|
||
gateway = LLMGateway(config=server_config.llm_config)
|
||
|
||
for name, pconf in server_config.llm_config.providers.items():
|
||
if not pconf.api_key:
|
||
continue
|
||
try:
|
||
if pconf.type == "anthropic":
|
||
provider = AnthropicProvider(
|
||
api_key=pconf.api_key,
|
||
model=list(pconf.models.keys())[0] if pconf.models else "claude-sonnet-4-20250514",
|
||
max_tokens=pconf.max_tokens,
|
||
base_url=pconf.base_url or "https://api.anthropic.com",
|
||
timeout=pconf.timeout,
|
||
)
|
||
elif pconf.type == "gemini":
|
||
provider = GeminiProvider(
|
||
api_key=pconf.api_key,
|
||
model=list(pconf.models.keys())[0] if pconf.models else "gemini-2.0-flash",
|
||
max_output_tokens=pconf.max_tokens,
|
||
base_url=pconf.base_url or "https://generativelanguage.googleapis.com",
|
||
timeout=pconf.timeout,
|
||
)
|
||
else:
|
||
provider = OpenAICompatibleProvider(
|
||
api_key=pconf.api_key,
|
||
base_url=pconf.base_url,
|
||
)
|
||
gateway.register_provider(name, provider)
|
||
except Exception as e:
|
||
import logging
|
||
logging.getLogger(__name__).warning(f"Failed to register LLM provider '{name}': {e}")
|
||
|
||
return gateway
|
||
|
||
|
||
def _resolve_default_model(server_config: "ServerConfig") -> str:
|
||
"""Resolve the default model from config."""
|
||
if server_config.llm_config.model_aliases and "default" in server_config.llm_config.model_aliases:
|
||
return server_config.llm_config.model_aliases["default"]
|
||
# Fallback: first provider's first model
|
||
for name, pconf in server_config.llm_config.providers.items():
|
||
if pconf.api_key and pconf.models:
|
||
first_model = list(pconf.models.keys())[0]
|
||
return f"{name}/{first_model}"
|
||
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(Panel(
|
||
"[bold]Chat Commands[/bold]\n\n"
|
||
" [cyan]/help[/cyan] — Show this help\n"
|
||
" [cyan]/clear[/cyan] — Clear conversation (new session)\n"
|
||
" [cyan]/model <name>[/cyan] — Switch LLM model\n"
|
||
" [cyan]/quit[/cyan] — Exit chat\n\n"
|
||
"[bold]Tips[/bold]\n\n"
|
||
" • Multi-line input: end a line with [cyan]\\[/cyan] to continue\n"
|
||
" • Your conversation is stored in memory for the session",
|
||
border_style="dim",
|
||
))
|