从 0 构建一个 Gemini Cli - Agent 1.0

An AI agent is an LLM wrecking its environment in a loop. — Solomon Hykes

0x10 Agent

AI 代理是一个循环破坏其环境的 LLM —— 这是 Simon Willison 在九月的博文 Designing agentic loops 中引用的 Solomon Hykes 的发言,而 Simon 总结的 Agent 定义为“LLM 代理循环运行工具以实现目标。”

上一篇中我们已经实现了一个能在终端中和 LLM 交互的 TUI 窗口,下一步的目标就是使其具备 Agent 的形态。

工具的话参考 Gemini Cli 的工具描述,主要包含文件系统读写、shell、网络搜索等工具集。

把目标丢给 Gemini 后得到一个基本的实现路径:

  1. 定义工具:明确 Agent 可以使用的工具(如执行 shell 命令、读写文件)。
  2. 改造 LLM 客户端:这里需要我们的 LLM 支持 Function Calling,将工具定义和执行结果包在 LLM 调用的循环中以实现目标。
  3. 更新 TUI 界面:引入 Agent 状态和用户确认的交互来优化体验。

0x11 实现读取目录的简单工具调用

首先尝试实现一个简单安全的系统操作功能 list_directory,需要建立一个通用的工具接口 Tool

1
2
3
4
5
6
7
8
9
10
11
12
// Tool represents a function that can be called by the agent.
type Tool interface {
// Name is the name of the tool, as it would be called by the model.
Name() string
// Description is a description of the tool's purpose, inputs, and outputs.
Description() string
// Parameters returns the JSON schema for the tool's arguments.
Parameters() any
// Execute runs the tool with the given arguments and returns the output.
// The args are expected to be a JSON string.
Execute(args string) (string, error)
}

接下来给出 list_directory 的工具定义:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
type ListDirectoryTool struct{}

func (t *ListDirectoryTool) Name() string {
return "list_directory"
}

func (t *ListDirectoryTool) Description() string {
return "Lists files and subdirectories within a specified directory path. Usage: {\"path\": \"<directory_path>\"}"
}

func (t *ListDirectoryTool) Parameters() any {
return map[string]any{
"type": "object",
"properties": map[string]any{
"path": map[string]any{
"type": "string",
"description": "The path to the directory to list.",
},
},
"required": []string{"path"},
}
}

type ListDirectoryArgs struct {
Path string `json:"path"`
}

func (t *ListDirectoryTool) Execute(args string) (string, error) {
var toolArgs ListDirectoryArgs
// ...解析 json 参数并获取目录信息
return output.String(), nil
}

然后将工具注册到 client 中,让客户端在与 LLM 通信时,能够将这个新工具的信息(名称、描述)传递给 LLM,并准备好在接收到调用指令时执行它。

定义 toolRegistry 来存储可用工具集:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// NewClient creates a new LLM client.
func NewClient(apiURL, apiKey string) *Client {
// Initialize the tool registry and register tools.
toolRegistry := make(map[string]tools.Tool)
listDirTool := &tools.ListDirectoryTool{}
toolRegistry[listDirTool.Name()] = listDirTool

return &Client{
apiURL: apiURL,
apiKey: apiKey,
http: &http.Client{},
toolRegistry: toolRegistry,
}
}

根据 OpenAI 相关规范引入工具调用的相关请求构造体:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// Message is a single message in a chat completion request.
type Message struct {
Role string `json:"role"`
Content string `json:"content,omitempty"`
ToolCalls []ToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
}

// ToolCall represents a complete tool call.
type ToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}

// ToolCallDelta represents a chunk of a tool call from the stream.
type ToolCallDelta struct {
Index int `json:"index"`
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Function struct {
Name string `json:"name,omitempty"`
Arguments string `json:"arguments,omitempty"`
} `json:"function,omitempty"`
}

// Tool represents the definition of a tool that can be called.
type Tool struct {
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters any `json:"parameters"`
} `json:"function"`
}

修改流式调用的核心方法 CompletionStream,在流式输出中聚合工具调用命令:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// Aggregate tool calls
if len(choice.Delta.ToolCalls) > 0 {
for _, toolCallDelta := range choice.Delta.ToolCalls {
if len(toolCalls) <= toolCallDelta.Index {
// Expand the slice if a new tool call index appears
toolCalls = append(toolCalls, make([]ToolCall, toolCallDelta.Index-len(toolCalls)+1)...)
}
call := &toolCalls[toolCallDelta.Index]
if toolCallDelta.ID != "" {
call.ID = toolCallDelta.ID
}
if toolCallDelta.Type != "" {
call.Type = toolCallDelta.Type
}
call.Function.Name += toolCallDelta.Function.Name
call.Function.Arguments += toolCallDelta.Function.Arguments
}
}

其中 toolCallDelta 是循环数据流中的工具调用数据碎片,拼接完整后即可得到 toolCalls 这样一个完成的工具调用之类,包含工具名和参数等信息。

拼装完成后,当收到 tool_calls 这个 finishReason 之后,意味着我们到了真正需要执行工具调用的时候。这时遍历之前拼装完成的工具命令,在工具集中找到对应的方法执行,再将执行的结果分配到 “tool” 这个角色的消息中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// After stream, check for tool calls
if finishReason == "tool_calls" {
log.Println("Tool call requested. Executing tools...")

// Append the assistant's message with tool call requests to history
assistantMessage := Message{
Role: "assistant",
ToolCalls: toolCalls,
}
newMessages := append(messages, assistantMessage)

// Execute tools and append results
for _, toolCall := range toolCalls {
log.Printf("Executing: %s(%s)", toolCall.Function.Name, toolCall.Function.Arguments)
tool, ok := c.toolRegistry[toolCall.Function.Name]
if !ok {
log.Printf("Error: tool '%s' not found", toolCall.Function.Name)
continue
}

result, err := tool.Execute(toolCall.Function.Arguments)
if err != nil {
log.Printf("Error executing tool '%s': %v", toolCall.Function.Name, err)
result = fmt.Sprintf("Error: %v", err)
}

// Append tool result to messages
newMessages = append(newMessages, Message{
Role: "tool",
ToolCallID: toolCall.ID,
Content: result,
})
}

// Recurse with the new messages to get the final response
c.runCompletionStream(newMessages, model, ch)
return
}

上面还将之前 CompletionStream 方法中单独拆出 runCompletionStream 来实现递归调用,这里体现了 Agent 本质就是循环重复自己来完成"思考-行动-观察"的闭环,直到目的达成(or not)退出为止。

调试成功后效果如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
D:\Projects\my-project\GhOst git:[main]
go run .
You:
列出当前目录下的文件

当前目录下的文件和子目录包括:

• .ghost.yaml
• .ghost.yaml.example
• .git/ (目录)
• .gitignore
• .idea/ (目录)
• GEMINI.md
• README.md
• cmd/ (目录)
• ghost.exe
• ghost.exe~
• go.mod
• go.sum
• internal/ (目录)
• main.go
• vendor/ (目录)

0x12 读写文件与用户确认交互

对于 read_file 操作来说实现比较简单,但是 write_file 操作的话需要一个用户确认的交互,改到这里的确认交互就会暴露之前 client 和 tui 以及工具注册方法耦合较深问题,遂尝试尽早将部分 agent 的控制逻辑独立出来。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// Agent is the core logic unit of the application. It is UI-independent.
type Agent struct {
client *Client
modelName string
toolRegistry map[string]tools.Tool

// State
messages []Message
pendingToolCalls []ToolCall
confirmingToolCall ToolCall
isConfirming bool

// Live state for streaming
lastStreamedContent string
}

// NewAgent creates a new agent.
func NewAgent(client *Client, modelName string) *Agent {
toolRegistry := make(map[string]tools.Tool)
// ...工具注册

return &Agent{
client: client,
modelName: modelName,
toolRegistry: toolRegistry,
messages: []Message{},
}
}

工具调用的控制逻辑如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
func (a *Agent) processToolCalls() tea.Cmd {
if len(a.pendingToolCalls) == 0 {
return a.client.CompletionStream(a.messages, a.modelName)
}

toolCall := a.pendingToolCalls[0]
tool, ok := a.toolRegistry[toolCall.Function.Name]
if !ok {
return func() tea.Msg {
return ErrorMsg{Err: fmt.Errorf("tool %s not found in registry", toolCall.Function.Name)}
}
}

if tool.RequiresConfirmation() {
a.confirmingToolCall = toolCall
a.isConfirming = true
return nil
}

a.pendingToolCalls = a.pendingToolCalls[1:]
return a.executeTool(toolCall)
}

func (a *Agent) executeTool(toolCall ToolCall) tea.Cmd {
return func() tea.Msg {
tool, _ := a.toolRegistry[toolCall.Function.Name]
result, err := tool.Execute(toolCall.Function.Arguments)
if err != nil {
result = fmt.Sprintf("Error executing tool %s: %v", toolCall.Function.Name, err)
}

return ToolResultMsg{
ToolCallID: toolCall.ID,
Result: result,
}
}
}

tui 中更新 model,将多轮对话消息放入 agent 中维护:

1
2
3
4
5
6
7
8
9
10
11
// model is the state of our TUI application.
type model struct {
viewport viewport.Model
textarea textarea.Model
agent *llm.Agent // The new core logic handler
sub chan tea.Msg // Channel for receiving streaming messages
loading bool
lastContent string // Stores the live content of the current streaming message
err error
availableHeight int // Available height for the viewport
}

Update 方法对应修改添加工具调用的交互场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
case llm.AssistantToolCallMsg:
cmd = m.agent.HandleToolCallRequest(msg)
m.viewport.SetContent(m.renderConversation(true))
m.viewport.GotoBottom()
return m, cmd

case llm.ToolResultMsg:
cmd = m.agent.HandleToolResult(msg.ToolCallID, msg.Result)
m.viewport.SetContent(m.renderConversation(true))
m.viewport.GotoBottom()
return m, cmd

case tea.KeyMsg:
viewState := m.agent.GetViewState()
if viewState.IsConfirming {
switch msg.String() {
case "y", "Y":
cmd = m.agent.HandleConfirmation(true)
return m, cmd
case "n", "N":
cmd = m.agent.HandleConfirmation(false)
return m, cmd
}
}

switch msg.Type {
case tea.KeyCtrlC, tea.KeyEsc:
return m, tea.Quit
case tea.KeyEnter:
prompt := strings.TrimSpace(m.textarea.Value())
if prompt != "" && !m.loading && !viewState.IsConfirming {
cmd = m.agent.HandleUserInput(prompt)
m.textarea.Reset()
m.viewport.SetContent(m.renderConversation(true))
m.viewport.GotoBottom()
return m, cmd
}
}
}

在 View 中添加一个简单的确认 Box(UI 先糊弄着):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
var confirmationBox string  

if viewState.IsConfirming {
confirmStyle := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("205")).
Padding(1, 2)

question := fmt.Sprintf(
"GhOst wants to run the tool: %s\n\nArguments:\n%s\n\nDo you want to allow this?",
viewState.ConfirmingToolCall.Function.Name,
viewState.ConfirmingToolCall.Function.Arguments,
)
confirmationBox = confirmStyle.Render(question)
}

至于 fs 工具中添加一个确认配置,注册时由工具自己声明控制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Tool represents a function that can be called by the agent.
type Tool interface {
// Name is the name of the tool, as it would be called by the model.
Name() string
// Description is a description of the tool's purpose, inputs, and outputs.
Description() string
// Parameters returns the JSON schema for the tool's arguments.
Parameters() any
// Execute runs the tool with the given arguments and returns the output.
// The args are expected to be a JSON string.
Execute(args string) (string, error)
// RequiresConfirmation indicates whether the tool requires user confirmation before execution.
RequiresConfirmation() bool
}

还有 write_file 的实现和一些高度的适配调整就此略过,整体调试下来已经可以满足工具操作确认的简单交互,后面实现 Edit 功能时再考虑优化完善 diff 信息和确认弹窗的样式等交互。

0x13 命令执行

完成基本的工具定义后即可顺理成章地加上 globsearch_file_contentreplace 工具,而命令执行的接口其实相对比较简单,由于之前已经完成了确认交互的功能,只需要留意下操作系统环境即可:

1
2
3
4
5
6
7
8
var cmd *exec.Cmd
if runtime.GOOS == "windows" {
// Windows 系统
cmd = exec.Command("cmd", "/C", toolArgs.Command)
} else {
// Linux, macOS, and other Unix-like systems
cmd = exec.Command("sh", "-c", toolArgs.Command)
}

由于多了不同的工具类型,之前的工具定义接口也要单独提出来:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Tool represents a function that can be called by the agent.
type Tool interface {
// Name is the name of the tool, as it would be called by the model.
Name() string
// Description is a description of the tool's purpose, inputs, and outputs.
Description() string
// Parameters returns the JSON schema for the tool's arguments.
Parameters() any
// Execute runs the tool with the given arguments and returns the output.
// The args are expected to be a JSON string.
Execute(args string) (string, error)
// RequiresConfirmation indicates whether the tool requires user confirmation before execution.
RequiresConfirmation() bool
}

由于接口只为了功能实现而定义,所以 UI 交互上还有不少值得改进的部分,目前效果如下:

0x14 系统提示

TODO

0x15 What’s Next

这个月看了篇文章叫 Agents 2.0:从浅循环到深层代理,至此已经基本实现了其中所描述的 Agent 1.0 功能——循环处理自身并借助工具调用完成用户提示,但是这种模式只能在较短的上下文中发挥作用,遇到复杂的任务就会表现出明显的局限性。

接下来会优先清理一些 UI 交互上的遗留问题,再向 Agent 2.0 中的功能迈进,包括但不限于长期记忆、明确规划、多代理等高级功能。