orca.ai/pkg/actor/llm_agent.go
大森 e18dde7c15 feat: implement TUI with bubbletea and multi-agent collaboration
- Add bubbletea/lipgloss/glamour dependencies for TUI
- Create internal/tui package with EventWriter, styles, and bubbletea Model
- Support streaming output display in conversation window
- Add right panel with statistics and active agent status
- Implement multi-agent collaboration with sub-agents
- Add AgentCallTool for delegating tasks to sub-agents
- Support parallel tool execution with goroutines
- Auto-discover sub-agents from ~/.orca/prompts/ directory
- Fix orchestrator routing based on msg.To field
- Add non-blocking event writer with timeout to prevent blocking
2026-05-10 14:28:17 +08:00

583 lines
16 KiB
Go
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.

package actor
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
"sync"
"github.com/orca/orca/pkg/bus"
"github.com/orca/orca/pkg/llm"
"github.com/orca/orca/pkg/session"
"github.com/orca/orca/pkg/skill"
"github.com/orca/orca/pkg/tool"
)
// LLMAgent implements the Agent interface by integrating an LLM backend
// with the actor system and tool framework.
//
// It receives user messages, retrieves session context, calls the LLM,
// and handles tool call responses by executing tools and feeding results
// back to the LLM for final response generation.
type LLMAgent struct {
*BaseAgent
llm llm.LLM
sessionMgr *session.Manager
sessionID string
toolManager *tool.Manager
skillManager *skill.Manager
toolWorker *ToolWorker
windowSize int
streamWriter io.Writer
systemPrompt string
subAgents map[string]string
}
// LLMAgentOption is a functional option for configuring the LLMAgent.
type LLMAgentOption func(*LLMAgent)
// WithSessionManager sets the session manager for conversation history.
func WithSessionManager(mgr *session.Manager) LLMAgentOption {
return func(a *LLMAgent) {
a.sessionMgr = mgr
}
}
// WithSessionID sets the session ID for conversation persistence.
func WithSessionID(id string) LLMAgentOption {
return func(a *LLMAgent) {
a.sessionID = id
}
}
// WithToolManager sets the tool manager for executing tools.
func WithToolManager(mgr *tool.Manager) LLMAgentOption {
return func(a *LLMAgent) {
a.toolManager = mgr
}
}
// WithToolWorker sets the tool worker for delegated tool execution.
func WithToolWorker(w *ToolWorker) LLMAgentOption {
return func(a *LLMAgent) {
a.toolWorker = w
}
}
// WithWindowSize sets the context window size for session history.
func WithWindowSize(size int) LLMAgentOption {
return func(a *LLMAgent) {
a.windowSize = size
}
}
func WithSkillManager(mgr *skill.Manager) LLMAgentOption {
return func(a *LLMAgent) {
a.skillManager = mgr
}
}
// WithStreamWriter sets the writer for streaming LLM output.
func WithStreamWriter(w io.Writer) LLMAgentOption {
return func(a *LLMAgent) {
a.streamWriter = w
}
}
func WithSystemPrompt(prompt string) LLMAgentOption {
return func(a *LLMAgent) {
a.systemPrompt = prompt
}
}
func WithSubAgents(agents map[string]string) LLMAgentOption {
return func(a *LLMAgent) {
a.subAgents = agents
}
}
// NewLLMAgent creates a new LLMAgent with the given LLM backend and options.
// The agent is started automatically upon creation.
func NewLLMAgent(id string, backend llm.LLM, opts ...LLMAgentOption) *LLMAgent {
a := &LLMAgent{
BaseAgent: NewBaseAgent(id, "llm_agent"),
llm: backend,
windowSize: 20, // Default context window
}
for _, opt := range opts {
opt(a)
}
a.SetHandler(a.handleMessage)
if err := a.Start(); err != nil {
panic(fmt.Sprintf("llm_agent: failed to start: %v", err))
}
return a
}
// handleMessage routes incoming messages to the appropriate handler.
func (a *LLMAgent) handleMessage(ctx context.Context, msg bus.Message) (bus.Message, error) {
switch msg.Type {
case bus.MsgTypeTaskRequest:
return a.handleUserMessage(ctx, msg)
case bus.MsgTypeSystem:
return a.handleSystem(ctx, msg)
default:
return bus.Message{}, fmt.Errorf("llm_agent %s: unsupported message type %s", a.ID(), msg.Type)
}
}
// handleUserMessage processes a user message through the LLM.
//
// Flow:
// 1. Persist the user message to session history
// 2. Retrieve recent conversation context
// 3. Convert to LLM message format
// 4. Call LLM.Chat
// 5. If response has tool calls:
// a. Execute each tool (directly or via ToolWorker)
// b. Add tool results to conversation
// c. Call LLM.Chat again with results
// 6. Persist the assistant response
// 7. Return the final response
func (a *LLMAgent) handleUserMessage(ctx context.Context, msg bus.Message) (bus.Message, error) {
content, ok := msg.Content.(string)
if !ok {
return bus.Message{}, fmt.Errorf("llm_agent: expected string content, got %T", msg.Content)
}
// Ensure session exists
if a.sessionMgr != nil && a.sessionID != "" {
// Check if session exists; create if not
if _, err := a.sessionMgr.GetSession(a.sessionID); err != nil {
a.sessionMgr.CreateSession(a.sessionID, map[string]string{
"source": "llm_agent",
})
}
// Persist user message
a.sessionMgr.AddMessage(a.sessionID, session.RoleUser, content, nil)
}
llmMessages := a.buildLLMMessages()
if a.skillManager != nil {
matchedSkills := a.skillManager.FindSkill(content)
for _, s := range matchedSkills {
if s.Body != "" {
llmMessages = append(llmMessages, llm.Message{
Role: "system",
Content: fmt.Sprintf("以下是你需要遵循的 %s 技能指南:\n\n%s", s.Name, s.Body),
})
}
}
}
// Call LLM (potentially multiple rounds for tool calls)
finalResponse, err := a.chatWithToolLoop(ctx, llmMessages)
if err != nil {
return bus.Message{}, fmt.Errorf("llm_agent: LLM chat failed: %w", err)
}
// Persist assistant response
if a.sessionMgr != nil && a.sessionID != "" {
a.sessionMgr.AddMessage(a.sessionID, session.RoleAssistant, finalResponse, nil)
}
return bus.Message{
ID: msg.ID + "-response",
Type: bus.MsgTypeTaskResponse,
From: a.ID(),
To: msg.From,
Content: finalResponse,
}, nil
}
func (a *LLMAgent) buildLLMMessages() []llm.Message {
messages := make([]llm.Message, 0)
// 1. 用户自定义 system prompt配置式身份描述
if a.systemPrompt != "" {
messages = append(messages, llm.Message{
Role: "system",
Content: a.systemPrompt,
})
}
// 2. 运行时工具说明(动态生成)
toolPrompt := a.buildToolPrompt()
if toolPrompt != "" {
messages = append(messages, llm.Message{
Role: "system",
Content: toolPrompt,
})
}
if a.sessionMgr == nil || a.sessionID == "" {
return messages
}
sessionMsgs, err := a.sessionMgr.GetContext(a.sessionID, a.windowSize)
if err != nil {
return messages
}
for _, sm := range sessionMsgs {
msg := llm.Message{
Role: string(sm.Role),
Content: sm.Content,
}
if sm.Role == session.RoleTool && sm.Metadata != nil {
msg.ToolCallID = sm.Metadata["tool_call_id"]
}
messages = append(messages, msg)
}
return messages
}
// buildToolPrompt 生成工具说明提示词(不包含身份描述)。
// 将可用工具和调用规则注入给 LLM支持基于提示词的工具调用。
func (a *LLMAgent) buildToolPrompt() string {
var b strings.Builder
if a.toolManager != nil {
b.WriteString("你可以使用以下工具来完成用户的请求。\n\n")
b.WriteString("可用工具列表:\n")
for _, t := range a.toolManager.List() {
b.WriteString(fmt.Sprintf("\n工具名: %s\n", t.Name()))
b.WriteString(fmt.Sprintf("描述: %s\n", t.Description()))
paramsJSON, _ := json.Marshal(t.Parameters())
b.WriteString(fmt.Sprintf("参数: %s\n", string(paramsJSON)))
}
b.WriteString("\n规则\n")
b.WriteString("1. 当你需要调用工具时,请在回复中**只输出**以下 JSON 格式(不要添加其他文字):\n")
b.WriteString(` {"tool": "工具名", "arguments": {"参数名": "参数值"}}` + "\n")
b.WriteString("2. 如果需要同时调用多个工具(并行执行),请输出 JSON 数组格式:\n")
b.WriteString(` [{"tool": "工具名1", "arguments": {...}}, {"tool": "工具名2", "arguments": {...}}]` + "\n")
b.WriteString("3. 如果你已经看到了工具返回的结果,请直接根据结果回答用户,不要再次调用工具。\n")
b.WriteString("4. 如果你不需要调用工具,请直接回复用户。\n")
}
if len(a.subAgents) > 0 {
b.WriteString("\n\n你可以调用以下专业Agent来协助完成特定任务\n")
for name, description := range a.subAgents {
b.WriteString(fmt.Sprintf("- %s: %s\n", name, description))
}
b.WriteString("\n调用方式使用 agent_call 工具,指定 agent 名称和任务描述。\n")
b.WriteString("示例:{\"tool\": \"agent_call\", \"arguments\": {\"agent\": \"coder\", \"task\": \"写个快速排序\"}}\n")
b.WriteString("如果用户有多个独立任务,请同时调用多个 agent_callJSON数组格式让它们并行执行。\n")
b.WriteString("\n重要当用户的请求涉及上述专业领域时你必须调用相应的子Agent不要自己直接回答。\n")
}
if a.skillManager != nil {
skills := a.skillManager.ListSkills()
if len(skills) > 0 {
b.WriteString("\n\n你还可以使用以下技能来更好地帮助用户\n")
for _, s := range skills {
b.WriteString(fmt.Sprintf("\n=== 技能: %s ===\n", s.Name))
b.WriteString(fmt.Sprintf("描述: %s\n", s.Description))
if len(s.Triggers) > 0 {
b.WriteString(fmt.Sprintf("触发词: %s\n", strings.Join(s.Triggers, ", ")))
}
if s.Body != "" {
body := s.Body
if len(body) > 4000 {
body = body[:4000] + "\n...[内容已截断]"
}
b.WriteString(fmt.Sprintf("\n详细指南:\n%s\n", body))
}
b.WriteString(fmt.Sprintf("=== 结束: %s ===\n", s.Name))
}
b.WriteString("\n当用户的请求匹配某个技能的触发词时请根据该技能的详细指南提供更专业的帮助。\n")
b.WriteString("你应该主动使用相关技能的专业知识来回答用户问题,而不需要询问用户是否使用技能。\n")
}
}
if b.Len() == 0 {
return ""
}
return b.String()
}
func (a *LLMAgent) chatWithToolLoop(ctx context.Context, messages []llm.Message) (string, error) {
maxRounds := 10
for round := 0; round < maxRounds; round++ {
var content string
var err error
if a.streamWriter != nil {
content, err = a.streamChat(ctx, messages)
} else {
content, err = a.syncChat(ctx, messages)
}
if err != nil {
return "", fmt.Errorf("chat round %d failed: %w", round, err)
}
toolCalls := a.parseToolCallsFromContent(content)
if len(toolCalls) == 0 {
return content, nil
}
messages = append(messages, llm.Message{
Role: "assistant",
Content: content,
})
results := a.executeToolCallsParallel(ctx, toolCalls)
for _, result := range results {
messages = append(messages, llm.Message{
Role: "user",
Content: result,
})
}
}
return "", fmt.Errorf("llm_agent: exceeded maximum tool call rounds (%d)", maxRounds)
}
func (a *LLMAgent) syncChat(ctx context.Context, messages []llm.Message) (string, error) {
response, err := a.llm.Chat(ctx, messages)
if err != nil {
return "", err
}
if len(response.ToolCalls) > 0 {
return response.Content, nil
}
return response.Content, nil
}
func (a *LLMAgent) streamChat(ctx context.Context, messages []llm.Message) (string, error) {
var content strings.Builder
if a.streamWriter != nil {
fmt.Fprint(a.streamWriter, "\n")
}
err := a.llm.Stream(ctx, messages, func(chunk string) error {
content.WriteString(chunk)
if a.streamWriter != nil {
fmt.Fprint(a.streamWriter, chunk)
}
return nil
})
if err != nil {
return "", err
}
if a.streamWriter != nil {
fmt.Fprintln(a.streamWriter)
}
return content.String(), nil
}
func (a *LLMAgent) parseToolCallsFromContent(content string) []llm.ToolCall {
cleanContent := a.extractJSONFromMarkdown(content)
var toolCalls []llm.ToolCall
var callIndex int
var parsedList []struct {
Tool string `json:"tool"`
Arguments map[string]interface{} `json:"arguments"`
}
if err := json.Unmarshal([]byte(cleanContent), &parsedList); err == nil && len(parsedList) > 0 {
for _, parsed := range parsedList {
if parsed.Tool == "" {
continue
}
argsJSON, _ := json.Marshal(parsed.Arguments)
toolCalls = append(toolCalls, llm.ToolCall{
ID: fmt.Sprintf("call_%d", callIndex),
Type: "function",
Function: llm.FunctionCall{
Name: parsed.Tool,
Arguments: string(argsJSON),
},
})
callIndex++
}
return toolCalls
}
var parsed struct {
Tool string `json:"tool"`
Arguments map[string]interface{} `json:"arguments"`
}
if err := json.Unmarshal([]byte(cleanContent), &parsed); err == nil && parsed.Tool != "" {
argsJSON, _ := json.Marshal(parsed.Arguments)
return []llm.ToolCall{{
ID: "call_0",
Type: "function",
Function: llm.FunctionCall{
Name: parsed.Tool,
Arguments: string(argsJSON),
},
}}
}
var commaSeparated []struct {
Tool string `json:"tool"`
Arguments map[string]interface{} `json:"arguments"`
}
wrapped := "[" + cleanContent + "]"
if err := json.Unmarshal([]byte(wrapped), &commaSeparated); err == nil && len(commaSeparated) > 0 {
for _, parsed := range commaSeparated {
if parsed.Tool == "" {
continue
}
argsJSON, _ := json.Marshal(parsed.Arguments)
toolCalls = append(toolCalls, llm.ToolCall{
ID: fmt.Sprintf("call_%d", callIndex),
Type: "function",
Function: llm.FunctionCall{
Name: parsed.Tool,
Arguments: string(argsJSON),
},
})
callIndex++
}
return toolCalls
}
return nil
}
func (a *LLMAgent) extractJSONFromMarkdown(content string) string {
start := strings.Index(content, "```")
if start == -1 {
return content
}
start = strings.Index(content[start:], "\n")
if start == -1 {
return content
}
start++
end := strings.LastIndex(content[start:], "```")
if end == -1 {
return content
}
return strings.TrimSpace(content[start : start+end])
}
func (a *LLMAgent) executeToolCall(ctx context.Context, tc llm.ToolCall) string {
toolName := tc.Function.Name
if a.streamWriter != nil {
fmt.Fprintf(a.streamWriter, "\n[正在执行工具: %s...]\n", toolName)
}
var args map[string]interface{}
if err := json.Unmarshal([]byte(tc.Function.Arguments), &args); err != nil {
args = map[string]interface{}{
"_raw": tc.Function.Arguments,
}
}
if a.toolWorker != nil {
toolCallMsg := bus.Message{
ID: tc.ID,
Type: bus.MsgTypeToolCall,
From: a.ID(),
To: a.toolWorker.ID(),
Content: map[string]interface{}{"name": toolName, "arguments": args},
}
resultMsg, err := a.toolWorker.Process(ctx, toolCallMsg)
if a.streamWriter != nil {
fmt.Fprintf(a.streamWriter, "[工具 %s 执行完成]\n", toolName)
}
if err != nil {
return fmt.Sprintf(`{"error": "tool execution failed: %v"}`, err)
}
resultJSON, err := json.Marshal(resultMsg.Content)
if err != nil {
return fmt.Sprintf(`{"error": "failed to marshal result: %v"}`, err)
}
return string(resultJSON)
}
if a.toolManager != nil {
result, err := a.toolManager.Execute(toolName, ctx, args)
if a.streamWriter != nil {
fmt.Fprintf(a.streamWriter, "[工具 %s 执行完成]\n", toolName)
}
if err != nil {
return fmt.Sprintf(`{"error": "tool execution failed: %v"}`, err)
}
resultJSON, err := json.Marshal(result)
if err != nil {
return fmt.Sprintf(`{"error": "failed to marshal result: %v"}`, err)
}
return string(resultJSON)
}
return fmt.Sprintf(`{"error": "no tool worker or tool manager available for %q"}`, toolName)
}
func (a *LLMAgent) executeToolCallsParallel(ctx context.Context, toolCalls []llm.ToolCall) []string {
if len(toolCalls) == 1 {
result := a.executeToolCall(ctx, toolCalls[0])
return []string{fmt.Sprintf("工具 %s 的执行结果:%s", toolCalls[0].Function.Name, result)}
}
type result struct {
index int
content string
}
results := make([]result, len(toolCalls))
var wg sync.WaitGroup
for i, tc := range toolCalls {
wg.Add(1)
go func(idx int, toolCall llm.ToolCall) {
defer wg.Done()
res := a.executeToolCall(ctx, toolCall)
results[idx] = result{
index: idx,
content: fmt.Sprintf("工具 %s 的执行结果:%s", toolCall.Function.Name, res),
}
}(i, tc)
}
wg.Wait()
strings := make([]string, len(toolCalls))
for i, r := range results {
strings[i] = r.content
}
return strings
}
// handleSystem processes internal system messages.
func (a *LLMAgent) handleSystem(ctx context.Context, msg bus.Message) (bus.Message, error) {
return bus.Message{
ID: msg.ID + "-ack",
Type: bus.MsgTypeSystem,
From: a.ID(),
To: msg.From,
Content: "llm_agent acknowledged",
}, nil
}
func (a *LLMAgent) SetStreamWriter(w io.Writer) {
a.streamWriter = w
}
var _ Agent = (*LLMAgent)(nil)
var _ Agent = (*ToolWorker)(nil)