feat: agent concurrency, conversation summaries, AI tools config, UI polish
Some checks failed
Beta Release / beta (push) Failing after 33s
Some checks failed
Beta Release / beta (push) Failing after 33s
- Agent slot limiter for concurrent tool execution - Conversation summarization with soft-delete (MarkSummarized) - ANSI stripping in terminal tool output - Configurable crush-run timeout (default 600s, max 900s) - Starship theme refactor, AI tools config grid, system update UI - Streaming segments refactor, summarized messages block in feed - CSS: headings, scrollbars, tool cards, summary block styles - i18n additions (en+fr) for tools, updates, config 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush <crush@charm.land>
This commit is contained in:
@@ -6,11 +6,18 @@ import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ansiRegex = regexp.MustCompile(`\x1b\[[0-9;]*[a-zA-Z]|\x1b\][^\x07]*\x07|\x1b\][^\x1b]*\x1b\\|\x1b[()][AB012]|\[\]`)
|
||||
|
||||
func stripANSI(s string) string {
|
||||
return ansiRegex.ReplaceAllString(s, "")
|
||||
}
|
||||
|
||||
var (
|
||||
sudoCache bool
|
||||
sudoCacheSet bool
|
||||
@@ -103,6 +110,7 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
||||
output, err := cmd.CombinedOutput()
|
||||
|
||||
result := string(output)
|
||||
result = stripANSI(result)
|
||||
if len(result) > 10000 {
|
||||
result = result[:10000] + "\n... [truncated]"
|
||||
}
|
||||
@@ -116,7 +124,8 @@ func NewTerminalTool() (*ToolDefinition, error) {
|
||||
}
|
||||
|
||||
type CrushRunParams struct {
|
||||
Task string `json:"task" description:"The task description for Crush to execute"`
|
||||
Task string `json:"task" description:"The task description for Crush to execute"`
|
||||
Timeout int `json:"timeout,omitempty" description:"Maximum execution time in seconds (default 600, max 900)"`
|
||||
}
|
||||
|
||||
func NewCrushRunTool() (*ToolDefinition, error) {
|
||||
@@ -127,7 +136,14 @@ func NewCrushRunTool() (*ToolDefinition, error) {
|
||||
return TextErrorResponse("task is required"), nil
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(ctx, 300*time.Second)
|
||||
timeout := time.Duration(p.Timeout) * time.Second
|
||||
if timeout == 0 {
|
||||
timeout = 600 * time.Second
|
||||
}
|
||||
if timeout > 900*time.Second {
|
||||
timeout = 900 * time.Second
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(ctx, timeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(ctx, "crush", "run", p.Task)
|
||||
@@ -139,7 +155,14 @@ func NewCrushRunTool() (*ToolDefinition, error) {
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return TextErrorResponse(fmt.Sprintf("Crush error: %v\n\n%s", err, result)), nil
|
||||
errMsg := fmt.Sprintf("Crush error: %v", err)
|
||||
if ctx.Err() == context.DeadlineExceeded {
|
||||
errMsg = fmt.Sprintf("Crush timed out after %d seconds. Try splitting the task into smaller parts.", int(timeout.Seconds()))
|
||||
}
|
||||
if result != "" {
|
||||
errMsg += "\n\n" + result
|
||||
}
|
||||
return TextErrorResponse(errMsg), nil
|
||||
}
|
||||
|
||||
return TextResponse(result), nil
|
||||
|
||||
@@ -13,6 +13,9 @@ const (
|
||||
MaxToolIterations = 15
|
||||
)
|
||||
|
||||
// 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 {
|
||||
@@ -21,6 +24,7 @@ type ChatEngine struct {
|
||||
tools json.RawMessage
|
||||
onChunk func(map[string]interface{})
|
||||
stream bool
|
||||
limiter ToolLimiter
|
||||
TotalTokens int
|
||||
}
|
||||
|
||||
@@ -44,6 +48,11 @@ 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) {
|
||||
@@ -77,7 +86,7 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
|
||||
|
||||
if content != "" {
|
||||
if ce.onChunk != nil {
|
||||
@@ -115,6 +124,35 @@ func (ce *ChatEngine) RunWithTools(ctx context.Context, messages []orchestrator.
|
||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||
}
|
||||
|
||||
if ce.limiter != nil {
|
||||
release, 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
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
|
||||
result, execErr := ce.registry.Execute(ctx, call)
|
||||
if execErr != nil {
|
||||
result = agent.ToolResponse{
|
||||
@@ -179,7 +217,7 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
||||
}
|
||||
|
||||
choice := resp.Choices[0]
|
||||
content := cleanThinkingTags(choice.Message.Content)
|
||||
content := orchestrator.CleanAIResponse(cleanThinkingTags(choice.Message.Content))
|
||||
|
||||
if content != "" {
|
||||
finalContent = content
|
||||
@@ -203,6 +241,20 @@ func (ce *ChatEngine) RunNonStream(ctx context.Context, messages []orchestrator.
|
||||
Arguments: json.RawMessage(tc.Function.Arguments),
|
||||
}
|
||||
|
||||
if ce.limiter != nil {
|
||||
release, 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
|
||||
}
|
||||
defer release()
|
||||
}
|
||||
|
||||
result, execErr := ce.registry.Execute(ctx, call)
|
||||
if execErr != nil {
|
||||
result = agent.ToolResponse{
|
||||
|
||||
@@ -17,12 +17,53 @@ const contextWindowTokens = 150000
|
||||
const summarizeRatio = 0.80
|
||||
const charsPerToken = 4
|
||||
|
||||
func extractDisplayContent(role, content string) string {
|
||||
if role != "assistant" {
|
||||
return content
|
||||
}
|
||||
var parsed struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
} `json:"tool_calls"`
|
||||
ToolResults []struct {
|
||||
Name string `json:"name"`
|
||||
Result string `json:"result"`
|
||||
} `json:"tool_results"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &parsed); err != nil {
|
||||
return content
|
||||
}
|
||||
var sb strings.Builder
|
||||
if parsed.Content != "" {
|
||||
sb.WriteString(parsed.Content)
|
||||
}
|
||||
for _, tc := range parsed.ToolCalls {
|
||||
sb.WriteString("\n[")
|
||||
sb.WriteString(tc.Name)
|
||||
sb.WriteString("] ")
|
||||
sb.WriteString(tc.Args)
|
||||
}
|
||||
for _, tr := range parsed.ToolResults {
|
||||
sb.WriteString("\n[result")
|
||||
if tr.Name != "" {
|
||||
sb.WriteString(":")
|
||||
sb.WriteString(tr.Name)
|
||||
}
|
||||
sb.WriteString("] ")
|
||||
sb.WriteString(tr.Result)
|
||||
}
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
type FeedMessage struct {
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Time string `json:"time"`
|
||||
Images []string `json:"images,omitempty"`
|
||||
Summarized bool `json:"summarized,omitempty"`
|
||||
}
|
||||
|
||||
type Conversation struct {
|
||||
@@ -168,13 +209,15 @@ func (cs *ConversationStore) SetSummary(summary string) {
|
||||
cs.save()
|
||||
}
|
||||
|
||||
func (cs *ConversationStore) TrimOld(keepCount int) {
|
||||
func (cs *ConversationStore) MarkSummarized(upToIndex int) {
|
||||
cs.mu.Lock()
|
||||
defer cs.mu.Unlock()
|
||||
if len(cs.conv.Messages) <= keepCount {
|
||||
if upToIndex <= 0 || upToIndex >= len(cs.conv.Messages) {
|
||||
return
|
||||
}
|
||||
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
|
||||
for i := 0; i < upToIndex; i++ {
|
||||
cs.conv.Messages[i].Summarized = true
|
||||
}
|
||||
cs.save()
|
||||
}
|
||||
|
||||
@@ -191,7 +234,10 @@ func (cs *ConversationStore) ApproxTokenCountDetailed() TokenCount {
|
||||
}
|
||||
|
||||
for _, m := range cs.conv.Messages {
|
||||
count := utf8.RuneCountInString(m.Content) / charsPerToken
|
||||
if m.Role == "system" || m.Summarized {
|
||||
continue
|
||||
}
|
||||
count := utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / charsPerToken
|
||||
result.byMessage += count
|
||||
result.byRole[m.Role] += count
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -64,15 +63,13 @@ func (s *Server) describeImages(images []ImageAttachment) []string {
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
log.Printf("[vlm] no API key found for image description")
|
||||
return nil
|
||||
}
|
||||
|
||||
descriptions := make([]string, 0, len(images))
|
||||
for i, img := range images {
|
||||
for _, img := range images {
|
||||
desc, err := s.callVLM(apiKey, img)
|
||||
if err != nil {
|
||||
log.Printf("[vlm] image %d (%s) failed: %v", i+1, img.Filename, err)
|
||||
descriptions = append(descriptions, fmt.Sprintf("(description unavailable: %v)", err))
|
||||
} else {
|
||||
descriptions = append(descriptions, desc)
|
||||
@@ -163,7 +160,7 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
id, err := saveImage(body.Images[i].Data, body.Images[i].Filename, body.Images[i].MimeType)
|
||||
if err != nil {
|
||||
log.Printf("[images] failed to save %s: %v", body.Images[i].Filename, err)
|
||||
_ = err
|
||||
} else {
|
||||
imageIDs = append(imageIDs, id)
|
||||
}
|
||||
@@ -227,6 +224,7 @@ func (s *Server) handleStreamChat(w http.ResponseWriter, orb *orchestrator.Orche
|
||||
messages := s.buildContextMessages(userMessage)
|
||||
|
||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||
engine.SetLimiter(s.AcquireAgentSlot)
|
||||
engine.OnChunk(func(data map[string]interface{}) {
|
||||
if data == nil {
|
||||
return
|
||||
@@ -265,6 +263,7 @@ func (s *Server) handleNonStreamChat(w http.ResponseWriter, orb *orchestrator.Or
|
||||
messages := s.buildContextMessages(userMessage)
|
||||
|
||||
engine := NewChatEngine(orb, s.agentRegistry, s.agentToolsJSON)
|
||||
engine.SetLimiter(s.AcquireAgentSlot)
|
||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -299,7 +298,11 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
||||
included := 0
|
||||
tokensUsed := 0
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
|
||||
if history[i].Summarized {
|
||||
break
|
||||
}
|
||||
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||
if msgTokens == 0 {
|
||||
msgTokens = 1
|
||||
}
|
||||
@@ -315,14 +318,21 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
||||
start = 0
|
||||
}
|
||||
|
||||
hasSummarized := false
|
||||
for i := 0; i < start; i++ {
|
||||
if history[i].Summarized {
|
||||
hasSummarized = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if start > 0 {
|
||||
log.Printf("[studio] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, contextWindowTokens, included, len(history), start)
|
||||
_ = start
|
||||
}
|
||||
|
||||
messages := make([]orchestrator.Message, 0, included+2)
|
||||
|
||||
summary := s.convStore.GetSummary()
|
||||
if summary != "" && start > 0 {
|
||||
if summary != "" && (start > 0 || hasSummarized) {
|
||||
messages = append(messages, orchestrator.Message{
|
||||
Role: "system",
|
||||
Content: orchestrator.TextContent("Résumé de la conversation précédente:\n" + summary),
|
||||
@@ -330,27 +340,13 @@ func (s *Server) buildContextMessages(userMessage string) []orchestrator.Message
|
||||
}
|
||||
|
||||
for _, m := range history[start:] {
|
||||
content := m.Content
|
||||
if m.Role == "assistant" {
|
||||
var parsed struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
|
||||
content = parsed.Content
|
||||
}
|
||||
}
|
||||
role := m.Role
|
||||
if role == "system" {
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
displayContent := extractDisplayContent(m.Role, m.Content)
|
||||
messages = append(messages, orchestrator.Message{
|
||||
Role: role,
|
||||
Content: orchestrator.TextContent(content),
|
||||
Role: m.Role,
|
||||
Content: orchestrator.TextContent(displayContent),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -391,8 +387,7 @@ func (s *Server) autoSummarize() {
|
||||
}
|
||||
|
||||
s.convStore.SetSummary(result)
|
||||
s.convStore.TrimOld(len(messages) - half)
|
||||
s.convStore.Add("system", "[Conversation résumée automatiquement]")
|
||||
s.convStore.MarkSummarized(half)
|
||||
}
|
||||
|
||||
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -335,30 +335,25 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
|
||||
body.Theme = s.config.Terminal.PromptTheme
|
||||
}
|
||||
|
||||
cfgDir, err := config.ConfigDir()
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
themeFile := ApplyStarshipTheme(body.Theme)
|
||||
|
||||
s.config.Terminal.PromptTheme = body.Theme
|
||||
config.Save(s.config)
|
||||
|
||||
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
|
||||
}
|
||||
|
||||
func ApplyStarshipTheme(theme string) string {
|
||||
cfgDir, _ := config.ConfigDir()
|
||||
starshipDir := filepath.Join(cfgDir, "starship")
|
||||
if err := os.MkdirAll(starshipDir, 0755); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
os.MkdirAll(starshipDir, 0755)
|
||||
themeFile := filepath.Join(starshipDir, "starship.toml")
|
||||
|
||||
themeContent := getStarshipThemeConfig(body.Theme)
|
||||
if err := os.WriteFile(themeFile, []byte(themeContent), 0644); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
themeContent := getStarshipThemeConfig(theme)
|
||||
os.WriteFile(themeFile, []byte(themeContent), 0644)
|
||||
|
||||
home, _ := os.UserHomeDir()
|
||||
shellRCs := []string{
|
||||
filepath.Join(home, ".bashrc"),
|
||||
filepath.Join(home, ".zshrc"),
|
||||
}
|
||||
for _, rc := range shellRCs {
|
||||
for _, rc := range []string{filepath.Join(home, ".bashrc"), filepath.Join(home, ".zshrc")} {
|
||||
if _, err := os.Stat(rc); err != nil {
|
||||
continue
|
||||
}
|
||||
@@ -375,10 +370,7 @@ func (s *Server) handleApplyStarshipTheme(w http.ResponseWriter, r *http.Request
|
||||
f.Close()
|
||||
}
|
||||
|
||||
s.config.Terminal.PromptTheme = body.Theme
|
||||
config.Save(s.config)
|
||||
|
||||
writeJSON(w, map[string]interface{}{"status": "ok", "config": themeFile})
|
||||
return themeFile
|
||||
}
|
||||
|
||||
func getStarshipThemeConfig(theme string) string {
|
||||
|
||||
@@ -91,6 +91,9 @@ func (s *Server) handleSkills(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
for i := range list {
|
||||
list[i].Deployed = skills.IsDeployed(list[i].Name)
|
||||
}
|
||||
writeJSON(w, map[string]interface{}{
|
||||
"skills": list,
|
||||
"count": len(list),
|
||||
|
||||
@@ -226,6 +226,29 @@ func (s *Server) handleSkillsDeploy(w http.ResponseWriter, r *http.Request) {
|
||||
writeJSON(w, map[string]string{"status": "all deployed"})
|
||||
}
|
||||
|
||||
func (s *Server) handleSkillsUndeploy(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "POST" {
|
||||
writeError(w, "POST only", http.StatusMethodNotAllowed)
|
||||
return
|
||||
}
|
||||
var body struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
writeError(w, err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if body.Name == "" {
|
||||
writeError(w, "name is required", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if err := skills.Undeploy(body.Name); err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
writeJSON(w, map[string]string{"status": "undeployed", "skill": body.Name})
|
||||
}
|
||||
|
||||
func (s *Server) handleSSHConnections(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" {
|
||||
writeError(w, "GET only", http.StatusMethodNotAllowed)
|
||||
|
||||
@@ -4,7 +4,6 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -108,6 +107,7 @@ func (s *Server) handleShellChatStream(w http.ResponseWriter, orb *orchestrator.
|
||||
messages := s.buildShellContextMessages()
|
||||
|
||||
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||
engine.SetLimiter(s.AcquireAgentSlot)
|
||||
engine.OnChunk(func(data map[string]interface{}) {
|
||||
if data == nil {
|
||||
return
|
||||
@@ -149,6 +149,7 @@ func (s *Server) handleShellChatNonStream(w http.ResponseWriter, orb *orchestrat
|
||||
messages := s.buildShellContextMessages()
|
||||
|
||||
engine := NewChatEngine(orb, s.shellAgentRegistry, s.shellAgentToolsJSON)
|
||||
engine.SetLimiter(s.AcquireAgentSlot)
|
||||
finalContent, err := engine.RunNonStream(ctx, messages)
|
||||
if err != nil {
|
||||
writeError(w, err.Error(), http.StatusInternalServerError)
|
||||
@@ -185,7 +186,8 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
||||
included := 0
|
||||
tokensUsed := 0
|
||||
for i := len(history) - 1; i >= 0; i-- {
|
||||
msgTokens := utf8.RuneCountInString(history[i].Content) / charsPerToken
|
||||
displayContent := extractDisplayContent(history[i].Role, history[i].Content)
|
||||
msgTokens := utf8.RuneCountInString(displayContent) / charsPerToken
|
||||
if msgTokens == 0 {
|
||||
msgTokens = 1
|
||||
}
|
||||
@@ -202,33 +204,19 @@ func (s *Server) buildShellContextMessages() []orchestrator.Message {
|
||||
}
|
||||
|
||||
if start > 0 {
|
||||
log.Printf("[shell] context budget: %d/%d tokens, including %d/%d messages (dropped %d older)", tokensUsed+overhead, shellMaxTokens, included, len(history), start)
|
||||
_ = start
|
||||
}
|
||||
|
||||
messages := make([]orchestrator.Message, 0, included)
|
||||
|
||||
for _, m := range history[start:] {
|
||||
content := m.Content
|
||||
if m.Role == "assistant" {
|
||||
var parsed struct {
|
||||
Content string `json:"content"`
|
||||
ToolCalls []struct {
|
||||
ToolCallID string `json:"tool_call_id"`
|
||||
Name string `json:"name"`
|
||||
Args string `json:"args"`
|
||||
} `json:"tool_calls"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(content), &parsed); err == nil && parsed.Content != "" {
|
||||
content = parsed.Content
|
||||
}
|
||||
}
|
||||
role := m.Role
|
||||
if role == "system" {
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
displayContent := extractDisplayContent(m.Role, m.Content)
|
||||
messages = append(messages, orchestrator.Message{
|
||||
Role: role,
|
||||
Content: orchestrator.TextContent(content),
|
||||
Role: m.Role,
|
||||
Content: orchestrator.TextContent(displayContent),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
@@ -64,7 +63,7 @@ func cleanupImages(ids []string) {
|
||||
for _, id := range ids {
|
||||
p := imagePath(id)
|
||||
if err := os.Remove(p); err != nil && !os.IsNotExist(err) {
|
||||
log.Printf("[images] failed to delete %s: %v", id, err)
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,15 @@ package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/muyue/muyue/internal/agent"
|
||||
"github.com/muyue/muyue/internal/config"
|
||||
"github.com/muyue/muyue/internal/installer"
|
||||
"github.com/muyue/muyue/internal/scanner"
|
||||
"github.com/muyue/muyue/internal/workflow"
|
||||
)
|
||||
@@ -24,6 +27,8 @@ type Server struct {
|
||||
shellAgentRegistry *agent.Registry
|
||||
shellAgentToolsJSON json.RawMessage
|
||||
workflowEngine *workflow.Engine
|
||||
activeCrushAgents atomic.Int32
|
||||
activeClaudeAgents atomic.Int32
|
||||
}
|
||||
|
||||
func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
@@ -43,7 +48,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
}
|
||||
// Save initial config to establish the file for first-time usage
|
||||
if err := config.Save(defaultCfg); err != nil {
|
||||
log.Printf("config: initial save failed: %v", err)
|
||||
_ = err
|
||||
}
|
||||
cfg = defaultCfg
|
||||
}
|
||||
@@ -65,6 +70,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
|
||||
s.shellAgentToolsJSON = json.RawMessage(shellToolsJSON)
|
||||
|
||||
s.workflowEngine, _ = workflow.NewEngine(s.agentRegistry)
|
||||
s.initStarship()
|
||||
s.routes()
|
||||
return s
|
||||
}
|
||||
@@ -120,6 +126,7 @@ func (s *Server) routes() {
|
||||
s.mux.HandleFunc("/api/conversations/", s.handleDeleteConversation)
|
||||
s.mux.HandleFunc("/api/lsp/install", s.handleLSPInstall)
|
||||
s.mux.HandleFunc("/api/skills/deploy", s.handleSkillsDeploy)
|
||||
s.mux.HandleFunc("/api/skills/undeploy", s.handleSkillsUndeploy)
|
||||
s.mux.HandleFunc("/api/ssh/connections", s.handleSSHConnections)
|
||||
s.mux.HandleFunc("/api/ssh/test", s.handleSSHTest)
|
||||
|
||||
@@ -156,3 +163,37 @@ func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
s.mux.ServeHTTP(w, r)
|
||||
}
|
||||
|
||||
const maxCrushAgents = 2
|
||||
const maxClaudeAgents = 2
|
||||
|
||||
func (s *Server) AcquireAgentSlot(toolName string) (release func(), err error) {
|
||||
var counter *atomic.Int32
|
||||
var max int32
|
||||
switch toolName {
|
||||
case "crush_run":
|
||||
counter = &s.activeCrushAgents
|
||||
max = maxCrushAgents
|
||||
case "claude_run":
|
||||
counter = &s.activeClaudeAgents
|
||||
max = maxClaudeAgents
|
||||
default:
|
||||
return func() {}, nil
|
||||
}
|
||||
current := counter.Add(1)
|
||||
if current > max {
|
||||
counter.Add(-1)
|
||||
return nil, fmt.Errorf("Limite de %d agents %s atteinte", max, toolName)
|
||||
}
|
||||
return func() { counter.Add(-1) }, nil
|
||||
}
|
||||
|
||||
func (s *Server) initStarship() {
|
||||
if _, err := exec.LookPath("starship"); err != nil {
|
||||
inst := installer.New(s.config)
|
||||
if result := inst.InstallTool("starship"); !result.Success {
|
||||
return
|
||||
}
|
||||
}
|
||||
ApplyStarshipTheme(s.config.Terminal.PromptTheme)
|
||||
}
|
||||
|
||||
@@ -147,7 +147,10 @@ func (s *ShellConvStore) ApproxTokens() int {
|
||||
defer s.mu.RUnlock()
|
||||
total := 0
|
||||
for _, m := range s.msgs {
|
||||
total += utf8.RuneCountInString(m.Content) / shellCharsPerToken
|
||||
if m.Role == "system" {
|
||||
continue
|
||||
}
|
||||
total += utf8.RuneCountInString(extractDisplayContent(m.Role, m.Content)) / shellCharsPerToken
|
||||
}
|
||||
total += utf8.RuneCountInString(shellSystemPromptBase) / shellCharsPerToken
|
||||
if analysis := LoadSystemAnalysis(); analysis != "" {
|
||||
|
||||
@@ -3,7 +3,6 @@ package api
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -48,7 +47,6 @@ type wsMessage struct {
|
||||
func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
conn, err := upgrader.Upgrade(w, r, nil)
|
||||
if err != nil {
|
||||
log.Printf("ws upgrade: %v", err)
|
||||
return
|
||||
}
|
||||
defer conn.Close()
|
||||
@@ -56,17 +54,13 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
var initMsg wsMessage
|
||||
_, raw, err := conn.ReadMessage()
|
||||
if err != nil {
|
||||
log.Printf("terminal: read init message failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "failed to read init message"})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: init message received: %s", string(raw))
|
||||
if err := json.Unmarshal(raw, &initMsg); err != nil {
|
||||
log.Printf("terminal: unmarshal init message failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: "invalid init message"})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: init type=%q data=%q", initMsg.Type, initMsg.Data)
|
||||
|
||||
var cmd *exec.Cmd
|
||||
|
||||
@@ -111,24 +105,19 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
} else {
|
||||
shell := strings.TrimSpace(initMsg.Data)
|
||||
log.Printf("terminal: requested shell=%q, trimmed=%q", initMsg.Data, shell)
|
||||
if shell == "" {
|
||||
shell = detectShell()
|
||||
log.Printf("terminal: auto-detected shell=%q", shell)
|
||||
}
|
||||
|
||||
if shell == "" {
|
||||
log.Printf("terminal: no shell detected, falling back to /bin/sh")
|
||||
shell = "/bin/sh"
|
||||
}
|
||||
|
||||
if path, err := exec.LookPath(shell); err == nil {
|
||||
shell = path
|
||||
log.Printf("terminal: resolved shell path=%q", shell)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(shell); err != nil {
|
||||
log.Printf("terminal: shell stat failed: %v for %q", err, shell)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: fmt.Sprintf("shell not found: %s (resolved from: %q)", shell, initMsg.Data)})
|
||||
return
|
||||
}
|
||||
@@ -148,14 +137,11 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
cmd.Env = append(os.Environ(), "TERM=xterm-256color")
|
||||
|
||||
log.Printf("terminal: starting pty with cmd=%q args=%v", cmd.Path, cmd.Args)
|
||||
ptmx, err := pty.Start(cmd)
|
||||
if err != nil {
|
||||
log.Printf("terminal: pty start failed: %v", err)
|
||||
conn.WriteJSON(wsMessage{Type: "error", Data: err.Error()})
|
||||
return
|
||||
}
|
||||
log.Printf("terminal: pty started successfully")
|
||||
|
||||
var once sync.Once
|
||||
cleanup := func() {
|
||||
|
||||
@@ -2,7 +2,6 @@ package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
@@ -162,7 +161,7 @@ func ConfigDir() (string, error) {
|
||||
if _, err := os.Stat(legacyDir); err == nil {
|
||||
if _, err := os.Stat(dir); err != nil {
|
||||
if err := os.Rename(legacyDir, dir); err != nil {
|
||||
log.Printf("config migration: rename %s to %s: %v", legacyDir, dir, err)
|
||||
_ = err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
@@ -17,6 +16,14 @@ import (
|
||||
)
|
||||
|
||||
var thinkRegex = regexp.MustCompile(`(?s)<[Tt]hink[^>]*>.*?</[Tt]hink>`)
|
||||
var providerToolBlockRegex = regexp.MustCompile(`(?s)<[a-zA-Z][a-zA-Z0-9]*:tool_call[^>]*>.*?</[a-zA-Z][a-zA-Z0-9]*:tool_call>`)
|
||||
var providerTagRegex = regexp.MustCompile(`(?s)</?[a-zA-Z][a-zA-Z0-9]*:[a-zA-Z_]+[^>]*>`)
|
||||
var xmlToolTagRegex = regexp.MustCompile(`(?s)</?(invoke|parameter|tool_call|tool_result)[^>]*>`)
|
||||
var bracketToolCallRegex = regexp.MustCompile(`(?m)^\[(?:terminal|shell|bash|command|execute)\]\s*\{[^}]*\}\s*$`)
|
||||
|
||||
var streamBlockStartRegex = regexp.MustCompile(`<[a-zA-Z][a-zA-Z0-9]*:tool_call`)
|
||||
var streamXmlStartRegex = regexp.MustCompile(`<(?:invoke|parameter|tool_call|tool_result)[\s>]`)
|
||||
var streamBracketStartRegex = regexp.MustCompile(`\[(?:terminal|shell|bash|command|execute)\]\s*\{`)
|
||||
|
||||
const maxHistorySize = 100
|
||||
|
||||
@@ -197,7 +204,7 @@ func (o *Orchestrator) Send(userMessage string) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
content := cleanAIResponse(chatResp.Choices[0].Message.Content)
|
||||
content := CleanAIResponse(chatResp.Choices[0].Message.Content)
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "assistant",
|
||||
@@ -297,7 +304,7 @@ func (o *Orchestrator) SendStream(userMessage string, onChunk func(string)) (str
|
||||
return fullContent.String(), fmt.Errorf("read stream: %w", err)
|
||||
}
|
||||
|
||||
content := cleanAIResponse(fullContent.String())
|
||||
content := CleanAIResponse(fullContent.String())
|
||||
o.histMu.Lock()
|
||||
o.history = append(o.history, Message{
|
||||
Role: "assistant",
|
||||
@@ -388,6 +395,7 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
||||
var fullContent strings.Builder
|
||||
var accumulatedToolCalls []ToolCallMsg
|
||||
var totalTokens int
|
||||
var insideToolBlock bool
|
||||
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
@@ -411,7 +419,10 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
||||
chunk := chatResp.Choices[0].Delta.Content
|
||||
if chunk != "" {
|
||||
fullContent.WriteString(chunk)
|
||||
onChunk(chunk, nil)
|
||||
cleanedChunk := CleanStreamChunk(chunk, &insideToolBlock)
|
||||
if cleanedChunk != "" {
|
||||
onChunk(cleanedChunk, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle delta tool calls
|
||||
@@ -463,15 +474,19 @@ func (o *Orchestrator) SendWithToolsStream(messages []Message, onChunk ChunkCall
|
||||
}{},
|
||||
}
|
||||
|
||||
finalContent := cleanAIResponse(fullContent.String())
|
||||
finalContent := CleanAIResponse(fullContent.String())
|
||||
finalResp.Choices[0].Message.Content = finalContent
|
||||
finalResp.Choices[0].Message.ToolCalls = accumulatedToolCalls
|
||||
|
||||
return finalResp, nil
|
||||
}
|
||||
|
||||
func cleanAIResponse(content string) string {
|
||||
func CleanAIResponse(content string) string {
|
||||
content = thinkRegex.ReplaceAllString(content, "")
|
||||
content = providerToolBlockRegex.ReplaceAllString(content, "")
|
||||
content = providerTagRegex.ReplaceAllString(content, "")
|
||||
content = xmlToolTagRegex.ReplaceAllString(content, "")
|
||||
content = bracketToolCallRegex.ReplaceAllString(content, "")
|
||||
lines := strings.Split(content, "\n")
|
||||
var clean []string
|
||||
inBlock := false
|
||||
@@ -494,6 +509,35 @@ func cleanAIResponse(content string) string {
|
||||
return result
|
||||
}
|
||||
|
||||
// CleanStreamChunk applies lightweight cleaning to individual streaming chunks.
|
||||
// It tracks state via a bool pointer to suppress content inside tool-call blocks.
|
||||
func CleanStreamChunk(chunk string, insideBlock *bool) string {
|
||||
if *insideBlock {
|
||||
// Check for closing tag
|
||||
if strings.Contains(chunk, ":tool_call>") {
|
||||
*insideBlock = false
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Check for opening tool_call block
|
||||
if streamBlockStartRegex.MatchString(chunk) {
|
||||
*insideBlock = true
|
||||
// If closing tag also in same chunk, emit nothing
|
||||
if strings.Contains(chunk, ":tool_call>") {
|
||||
*insideBlock = false
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Clean individual tags and bracket calls
|
||||
cleaned := providerTagRegex.ReplaceAllString(chunk, "")
|
||||
cleaned = xmlToolTagRegex.ReplaceAllString(cleaned, "")
|
||||
cleaned = bracketToolCallRegex.ReplaceAllString(cleaned, "")
|
||||
|
||||
return cleaned
|
||||
}
|
||||
|
||||
func getProviderBaseURL(name string) string {
|
||||
switch name {
|
||||
case "minimax":
|
||||
@@ -616,6 +660,5 @@ func (o *Orchestrator) sendWithFallback(reqBody ChatRequest, baseURLOverride str
|
||||
return &chatResp, prov.Name, nil
|
||||
}
|
||||
|
||||
log.Printf("[orchestrator] fallback from %v to next provider", triedProviders)
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ type Skill struct {
|
||||
Dependencies []SkillDependency `yaml:"dependencies,omitempty" json:"dependencies,omitempty"`
|
||||
Languages []string `yaml:"languages,omitempty" json:"languages,omitempty"`
|
||||
Category string `yaml:"category,omitempty" json:"category,omitempty"`
|
||||
Deployed bool `yaml:"-" json:"deployed,omitempty"`
|
||||
}
|
||||
|
||||
type ValidationError struct {
|
||||
@@ -155,6 +156,27 @@ func Delete(name string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsDeployed(name string) bool {
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
crushPath := filepath.Join(home, ".config", "crush", "skills", name, "SKILL.md")
|
||||
claudePath := filepath.Join(home, ".claude", "skills", name, "SKILL.md")
|
||||
_, crushErr := os.Stat(crushPath)
|
||||
_, claudeErr := os.Stat(claudePath)
|
||||
return crushErr == nil || claudeErr == nil
|
||||
}
|
||||
|
||||
func Undeploy(name string) error {
|
||||
skill, err := Get(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
undeployFromTargets(skill.Name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func Update(skill *Skill) error {
|
||||
if errs := Validate(skill); len(errs) > 0 {
|
||||
return fmt.Errorf("validation failed: %v", errs)
|
||||
|
||||
Reference in New Issue
Block a user