"""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 [/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 [/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", ))