claude code 或 codex 等 Agent 的 CLI 介面中,有許多的指令功能,例如 /exit 可以退出程式,/help 可以查看可用指令說明等等。
今天,我們就來讓我們的 agent 擁有「更改模型」以及「退出程式」這兩個指令吧!
和工具調用一樣,我們定義一個屬性裝飾器來註冊什麼指令要對應到什麼函數,方便我們日後在 handle_cmd 以及 get_input() 中的 WordCompleter()(輸入補全)做處理。
輸入補全是什麼?
就像下圖那樣,我們只要事先輸入串列,就可以方便地做選擇:
舉個例子,當我輸入/時就會列出所有的指令(因為每個指令都是/開頭的),可以用上下及 tab 來做選擇,同樣道理,如果再多打一個字/m時,就會跑出/model的選項。
# src/meowgent/cli/commands.py
from agent import Agent
from cli import CLIRenderer
import questionary
from questionary import Style
from rich.padding import Padding
import ollama
from typing import Callable
import sys
import inspect
from rich.panel import Panel
COMMAND_REGISTRY = {} # "指令": 對應函數
def cmd_registry(cmd_name: str): # 屬性裝飾器標註函數對應的指令
def decorator(func: Callable):
COMMAND_REGISTRY[cmd_name] = func
return func
return decorator
這邊指令的字典準備好了,要回到 input_prompt.py 給接上:
首先,把字典中的鍵給取出放入串列,接著用 WordCompleter() 建立補全器物件,最後傳入 prompt()。
ignore_case:是否忽略大小寫。WORD:邊界設定,設為True時當指令為/cd ~/Downloads(指令後有其他內容)時不會出錯。
# src/meowgent/cli/input_prompt.py
from ...
from prompt_toolkit.completion import WordCompleter
from cli import COMMAND_REGISTRY
COMMANDS = [k for k in COMMAND_REGISTRY]
def get_input() -> ...:
...
_prompt_session ...
completer = WordCompleter(words=COMMANDS, ignore_case=True, WORD=True)
# 建立補全器
return _prompt_session.prompt(..., completer=completer)
要記得,我們要在函數前加上剛才定義的 cmd_registry(),且要加上註解(後面實作的 /help 會透過讀取註解來說明指令功能)。
這裡我說的註解不是
# ...,而是:def ...(...): """ 這個註解 """ ...也就是函數的 Docstring(文檔字串)。
而因為判斷指令的 handle_cmd() 會從 main.py 傳入與此函數不相關的參數,所以我們用 **kwargs 接住。
# src/meowgent/cli/commands.py
@cmd_registry("/model")
def _change_model(model_object: Agent, cli: CLIRenderer, **kwargs):
""" 選擇模型 """
這裡採用 ollama.list() 列出設備上所有的可用模型,回傳的 ListResponse 物件長這樣:
{
. "models": [
. {
"model": ... -> 模型名
..
},
. {
"model": ... -> 模型名
..
}, ...
]
}
所以,我們先取出 "models" 這個鍵,接著用串列生成式遍歷取出 "model",就能得到所有可用模型了。
除了選擇模型,也加上 "取消" 的選項:
# src/meowgent/cli/commands.py
@cmd_registry(...)
def _change_model(...)
...
try:
new_model = questionary.select(
"選擇模型",
choices=[m.model for m in ollama.list()["models"]] + ["取消"],
style=Style([
('question', 'dim'),
('instruction', 'dim')
])
).ask() # 選擇模型
捕捉沒有取得任何可用模型的例外,以及選擇 "取消" 的情況:
# src/meowgent/cli/commands.py
@cmd_registry(...)
def _change_model(...)
...
except Exception: # ollama.list() 失敗時
cli.console.print(Padding(
"[yellow]未偵測到本機已安裝的 Ollama 模型,或 Ollama 尚未啟動[/yellow]",
(0, 0, 0, 2))
)
return None
if new_model == "取消":
return None # 無作為
最後,選擇完後,還需要更改 Agent 物件以及 system prompt,然後加上一條更換成功的提示:
# src/meowgent/cli/commands.py
@cmd_registry(...)
def _change_model(...)
...
model_object.provider.model_name = new_model
model_object.renew_system_prompt(model_name=new_model)
cli.console.print(Padding(
f"[green]模型已成功切換為:{new_model}[/green]",
(0, 0, 0, 2))
)
一直以來,我們都只能以 ctrl + c 的方式來結束運作,加上這個簡單的函數終於可以有個正經的退出方式啦!
這裡直接調用之前寫過的
render_end()
# src/meowgent/cli/commands.py
@cmd_registry("/exit")
def _exit(cli: CLIRenderer, **kwargs):
""" 關閉 Meowgent """
cli.console.print(cli.render_end())
sys.exit(0) # 退出程序
輸入斜線後,補全器列出了一堆指令,但使用者可能不太清楚這些指令的功能是什麼,
所以,我們寫一個指令來讀取並印出在每個指令函數寫的註解吧!
用迴圈取出每一個註冊的指令,inspect.getdoc() 獲取函數的註解,
接著先存入到 lines 裡頭,這邊字串內的 : < 15 表示 cmd_name 要佔據 15 格,也就是會在後方補上空白。
# src/meowgent/cli/commands.py
@cmd_registry("/help")
def _show_commands(cli: CLIRenderer, **kwargs):
""" 顯示各指令以及詳細功能 """
lines = []
for cmd_name, cmd_func in COMMAND_REGISTRY.items():
doc = inspect.getdoc(cmd_func).strip()
lines.append(f"[blue]{cmd_name: <15}[/blue][dim]{doc}[/dim]")
還記得嗎?之前曾經有說過
inspect.getdoc()會把註解後面的那格空格給收錄,所以用strip()清掉。
迴圈把 lines 收集好後,利用 .join() 分行,最後利用 Panel.fit() 用一個框框給包起來顯示,最後再輸出一條分隔線。
border_style是標題(title參數)以及邊框的顏色。
Panel.fit()其實就是Panel()裡預設不填滿視窗(expand=False),只包覆到最長一行(會加上 padding 設定的)。
看到padding參數,左邊的縮排,我們只給 1 格,依然對齊我們的格式,這是因為框線剛好佔據了一格。
而其實如果inspect.getdoc()不做strip()然後padding=(0, 0, 0, 1)也是同樣效果(一樣是空了一格),但習慣上還是會strip()把結尾空格清除。
# src/meowgent/cli/commands.py
@cmd_registry(...)
def _show_commands(...):
...
cli.console.print(Panel.fit(
"\n".join(lines),
title="可用指令",
border_style="dim",
padding=(0, 1, 0, 1)
))
cli.console.print(cli.get_rule())
可以看到效果如下:
有了指令對應動作的函數,我們還需要有一個管理指令的邏輯,來在 main.py 獲取輸入時判斷「是否為指令」、「這個指令對應到哪個函數」,也就是 handle_cmd()。
這邊先來處理 commands.py 裡的「這個指令對應到哪個函數」。
把輸入的空格前切出(空格後放入 args),判斷是否有此指令,找到了則執行對應函數。
例如可能會有
/cd ~/Download這樣的指令,.split()[0]把/cd切出來做判斷,.split()[1:]則把~/Download當傳入的args。
# src/meowgent/cli/commands.py
def handle_cmd(input: str, model_object: Agent, cli: CLIRenderer):
func = COMMAND_REGISTRY.get(input.split()[0])
# 把空格以前切出來,也就是只切出指令部分,拿取函數物件,或回傳 None
if not func: # 找不到
cli.console.print(Padding("[red]查無此指令[/red]", (0, 0, 0, 2)))
else: # 找到了
func(
args = input.split()[1:], # 把除了指令的內容傳入
model_object=model_object,
cli=cli
) # 將後面的參數傳入
接著,終於可以接入到主程序中了,要判斷輸入為斜線開頭(判斷是否為指令),是則進入 handle_cmd() 處理,處理完後直接跳過此輪 while True:。
# src/meowgent/cli/main.py
from cli import ..., handle_cmd
...
if __name__ == "__main__":
...
try:
while True:
user_input = ...
if not user_input:
...
if user_input.startswith("/"):
handle_cmd(input=user_input, model_object=model, cli=cli)
continue # 跳過此次對話
response_streamer = ...
如此一來,就擁有切換模型以及用指令退出的能力啦!可以去試試看 /model、/exit、/help 有沒有起到作用?
下一篇,我們再繼續做一個特別的指令功能 - /cd 切換工作目錄!