fischer-agentkit/src/agentkit/cli/chat.py

423 lines
17 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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