Files
MuyueWorkspace/internal/api/chat_engine.go
Muyue d557b8e74c
All checks were successful
PR Check / check (pull_request) Successful in 1m0s
fix(windows): native ConPTY + kernel32 metrics + agent loop cap (v0.7.6)
Three issues reported on Windows + one user-requested limit bump:

1. Dashboard CPU/RAM/Network all at 0
   handleSystemMetrics read /proc/* exclusively. Replaced with a
   platform-split:
   - metrics_unix.go (!windows): existing /proc reading code.
   - metrics_windows.go: kernel32!GetSystemTimes for CPU
     (delta of idle vs kernel+user FILETIMEs) and
     kernel32!GlobalMemoryStatusEx for memory. Network left at zero
     for now — MIB_IF_ROW2 is too version-sensitive to parse by hand.
   handlers_info.go::handleSystemMetrics reduced to one delegating
   call.

2. Terminal black screen on Windows
   creack/pty/v2 returns "unsupported" on Windows; the v0.7.1 pipe
   fallback works but pipes don't carry TTY signals, so cmd/pwsh/wsl
   go silent. Implemented native ConPTY:
   - terminal_conpty_windows.go: CreatePseudoConsole + STARTUPINFOEX
     + PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE wiring via
     windows.NewProcThreadAttributeList. CreateProcessW launches
     child with the PC attached, full ANSI / line discipline /
     resize.
   - canUseConPTY() probes once at startup (Win10 1809+ check).
   - Restructure: terminal_session.go now holds just the interface
     + ptySession + pipeSession structs. terminal_session_unix.go
     wires creack/pty. terminal_session_windows.go tries ConPTY
     first, falls back to pipeSession.

3. Agent stops after 15 tool calls
   MaxToolIterations bumped 15 → 500. Doc comment explains why the
   cap exists at all (infinite-loop safety) and that 500 is well
   above realistic usage.

- internal/version/version.go: 0.7.5 → 0.7.6
- CHANGELOG.md: v0.7.6 entry covers the three fixes
2026-04-27 14:04:41 +02:00

333 lines
8.9 KiB
Go

package api
import (
"context"
"encoding/json"
"fmt"
"net/http"
"github.com/muyue/muyue/internal/agent"
"github.com/muyue/muyue/internal/orchestrator"
)
// MaxToolIterations bounds the inner tool-call loop in RunWithTools /
// RunNonStream. The cap exists only to avoid an infinite loop when a model
// keeps calling tools forever; the value is intentionally generous so a
// realistic agent run (multi-file refactor, exploratory debugging…) never
// hits it. If you find yourself raising this to absurd values, look for a
// loop bug in the model output instead.
const MaxToolIterations = 500
// ToolLimiter checks if a tool call is allowed and returns a release function.
type ToolLimiter func(toolName string) (release func(), err error)
// ChatEngine handles chat interactions with tool execution.
// This deduplicates chat logic previously repeated in handlers_chat.go and handlers_shell_chat.go.
type ChatEngine struct {
orchestrator *orchestrator.Orchestrator
registry *agent.Registry
tools json.RawMessage
onChunk func(map[string]interface{})
stream bool
limiter ToolLimiter
TotalTokens int
}
// NewChatEngine creates a new ChatEngine instance.
func NewChatEngine(orb *orchestrator.Orchestrator, registry *agent.Registry, tools json.RawMessage) *ChatEngine {
return &ChatEngine{
orchestrator: orb,
registry: registry,
tools: tools,
stream: false,
}
}
// SetStream enables streaming mode for the chat engine.
func (ce *ChatEngine) SetStream(enabled bool) {
ce.stream = enabled
}
// OnChunk sets the callback for SSE chunk writing.
func (ce *ChatEngine) OnChunk(fn func(map[string]interface{})) {
ce.onChunk = fn
}
// SetLimiter sets the tool call limiter for agent concurrency control.
func (ce *ChatEngine) SetLimiter(l ToolLimiter) {
ce.limiter = l
}
// RunWithTools executes the chat loop with tool calls.
// Returns final content, tool calls, tool results, and error.
func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.Message) (string, []map[string]interface{}, []map[string]interface{}, error) {
var finalContent string
var allToolCalls []map[string]interface{}
var allToolResults []map[string]interface{}
for i := 0; i < MaxToolIterations; i++ {
var resp *orchestrator.ChatResponse
var err error
if ce.stream {
// Use streaming version
resp, err = ce.orchestrator.SendWithToolsStream(messages, func(content string, toolCalls []orchestrator.ToolCallMsg) {
if ce.onChunk != nil && content != "" {
ce.onChunk(map[string]interface{}{"content": content})
}
})
} else {
resp, err = ce.orchestrator.SendWithTools(messages)
}
if err != nil {
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"error": err.Error()})
}
return finalContent, allToolCalls, allToolResults, err
}
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, allToolCalls, allToolResults, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0]
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"content": content})
}
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: orchestrator.TextContent(content),
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
toolCallData := map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
}
allToolCalls = append(allToolCalls, toolCallData)
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_call": toolCallData})
}
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
var release func()
if ce.limiter != nil {
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
limResultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": limitErr.Error(),
"is_error": true,
}
allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
"result": limitErr.Error(),
"is_error": true,
})
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_result": limResultData})
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
release = rel
}
result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
resultData := map[string]interface{}{
"tool_call_id": tc.ID,
"content": result.Content,
"is_error": result.IsError,
}
if result.Meta != nil {
for k, v := range result.Meta {
resultData[k] = v
}
}
allToolResults = append(allToolResults, map[string]interface{}{
"tool_call_id": tc.ID,
"name": tc.Function.Name,
"args": tc.Function.Arguments,
"result": result.Content,
"is_error": result.IsError,
})
if ce.onChunk != nil {
ce.onChunk(map[string]interface{}{"tool_result": resultData})
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(result.Content),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
return finalContent, allToolCalls, allToolResults, nil
}
// ProviderName returns the name of the active provider used by the engine.
func (ce *ChatEngine) ProviderName() string {
return ce.orchestrator.ProviderName()
}
// RunNonStream executes chat without streaming content to client.
func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.Message) (string, error) {
var finalContent string
for i := 0; i < MaxToolIterations; i++ {
resp, err := ce.orchestrator.SendWithTools(messages)
if err != nil {
return finalContent, err
}
if resp.Usage.TotalTokens > 0 {
ce.TotalTokens += resp.Usage.TotalTokens
}
if len(resp.Choices) == 0 {
return finalContent, fmt.Errorf("empty response from provider")
}
choice := resp.Choices[0]
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
if content != "" {
finalContent = content
}
if len(choice.Message.ToolCalls) == 0 {
break
}
assistantMsg := orchestrator.Message{
Role: "assistant",
Content: orchestrator.TextContent(content),
ToolCalls: choice.Message.ToolCalls,
}
messages = append(messages, assistantMsg)
for _, tc := range choice.Message.ToolCalls {
call := agent.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Arguments: json.RawMessage(tc.Function.Arguments),
}
var release func()
if ce.limiter != nil {
rel, limitErr := ce.limiter(tc.Function.Name)
if limitErr != nil {
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(limitErr.Error()),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
continue
}
release = rel
}
result, execErr := ce.registry.Execute(ctx, call)
if release != nil {
release()
}
if execErr != nil {
result = agent.ToolResponse{
Content: execErr.Error(),
IsError: true,
}
}
messages = append(messages, orchestrator.Message{
Role: "tool",
Content: orchestrator.TextContent(result.Content),
ToolCallID: tc.ID,
Name: tc.Function.Name,
})
}
finalContent = ""
}
if finalContent == "" {
finalContent = "(tool calls completed, no text response)"
}
return finalContent, nil
}
// SSEWriter handles Server-Sent Events writing to HTTP response.
type SSEWriter struct {
w http.ResponseWriter
flusher http.Flusher
}
// NewSSEWriter creates a new SSEWriter.
func NewSSEWriter(w http.ResponseWriter) *SSEWriter {
sse := &SSEWriter{w: w}
if f, ok := w.(http.Flusher); ok {
sse.flusher = f
}
return sse
}
// Write sends an SSE message.
func (s *SSEWriter) Write(data map[string]interface{}) {
b, _ := json.Marshal(data)
s.w.Write([]byte("data: " + string(b) + "\n\n"))
if s.flusher != nil {
s.flusher.Flush()
}
}
// SetupSSEHeaders sets up SSE response headers.
func SetupSSEHeaders(w http.ResponseWriter) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.WriteHeader(http.StatusOK)
}