From b90a6d12ea5b135e55b583464308caf4d0910c34 Mon Sep 17 00:00:00 2001 From: Badgerbees Date: Wed, 1 Apr 2026 03:13:34 +0700 Subject: [PATCH 01/16] =?UTF-8?q?=EF=BB=BFfix(telegram):=20refine=20duplic?= =?UTF-8?q?ate-message=20protection=20with=20narrow=20error=20classificati?= =?UTF-8?q?on?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses reviewer concerns regarding silent message loss by narrowing the error swallowing logic in EditMessage: - Excludes context.DeadlineExceeded and context.Canceled from being swallowed, ensuring local timeouts before transmission still trigger a fallback send. - Adds an explicit check for the 'message is not modified' error to safely identify edits that have already landed on Telegram's servers. - Narrowly targets confirmed post-connect dropouts (e.g., connection reset) instead of broad network-ish string matching. - Fixes the missing isPostConnectError definition and required errors import. --- pkg/channels/telegram/telegram.go | 59 +++++++++++++++++++++++++++++-- 1 file changed, 57 insertions(+), 2 deletions(-) diff --git a/pkg/channels/telegram/telegram.go b/pkg/channels/telegram/telegram.go index 831eb43cc..c1097bf04 100644 --- a/pkg/channels/telegram/telegram.go +++ b/pkg/channels/telegram/telegram.go @@ -4,6 +4,7 @@ import ( "context" "crypto/rand" "encoding/binary" + "errors" "fmt" "io" "net/http" @@ -377,8 +378,38 @@ func (c *TelegramChannel) EditMessage(ctx context.Context, chatID string, messag } _, err = c.bot.EditMessageText(ctx, editMsg) if err != nil { - logParseFailed(err, useMarkdownV2) - _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content)) + // If it failed because it was already modified (likely from a previous + // attempt that timed out on our end but landed on Telegram), we treat + // it as success to prevent the Manager from sending a duplicate message. + if strings.Contains(err.Error(), "message is not modified") { + return nil + } + + // Only fallback to plain text if the error looks like a parsing failure (Bad Request). + // Network errors or timeouts should NOT trigger a retry with different content. + if strings.Contains(err.Error(), "Bad Request") { + logParseFailed(err, useMarkdownV2) + _, err = c.bot.EditMessageText(ctx, tu.EditMessageText(tu.ID(cid), mid, content)) + } + } + + if err != nil { + if strings.Contains(err.Error(), "message is not modified") { + return nil + } + + if isPostConnectError(err) { + logger.WarnCF( + "telegram", + "EditMessage likely landed but result is unknown; swallowing error to prevent duplicate", + map[string]any{ + "chat_id": chatID, + "mid": mid, + "error": err.Error(), + }, + ) + return nil // Swallow to prevent Manager fallback to a new SendMessage + } } return err @@ -1133,3 +1164,27 @@ func cryptoRandInt() int { _, _ = rand.Read(b[:]) return int(binary.BigEndian.Uint32(b[:])) | 1 // ensure non-zero } + +// isPostConnectError identifies network errors that likely occurred after +// the request was transmitted to Telegram (e.g. dropped connection while +// waiting for response). Swallowing these for edits prevents duplicate +// fallbacks, at the small risk of leaving a stale placeholder if the +// edit never actually reached the server. +func isPostConnectError(err error) bool { + if err == nil { + return false + } + + // Context errors (timeout/canceled) are too broad; they can be triggered + // locally before any data is sent. Never swallow them. + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return false + } + + msg := strings.ToLower(err.Error()) + // Narrowly target connection dropouts where the request likely landed. + return strings.Contains(msg, "connection reset by peer") || + strings.Contains(msg, "unexpected eof") || + strings.Contains(msg, "connection closed by foreign host") || + strings.Contains(msg, "broken pipe") +} From 31afad6e87b48d7d863ffd39d8b42ed6da762d0c Mon Sep 17 00:00:00 2001 From: reusu Date: Wed, 1 Apr 2026 21:32:10 +0800 Subject: [PATCH 02/16] feat: add load_image tool for local file vision (#2116) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: add load_image tool for local file vision * fix: address load_image PR review feedback - Exclude load_image from sub-agent tools via Unregister after Clone, since RunToolLoop does not call resolveMediaRefs - Add ToolRegistry.Unregister() method - Fix scope collision: use channel:chatID instead of filename - Add channel/chatID context resolution matching send_file pattern - Add comment explaining iteration > 1 guard on resolveMediaRefs - Remove emoji from ForUser for consistency with send_file - Add load_image_test.go * feat: enable load_image for subagents via MediaResolver in RunToolLoop Instead of removing load_image from sub-agent tools (28f69e71), inject a MediaResolver into the legacy RunToolLoop fallback path so media:// refs are resolved to base64 before each LLM call — matching the main agent loop behavior. - Add MediaResolver field to ToolLoopConfig and call it on iteration > 1 - Add SubagentManager.SetMediaResolver() and wire it through runTask - Remove ToolRegistry.Unregister() (no longer needed) - Restore load_image in sub-agent tool set (revert Clone+Unregister) - Add TestSubagentManager_SetMediaResolver_StoresResolver * refactor(load_image): remove prompt parameter from tool schema * test(tools): add success-path test for LoadImageTool Add TestLoadImage_SuccessPath that creates a real PNG file with valid magic bytes, calls Execute with WithToolContext, and verifies: - result.IsError == false - ToolResult.Media contains a media:// ref - ToolResult.ForLLM contains the [image: marker - media ref is resolvable in the store Add explanatory comment in loop.go for why Media and ArtifactTags coexist on non-ResponseHandled tool results (e.g. load_image). * fix: preallocate slice in tests and add ResponseHandled guard in toolloop Fix prealloc linter failure in load_image_test.go. Prevent double-resolving media by checking ResponseHandled in toolloop.go. * Register TTS tool if provider is available --------- Co-authored-by: Reusu Co-authored-by: 美電球 --- pkg/agent/loop.go | 37 ++++++++ pkg/tools/load_image.go | 163 ++++++++++++++++++++++++++++++++ pkg/tools/load_image_test.go | 174 +++++++++++++++++++++++++++++++++++ pkg/tools/subagent.go | 19 ++++ pkg/tools/toolloop.go | 36 +++++++- 5 files changed, 425 insertions(+), 4 deletions(-) create mode 100644 pkg/tools/load_image.go create mode 100644 pkg/tools/load_image_test.go diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index b376ed0af..19c1f0369 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -281,6 +281,17 @@ func registerSharedTools( agent.Tools.Register(tools.NewSendTTSTool(ttsProvider, nil)) } + if cfg.Tools.IsToolEnabled("load_image") { + loadImageTool := tools.NewLoadImageTool( + agent.Workspace, + cfg.Agents.Defaults.RestrictToWorkspace, + cfg.Agents.Defaults.GetMaxMediaSize(), + nil, + allowReadPaths, + ) + agent.Tools.Register(loadImageTool) + } + // Skill discovery and installation tools skills_enabled := cfg.Tools.IsToolEnabled("skills") find_skills_enable := cfg.Tools.IsToolEnabled("find_skills") @@ -323,6 +334,14 @@ func registerSharedTools( subagentManager := tools.NewSubagentManager(provider, agent.Model, agent.Workspace) subagentManager.SetLLMOptions(agent.MaxTokens, agent.Temperature) + // Inject a media resolver so the legacy RunToolLoop fallback path can + // resolve media:// refs in the same way the main AgentLoop does. + // This keeps subagent vision support working even when the optimized + // sub-turn spawner path is unavailable. + subagentManager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + return resolveMediaRefs(msgs, al.mediaStore, cfg.Agents.Defaults.GetMaxMediaSize()) + }) + // Set the spawner that links into AgentLoop's turnState subagentManager.SetSpawner(func( ctx context.Context, @@ -1861,6 +1880,14 @@ turnLoop: providerToolDefs = filtered } + // Resolve media:// refs produced by tool results (e.g. load_image). + // Skipped on iteration 1 because inbound user media is already resolved + // before entering the loop; only subsequent iterations can contain new + // tool-generated media refs that need base64 encoding. + if iteration > 1 { + messages = resolveMediaRefs(messages, al.mediaStore, maxMediaSize) + } + callMessages := messages if gracefulTerminal { callMessages = append(append([]providers.Message(nil), messages...), ts.interruptHintMessage()) @@ -2551,6 +2578,13 @@ turnLoop: } if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + // For tools like load_image that produce media refs without sending them + // to the user channel (ResponseHandled == false), both Media and ArtifactTags + // coexist on the result: + // - Media: carries media:// refs that resolveMediaRefs will base64-encode + // into image_url parts in the next LLM iteration (enabling vision). + // - ArtifactTags: exposes the local file path as a structured [file:…] tag + // in the tool result text, so the LLM knows an artifact was produced. toolResult.ArtifactTags = buildArtifactTags(al.mediaStore, toolResult.Media) } @@ -2570,6 +2604,9 @@ turnLoop: Content: contentForLLM, ToolCallID: toolCallID, } + if len(toolResult.Media) > 0 && !toolResult.ResponseHandled { + toolResultMsg.Media = append(toolResultMsg.Media, toolResult.Media...) + } al.emitEvent( EventKindToolExecEnd, ts.eventMeta("runTurn", "turn.tool.end"), diff --git a/pkg/tools/load_image.go b/pkg/tools/load_image.go new file mode 100644 index 000000000..41ea6d054 --- /dev/null +++ b/pkg/tools/load_image.go @@ -0,0 +1,163 @@ +package tools + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" +) + +// LoadImageTool loads a local image file into the MediaStore and returns a +// media:// reference. The agent loop's resolveMediaRefs will then base64-encode +// it and attach it as an image_url part in the next LLM request, enabling +// vision on local files — the same pipeline used when a user sends an image +// through a chat channel. +// +// This is intentionally different from SendFileTool: +// - SendFileTool → MediaResult + WithResponseHandled() → sends file to user, ends turn +// - LoadImageTool → plain ToolResult with media:// in ForLLM → LLM sees the image next turn +type LoadImageTool struct { + workspace string + restrict bool + maxFileSize int + mediaStore media.MediaStore + allowPaths []*regexp.Regexp + + defaultChannel string + defaultChatID string +} + +func NewLoadImageTool( + workspace string, + restrict bool, + maxFileSize int, + store media.MediaStore, + allowPaths ...[]*regexp.Regexp, +) *LoadImageTool { + if maxFileSize <= 0 { + maxFileSize = config.DefaultMaxMediaSize + } + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + return &LoadImageTool{ + workspace: workspace, + restrict: restrict, + maxFileSize: maxFileSize, + mediaStore: store, + allowPaths: patterns, + } +} + +func (t *LoadImageTool) Name() string { return "load_image" } + +func (t *LoadImageTool) Description() string { + return "Load a local image file so you can analyze its contents with vision. " + + "Supported formats: JPEG, PNG, GIF, WebP, BMP. " + + "After calling this tool, describe or analyze the image in your next response." +} + +func (t *LoadImageTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the local image file. Relative paths are resolved from workspace.", + }, + }, + "required": []string{"path"}, + } +} + +func (t *LoadImageTool) SetContext(channel, chatID string) { + t.defaultChannel = channel + t.defaultChatID = chatID +} + +func (t *LoadImageTool) SetMediaStore(store media.MediaStore) { + t.mediaStore = store +} + +func (t *LoadImageTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, _ := args["path"].(string) + if strings.TrimSpace(path) == "" { + return ErrorResult("path is required") + } + + // Prefer context-injected channel/chatID (set by ExecuteWithContext), fall back to SetContext values. + channel := ToolChannel(ctx) + if channel == "" { + channel = t.defaultChannel + } + chatID := ToolChatID(ctx) + if chatID == "" { + chatID = t.defaultChatID + } + if channel == "" || chatID == "" { + return ErrorResult("no target channel/chat available") + } + + if t.mediaStore == nil { + return ErrorResult("media store not configured") + } + + resolved, err := validatePathWithAllowPaths(path, t.workspace, t.restrict, t.allowPaths) + if err != nil { + return ErrorResult(fmt.Sprintf("invalid path: %v", err)) + } + + info, err := os.Stat(resolved) + if err != nil { + return ErrorResult(fmt.Sprintf("file not found: %v", err)) + } + if info.IsDir() { + return ErrorResult("path is a directory, expected an image file") + } + if info.Size() > int64(t.maxFileSize) { + return ErrorResult(fmt.Sprintf( + "file too large: %d bytes (max %d bytes)", info.Size(), t.maxFileSize, + )) + } + + // Detect MIME type — reuse the helper already in send_file.go + mediaType := detectMediaType(resolved) + if !strings.HasPrefix(mediaType, "image/") { + return ErrorResult(fmt.Sprintf( + "file does not appear to be an image (detected type: %s)", mediaType, + )) + } + + filename := filepath.Base(resolved) + scope := fmt.Sprintf("tool:load_image:%s:%s", channel, chatID) + + ref, err := t.mediaStore.Store(resolved, media.MediaMeta{ + Filename: filename, + ContentType: mediaType, + Source: "tool:load_image", + CleanupPolicy: media.CleanupPolicyForgetOnly, + }, scope) + if err != nil { + return ErrorResult(fmt.Sprintf("failed to register image in media store: %v", err)) + } + + // Build the tool result text. The media:// ref will be picked up by + // resolveMediaRefs in loop_media.go and converted to a base64 data URL + // before the next LLM call, exactly like channel-received images. + msg := fmt.Sprintf("Image loaded: %s\n[image: %s]", filename, ref) + + return &ToolResult{ + ForLLM: msg, + ForUser: fmt.Sprintf("Loaded image: %s", filename), + // Media refs inside ForLLM are resolved by resolveMediaRefs in the + // agent loop before the next LLM call. Do NOT use MediaResult here — + // that would send the file to the user channel instead. + Media: []string{ref}, + } +} diff --git a/pkg/tools/load_image_test.go b/pkg/tools/load_image_test.go new file mode 100644 index 000000000..91118f93e --- /dev/null +++ b/pkg/tools/load_image_test.go @@ -0,0 +1,174 @@ +package tools + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/media" + "github.com/sipeed/picoclaw/pkg/providers" +) + +func TestLoadImage_PathRequired(t *testing.T) { + tool := NewLoadImageTool("/tmp", false, 0, nil) + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{}) + if !result.IsError { + t.Fatal("expected error for missing path") + } +} + +func TestLoadImage_NilMediaStore(t *testing.T) { + tool := NewLoadImageTool("/tmp", false, 0, nil) + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{"path": "test.png"}) + if !result.IsError || result.ForLLM != "media store not configured" { + t.Fatalf("expected media store error, got: %s", result.ForLLM) + } +} + +func TestLoadImage_NoChannelContext(t *testing.T) { + store := media.NewFileMediaStore() + tool := NewLoadImageTool("/tmp", false, 0, store) + // No WithToolContext — should fail + result := tool.Execute(context.Background(), map[string]any{"path": "test.png"}) + if !result.IsError || result.ForLLM != "no target channel/chat available" { + t.Fatalf("expected channel error, got: %s", result.ForLLM) + } +} + +func TestLoadImage_NonImageFile(t *testing.T) { + dir := t.TempDir() + txtFile := filepath.Join(dir, "readme.txt") + os.WriteFile(txtFile, []byte("hello"), 0o644) + + store := media.NewFileMediaStore() + tool := NewLoadImageTool(dir, false, 0, store) + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{"path": txtFile}) + if !result.IsError { + t.Fatal("expected error for non-image file") + } +} + +func TestLoadImage_DefaultMaxSize(t *testing.T) { + tool := NewLoadImageTool("/tmp", false, 0, nil) + if tool.maxFileSize != config.DefaultMaxMediaSize { + t.Errorf("expected default max size %d, got %d", config.DefaultMaxMediaSize, tool.maxFileSize) + } +} + +func TestLoadImage_FileTooLarge(t *testing.T) { + dir := t.TempDir() + bigFile := filepath.Join(dir, "big.png") + // Create a file with PNG header but exceeding max size + data := make([]byte, 1024) + copy(data, []byte{0x89, 0x50, 0x4E, 0x47}) // PNG magic bytes + os.WriteFile(bigFile, data, 0o644) + + store := media.NewFileMediaStore() + tool := NewLoadImageTool(dir, false, 512, store) // maxSize = 512 + ctx := WithToolContext(context.Background(), "test", "chat1") + result := tool.Execute(ctx, map[string]any{"path": bigFile}) + if !result.IsError { + t.Fatal("expected error for oversized file") + } +} + +func TestSubagentManager_SetMediaResolver_StoresResolver(t *testing.T) { + manager := NewSubagentManager(nil, "gpt-test", "/tmp") + + called := false + manager.SetMediaResolver(func(msgs []providers.Message) []providers.Message { + called = true + return msgs + }) + + manager.mu.RLock() + got := manager.mediaResolver + manager.mu.RUnlock() + + if got == nil { + t.Fatal("expected mediaResolver to be set") + } + + if called { + t.Fatal("resolver should not be called during SetMediaResolver") + } +} + +func TestLoadImage_SuccessPath(t *testing.T) { + dir := t.TempDir() + + // Create a minimal valid PNG file (8-byte signature + minimal IHDR + IEND). + // The PNG spec requires the 8-byte magic header: 0x89 P N G \r \n 0x1a \n + pngSignature := []byte{0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A} + // IHDR chunk: length(13) + "IHDR" + 1x1 px, 8-bit RGB, no interlace + CRC + ihdr := []byte{ + 0x00, 0x00, 0x00, 0x0D, // chunk length = 13 + 0x49, 0x48, 0x44, 0x52, // "IHDR" + 0x00, 0x00, 0x00, 0x01, // width = 1 + 0x00, 0x00, 0x00, 0x01, // height = 1 + 0x08, // bit depth = 8 + 0x02, // color type = RGB + 0x00, 0x00, 0x00, // compression, filter, interlace + 0x90, 0x77, 0x53, 0xDE, // CRC (valid for this IHDR) + } + // IEND chunk + iend := []byte{ + 0x00, 0x00, 0x00, 0x00, // chunk length = 0 + 0x49, 0x45, 0x4E, 0x44, // "IEND" + 0xAE, 0x42, 0x60, 0x82, // CRC + } + + pngData := make([]byte, 0, len(pngSignature)+len(ihdr)+len(iend)) + pngData = append(pngData, pngSignature...) + pngData = append(pngData, ihdr...) + pngData = append(pngData, iend...) + + imgPath := filepath.Join(dir, "test_image.png") + if err := os.WriteFile(imgPath, pngData, 0o644); err != nil { + t.Fatalf("failed to create test PNG: %v", err) + } + + store := media.NewFileMediaStore() + tool := NewLoadImageTool(dir, false, 0, store) + ctx := WithToolContext(context.Background(), "test", "chat1") + + result := tool.Execute(ctx, map[string]any{"path": imgPath}) + + // 1. Must not be an error + if result.IsError { + t.Fatalf("expected success, got error: %s", result.ForLLM) + } + + // 2. Media must contain exactly one media:// ref + if len(result.Media) != 1 { + t.Fatalf("expected 1 media ref, got %d", len(result.Media)) + } + if !strings.HasPrefix(result.Media[0], "media://") { + t.Errorf("expected media ref to start with 'media://', got: %s", result.Media[0]) + } + + // 3. ForLLM must contain the [image: marker + if !strings.Contains(result.ForLLM, "[image:") { + t.Errorf("expected ForLLM to contain '[image:' marker, got: %s", result.ForLLM) + } + + // 4. ForLLM should also contain the media:// ref + if !strings.Contains(result.ForLLM, result.Media[0]) { + t.Errorf("expected ForLLM to contain media ref %q, got: %s", result.Media[0], result.ForLLM) + } + + // 5. Verify the ref is resolvable in the store + resolved, err := store.Resolve(result.Media[0]) + if err != nil { + t.Fatalf("media ref not resolvable: %v", err) + } + if resolved != imgPath { + t.Errorf("expected resolved path %q, got %q", imgPath, resolved) + } +} diff --git a/pkg/tools/subagent.go b/pkg/tools/subagent.go index 9a1a8b802..ada89efb7 100644 --- a/pkg/tools/subagent.go +++ b/pkg/tools/subagent.go @@ -67,6 +67,12 @@ type SubagentManager struct { hasTemperature bool nextID int spawner SpawnSubTurnFunc + + // mediaResolver resolves media:// refs in tool-loop messages before + // each LLM call in the legacy RunToolLoop fallback path. + // This lets subagents reuse the same media handling behavior as the + // main agent loop without importing pkg/agent and creating a cycle. + mediaResolver func([]providers.Message) []providers.Message } func NewSubagentManager( @@ -90,6 +96,17 @@ func (sm *SubagentManager) SetSpawner(spawner SpawnSubTurnFunc) { sm.spawner = spawner } +// SetMediaResolver injects a message preprocessor that resolves media:// refs +// into LLM-ready content before each tool-loop iteration. +// This is only used by the legacy RunToolLoop fallback path. +func (sm *SubagentManager) SetMediaResolver( + resolver func([]providers.Message) []providers.Message, +) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.mediaResolver = resolver +} + // SetLLMOptions sets max tokens and temperature for subagent LLM calls. func (sm *SubagentManager) SetLLMOptions(maxTokens int, temperature float64) { sm.mu.Lock() @@ -177,6 +194,7 @@ func (sm *SubagentManager) runTask( temperature := sm.temperature hasMaxTokens := sm.hasMaxTokens hasTemperature := sm.hasTemperature + mediaResolver := sm.mediaResolver sm.mu.RUnlock() var result *ToolResult @@ -223,6 +241,7 @@ After completing the task, provide a clear summary of what was done.` Tools: tools, MaxIterations: maxIter, LLMOptions: llmOptions, + MediaResolver: mediaResolver, }, messages, task.OriginChannel, task.OriginChatID) if err == nil { diff --git a/pkg/tools/toolloop.go b/pkg/tools/toolloop.go index 387813e94..ac568f598 100644 --- a/pkg/tools/toolloop.go +++ b/pkg/tools/toolloop.go @@ -24,6 +24,11 @@ type ToolLoopConfig struct { Tools *ToolRegistry MaxIterations int LLMOptions map[string]any + + // MediaResolver resolves media:// refs in messages before each LLM call. + // This is optional and is mainly used by subagent legacy fallback execution + // so subagents can reuse the same multimodal media handling as the main loop. + MediaResolver func(messages []providers.Message) []providers.Message } // ToolLoopResult contains the result of running the tool loop. @@ -63,8 +68,27 @@ func RunToolLoop( if llmOpts == nil { llmOpts = map[string]any{} } - // 3. Call LLM - response, err := config.Provider.Chat(ctx, messages, providerToolDefs, config.Model, llmOpts) + + // 3. Resolve media:// refs and Call LLM. + // Tools like load_image produce media:// refs in their result messages. + // Without this step, the LLM would receive raw "media://uuid" strings + // instead of base64-encoded image data URLs. + // + // We build a separate callMessages slice so that: + // (a) the resolver output is used for the LLM call only, + // (b) the original `messages` slice keeps the unresolved refs for + // subsequent iterations — the resolver is idempotent but working + // on the original avoids double-encoding issues. + // + // On iteration 1 the initial user messages typically have no media:// + // refs (they come from plain text), so this is effectively a no-op; + // it becomes relevant from iteration 2 onward when tool results may + // contain media refs. + callMessages := messages + if config.MediaResolver != nil && iteration > 1 { + callMessages = config.MediaResolver(messages) + } + response, err := config.Provider.Chat(ctx, callMessages, providerToolDefs, config.Model, llmOpts) if err != nil { logger.ErrorCF("toolloop", "LLM call failed", map[string]any{ @@ -161,11 +185,15 @@ func RunToolLoop( for _, r := range results { contentForLLM := r.result.ContentForLLM() - messages = append(messages, providers.Message{ + toolMsg := providers.Message{ Role: "tool", Content: contentForLLM, ToolCallID: r.tc.ID, - }) + } + if len(r.result.Media) > 0 && !r.result.ResponseHandled { + toolMsg.Media = append(toolMsg.Media, r.result.Media...) + } + messages = append(messages, toolMsg) } } From e2a9bb97c72f0852a19c847c142ace1120944bbc Mon Sep 17 00:00:00 2001 From: Cytown Date: Wed, 1 Apr 2026 23:26:49 +0800 Subject: [PATCH 03/16] unify all panic event to panic log file (#2250) --- pkg/agent/loop.go | 1 + pkg/agent/subturn.go | 1 + pkg/channels/discord/voice.go | 1 + pkg/logger/panic.go | 40 ++++++++++++++++++++-------- pkg/tools/registry.go | 1 + web/backend/middleware/middleware.go | 1 + 6 files changed, 34 insertions(+), 11 deletions(-) diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 19c1f0369..15535e138 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -991,6 +991,7 @@ func (al *AgentLoop) ReloadProviderAndConfig( go func() { defer func() { if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) panicErr = fmt.Errorf("panic during registry creation: %v", r) logger.ErrorCF("agent", "Panic during registry creation", map[string]any{"panic": r}) diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index f5ba412ab..82a2a2010 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -427,6 +427,7 @@ func spawnSubTurn( // 7. Defer cleanup: deliver result (for async), emit End event, and recover from panics defer func() { if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) err = fmt.Errorf("subturn panicked: %v", r) result = nil logger.ErrorCF("subturn", "SubTurn panicked", map[string]any{ diff --git a/pkg/channels/discord/voice.go b/pkg/channels/discord/voice.go index 5b686b141..554b8ae71 100644 --- a/pkg/channels/discord/voice.go +++ b/pkg/channels/discord/voice.go @@ -120,6 +120,7 @@ func streamOggOpusToDiscord(ctx context.Context, vc *discordgo.VoiceConnection, defer func() { if rec := recover(); rec != nil { retErr = fmt.Errorf("voice connection closed during playback") + logger.RecoverPanicNoExit(rec) } }() diff --git a/pkg/logger/panic.go b/pkg/logger/panic.go index e53e4351a..0a9125dda 100644 --- a/pkg/logger/panic.go +++ b/pkg/logger/panic.go @@ -2,12 +2,15 @@ package logger import ( "fmt" + "io" "os" "path/filepath" "runtime/debug" "time" ) +var panicWriter io.WriteCloser + func InitPanic(filePath string) (func(), error) { if err := os.MkdirAll(filepath.Dir(filePath), 0o755); err != nil { return nil, fmt.Errorf("failed to create log directory: %w", err) @@ -16,21 +19,36 @@ func InitPanic(filePath string) (func(), error) { if writer == nil { return nil, fmt.Errorf("failed to create log file: %s", filePath) } + if panicWriter != nil { + _ = panicWriter.Close() + } + panicWriter = writer return func() { - defer writer.Close() + defer func() { + writer.Close() + panicWriter = nil + }() if err := recover(); err != nil { - now := time.Now().Format("2006-01-02 15:04:05") - stack := debug.Stack() - logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf( - "%v", - err, - ) + "\n" + string( - stack, - ) - - writer.Write([]byte(logMsg)) + RecoverPanicNoExit(err) os.Exit(1) } }, nil } + +func RecoverPanicNoExit(err any) { + if panicWriter == nil { + Errorf("panicWriter is nil, should not happen") + return + } + now := time.Now().Format("2006-01-02 15:04:05") + stack := debug.Stack() + logMsg := "\n\n====================\n[" + now + "] PANIC OCCURRED: " + fmt.Sprintf( + "%v", + err, + ) + "\n" + string( + stack, + ) + + panicWriter.Write([]byte(logMsg)) +} diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index 56af8d695..e51dff71a 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -228,6 +228,7 @@ func (r *ToolRegistry) ExecuteWithContext( func() { defer func() { if re := recover(); re != nil { + logger.RecoverPanicNoExit(re) errMsg := fmt.Sprintf("Tool '%s' crashed with panic: %v", name, re) logger.ErrorCF("tool", "Tool execution panic recovered", map[string]any{ diff --git a/web/backend/middleware/middleware.go b/web/backend/middleware/middleware.go index a0b7eb998..f9eb3149d 100644 --- a/web/backend/middleware/middleware.go +++ b/web/backend/middleware/middleware.go @@ -71,6 +71,7 @@ func Recoverer(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if err := recover(); err != nil { + logger.RecoverPanicNoExit(err) logger.ErrorC("http", fmt.Sprintf("panic recovered: %v\n%s", err, debug.Stack())) http.Error(w, `{"error":"internal server error"}`, http.StatusInternalServerError) } From 49e61fa07f07abdd57b96fbb0c40b64702d21868 Mon Sep 17 00:00:00 2001 From: sky5454 Date: Wed, 1 Apr 2026 23:41:32 +0800 Subject: [PATCH 04/16] feat(updater): robust self-update selection & extraction (nightly default) (#2201) * feat(updater): add web self-update endpoint and updater package * feat(selfupgrade): when url empty, using GetTestReleaseAPIURL for test . * feat(selfupgrade): only GetTestReleaseAPIURL . * feat(upgrade): cli $0 update work well! * fix(ci): fix ci err * fix(test): fix ci test * fix(ci): fix ci lint fmt err * test(updater): add test for updater * fix(ci): fix ci lint var copy err * fix(ci): retry ci * updater: require checksum verification, prefer API digest, verify SHA256, fix zip extraction, update tests * fix(lint): lint fixed * fix(lint): lint fixed2 * updater: stream download and verify sha256; add http client timeout and progress Avoid double-download by streaming asset into temp file while computing SHA256 and verifying against checksum; replace http.Get with shared httpClient (2m timeout) to prevent hangs; add simple stderr progress display; remove unused helpers. --- cmd/picoclaw/main.go | 2 + cmd/picoclaw/main_test.go | 1 + go.mod | 2 + go.sum | 11 + pkg/updater/updater.go | 707 ++++++++++++++++++++++++++++++++++++ pkg/updater/updater_test.go | 97 +++++ web/backend/api/router.go | 3 + web/backend/api/update.go | 52 +++ 8 files changed, 875 insertions(+) create mode 100644 pkg/updater/updater.go create mode 100644 pkg/updater/updater_test.go create mode 100644 web/backend/api/update.go diff --git a/cmd/picoclaw/main.go b/cmd/picoclaw/main.go index bf9c0389f..48dffbb33 100644 --- a/cmd/picoclaw/main.go +++ b/cmd/picoclaw/main.go @@ -24,6 +24,7 @@ import ( "github.com/sipeed/picoclaw/cmd/picoclaw/internal/status" "github.com/sipeed/picoclaw/cmd/picoclaw/internal/version" "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/updater" ) func NewPicoclawCommand() *cobra.Command { @@ -45,6 +46,7 @@ func NewPicoclawCommand() *cobra.Command { migrate.NewMigrateCommand(), skills.NewSkillsCommand(), model.NewModelCommand(), + updater.NewUpdateCommand("picoclaw"), version.NewVersionCommand(), ) diff --git a/cmd/picoclaw/main_test.go b/cmd/picoclaw/main_test.go index ad18cb330..cb221dece 100644 --- a/cmd/picoclaw/main_test.go +++ b/cmd/picoclaw/main_test.go @@ -43,6 +43,7 @@ func TestNewPicoclawCommand(t *testing.T) { "onboard", "skills", "status", + "update", "version", } diff --git a/go.mod b/go.mod index 5f311306e..3fa15b427 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/h2non/filetype v1.1.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 github.com/mdp/qrterminal/v3 v3.2.1 + github.com/minio/selfupdate v0.6.0 github.com/modelcontextprotocol/go-sdk v1.4.1 github.com/mymmrac/telego v1.7.0 github.com/open-dingtalk/dingtalk-stream-sdk-go v0.9.1 @@ -48,6 +49,7 @@ require ( ) require ( + aead.dev/minisign v0.2.0 // indirect filippo.io/edwards25519 v1.2.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect github.com/aws/aws-sdk-go-v2/credentials v1.19.12 // indirect diff --git a/go.sum b/go.sum index ca5dd0423..c1fef5983 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,5 @@ +aead.dev/minisign v0.2.0 h1:kAWrq/hBRu4AARY6AlciO83xhNnW9UaC8YipS2uhLPk= +aead.dev/minisign v0.2.0/go.mod h1:zdq6LdSd9TbuSxchxwhpA9zEb9YXcVGoE8JakuiGaIQ= cloud.google.com/go/compute/metadata v0.3.0/go.mod h1:zFmK7XCadkQkj6TtorcaGlCW1hT1fIilQDwofLpJ20k= filippo.io/edwards25519 v1.2.0 h1:crnVqOiS4jqYleHd9vaKZ+HKtHfllngJIiOpNpoJsjo= filippo.io/edwards25519 v1.2.0/go.mod h1:xzAOLCNug/yB62zG1bQ8uziwrIqIuxhctzJT18Q77mc= @@ -184,6 +186,8 @@ github.com/mattn/go-sqlite3 v1.14.34 h1:3NtcvcUnFBPsuRcno8pUtupspG/GM+9nZ88zgJcp github.com/mattn/go-sqlite3 v1.14.34/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/mdp/qrterminal/v3 v3.2.1 h1:6+yQjiiOsSuXT5n9/m60E54vdgFsw0zhADHhHLrFet4= github.com/mdp/qrterminal/v3 v3.2.1/go.mod h1:jOTmXvnBsMy5xqLniO0R++Jmjs2sTm9dFSuQ5kpz/SU= +github.com/minio/selfupdate v0.6.0 h1:i76PgT0K5xO9+hjzKcacQtO7+MjJ4JKA8Ak8XQ9DDwU= +github.com/minio/selfupdate v0.6.0/go.mod h1:bO02GTIPCMQFTEvE5h4DjYB58bCoZ35XLeBf0buTDdM= github.com/modelcontextprotocol/go-sdk v1.4.1 h1:M4x9GyIPj+HoIlHNGpK2hq5o3BFhC+78PkEaldQRphc= github.com/modelcontextprotocol/go-sdk v1.4.1/go.mod h1:Bo/mS87hPQqHSRkMv4dQq1XCu6zv4INdXnFZabkNU6s= github.com/mymmrac/telego v1.7.0 h1:yRO/l00tFGG4nY66ufUKb4ARqv7qx9+LsjQv/b0NEyo= @@ -308,7 +312,10 @@ golang.org/x/arch v0.24.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= +golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20211209193657-4570a0811e8b/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= golang.org/x/crypto v0.16.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= @@ -329,6 +336,7 @@ golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM= golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= @@ -351,11 +359,13 @@ golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5h golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210228012217-479acdf4ea46/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= @@ -369,6 +379,7 @@ golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= diff --git a/pkg/updater/updater.go b/pkg/updater/updater.go new file mode 100644 index 000000000..e73c1e859 --- /dev/null +++ b/pkg/updater/updater.go @@ -0,0 +1,707 @@ +package updater + +import ( + "archive/tar" + "archive/zip" + "compress/gzip" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + "time" + + "github.com/minio/selfupdate" + "github.com/spf13/cobra" + + "github.com/sipeed/picoclaw/pkg/config" +) + +// httpClient is a shared HTTP client used for release checks and downloads. +// The Timeout value applies to the entire HTTP request: dialing, TLS +// handshake, redirects, and reading the response body. It is NOT only +// a connection (dial) timeout. To control lower-level timeouts (dial, +// TLS handshake, response header wait), supply a custom Transport with +// an appropriately configured net.Dialer. +var httpClient = &http.Client{Timeout: 2 * time.Minute} + +// DownloadAndExtractRelease downloads a release archive (or uses a direct +// asset URL) and extracts it to a temporary directory. It returns the +// extraction directory on success. If releaseURL is empty, the latest +// release of the current project is used. platform/arch can be used to +// select the correct asset (e.g. "linux", "amd64"). +func DownloadAndExtractRelease(releaseURL, platform, arch string) (string, error) { + assetURL, checksum, err := findAssetInfo(releaseURL, platform, arch) + if err != nil { + return "", err + } + + // Download asset to temp file. Use the asset URL extension so + // extractArchive can detect the archive format (zip/tar.gz/tar). + tmpPattern := "picoclaw-release-*" + if u, perr := url.Parse(assetURL); perr == nil { + base := filepath.Base(u.Path) + lbase := strings.ToLower(base) + switch { + case strings.HasSuffix(lbase, ".zip"): + tmpPattern += ".zip" + case strings.HasSuffix(lbase, ".tar.gz") || strings.HasSuffix(lbase, ".tgz"): + tmpPattern += ".tar.gz" + case strings.HasSuffix(lbase, ".tar"): + tmpPattern += ".tar" + default: + tmpPattern += ".archive" + } + } else { + tmpPattern += ".archive" + } + + tmpFile, err := os.CreateTemp("", tmpPattern) + if err != nil { + return "", err + } + tmpPath := tmpFile.Name() + defer tmpFile.Close() + + resp, err := httpClient.Get(assetURL) + if err != nil { + os.Remove(tmpPath) + return "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + os.Remove(tmpPath) + return "", fmt.Errorf("failed to download asset: status %d", resp.StatusCode) + } + + // Stream download while computing SHA256 to avoid a second download. + // Also show a simple progress line to stderr so users see activity. + h := sha256.New() + pw := &progressWriter{total: resp.ContentLength} + mw := io.MultiWriter(tmpFile, h, pw) + if _, err = io.Copy(mw, resp.Body); err != nil { + _ = os.Remove(tmpPath) + return "", err + } + // ensure final progress line ends with newline + pw.Finish() + + // verify checksum if available + if checksum != "" { + got := hex.EncodeToString(h.Sum(nil)) + if !strings.EqualFold(got, checksum) { + _ = os.Remove(tmpPath) + return "", fmt.Errorf("checksum mismatch: got %s expected %s", got, checksum) + } + } + + // Extract + destDir, err := os.MkdirTemp("", "picoclaw-extract-*") + if err != nil { + os.Remove(tmpPath) + return "", err + } + + if err := extractArchive(tmpPath, destDir); err != nil { + os.Remove(tmpPath) + os.RemoveAll(destDir) + return "", err + } + + // cleanup archive file; keep extracted contents + _ = os.Remove(tmpPath) + return destDir, nil +} + +// UpdateSelfFromRelease downloads the release matching the given parameters, +// extracts it and applies the binary named programName to update the +// currently running executable using minio/selfupdate. +// If releaseURL is empty, the latest release is used. If platform or arch +// is empty, runtime values are used. +func UpdateSelfFromRelease(releaseURL, platform, arch, programName string) error { + if platform == "" { + platform = runtime.GOOS + } + if arch == "" { + arch = runtime.GOARCH + } + + dir, err := DownloadAndExtractRelease(releaseURL, platform, arch) + if err != nil { + return err + } + defer os.RemoveAll(dir) + + binPath, err := findBinaryInDir(dir, programName) + if err != nil { + return err + } + + // ensure executable bit on non-windows + if runtime.GOOS != "windows" { + _ = os.Chmod(binPath, 0o755) + } + + f, err := os.Open(binPath) + if err != nil { + return err + } + defer f.Close() + + // Backup current executable so we can roll back if needed. + var opts selfupdate.Options + if exePath, err := os.Executable(); err == nil { + opts.OldSavePath = exePath + ".old" + } + + if err := selfupdate.Apply(f, opts); err != nil { + return fmt.Errorf("apply update: %w", err) + } + + return nil +} + +// UpdateSelf updates the running executable by fetching the latest release +// and applying the binary matching programName. +func UpdateSelf(programName string) error { + // By default, select the latest stable release when no explicit + // release URL is provided. Use --nightly or a custom URL to override. + return UpdateSelfFromRelease("", runtime.GOOS, runtime.GOARCH, programName) +} + +// GetReleaseAPIURL returns the GitHub Releases API URL for the given repo owner. +// Example: owner="sky5454" -> https://api.github.com/repos/sky5454/picoclaw/releases/latest +func GetReleaseAPIURL(owner string) string { + return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/latest", owner) +} + +// GetProdReleaseAPIURL returns the production release API URL (upstream). +func GetProdReleaseAPIURL() string { + return GetReleaseAPIURL("sipeed") +} + +// GetReleaseTagAPIURL returns the GitHub Releases API URL for a specific tag. +// Example: owner="sipeed", tag="nightly" -> https://api.github.com/repos/sipeed/picoclaw/releases/tags/nightly +func GetReleaseTagAPIURL(owner, tag string) string { + return fmt.Sprintf("https://api.github.com/repos/%s/picoclaw/releases/tags/%s", owner, tag) +} + +// GetNightlyReleaseAPIURL returns the nightly release API URL for the production repo. +func GetNightlyReleaseAPIURL() string { + return GetReleaseTagAPIURL("sipeed", "nightly") +} + +// findAssetURL resolves the appropriate asset URL for the given release +// selector. It accepts direct archive URLs as well as GitHub release URLs +// or empty (latest release for the project). +func findAssetInfo(releaseURL, platform, arch string) (string, string, error) { + // returns (assetURL, sha256ChecksumHex, error) + if looksLikeDirectAssetURL(releaseURL) { + return "", "", fmt.Errorf("no checksum found for asset %s", releaseURL) + } + + apiURL := buildReleaseAPIURL(releaseURL) + if apiURL == "" { + // If caller provided an empty releaseURL, default to the + // production latest release API URL (stable release). + apiURL = GetProdReleaseAPIURL() + } + + resp, err := httpClient.Get(apiURL) + if err != nil { + return "", "", err + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return "", "", fmt.Errorf("failed to query releases: status %d", resp.StatusCode) + } + + var data struct { + TagName string `json:"tag_name"` + Assets []struct { + Name string `json:"name"` + BrowserDownloadURL string `json:"browser_download_url"` + Digest string `json:"digest"` + } `json:"assets"` + } + if err := json.NewDecoder(resp.Body).Decode(&data); err != nil { + return "", "", err + } + + // Selection order: platform -> arch -> extension. + platformLower := strings.ToLower(platform) + archLower := strings.ToLower(arch) + + isZip := func(name string) bool { + return strings.HasSuffix(name, ".zip") + } + isTarGz := func(name string) bool { + return strings.HasSuffix(name, ".tar.gz") || strings.HasSuffix(name, ".tgz") + } + isTar := func(name string) bool { return strings.HasSuffix(name, ".tar") } + + // collect indices of assets that contain platform (if provided) + var platformIdx []int + for i, a := range data.Assets { + n := strings.ToLower(a.Name) + if platform == "" || strings.Contains(n, platformLower) { + platformIdx = append(platformIdx, i) + } + } + + pickBest := func(idxs []int) (string, int, bool) { + if len(idxs) == 0 { + return "", -1, false + } + // prefer arch matches within idxs; if arch was specified but + // no arch match exists among idxs, treat as no candidate. + var archIdx []int + if arch != "" { + aliases := archAliases(archLower) + for _, i := range idxs { + n := strings.ToLower(data.Assets[i].Name) + for _, ali := range aliases { + if strings.Contains(n, ali) { + archIdx = append(archIdx, i) + break + } + } + } + if len(archIdx) == 0 { + return "", -1, false + } + } + candidates := archIdx + if len(candidates) == 0 { + candidates = idxs + } + + // extension preference + if platformLower == "windows" { + // prefer .zip only + for _, i := range candidates { + if isZip(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + // if no zip found, fallthrough to first candidate + return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true + } + + // non-windows: prefer tar.gz/tgz, then tar, then zip + for _, i := range candidates { + if isTarGz(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + for _, i := range candidates { + if isTar(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + for _, i := range candidates { + if isZip(strings.ToLower(data.Assets[i].Name)) { + return data.Assets[i].BrowserDownloadURL, i, true + } + } + // fallback to first candidate + return data.Assets[candidates[0]].BrowserDownloadURL, candidates[0], true + } + + // Try platform matches first + if url, idx, ok := pickBest(platformIdx); ok { + // attempt to find checksum: prefer asset digest from API if present + if d := strings.TrimSpace(data.Assets[idx].Digest); d != "" { + dLower := strings.ToLower(d) + if strings.HasPrefix(dLower, "sha256:") { + hexpart := strings.TrimPrefix(dLower, "sha256:") + return url, hexpart, nil + } + // If digest already looks like a 64-hex, return it + if ok, _ := regexp.MatchString("(?i)^[a-f0-9]{64}$", dLower); ok { + return url, dLower, nil + } + } + // Look for checksum assets and verify by computing the asset's sha256. + for j, a := range data.Assets { + n := strings.ToLower(a.Name) + if strings.Contains(n, "sha256") || + strings.Contains(n, "sha256sum") || + strings.Contains(n, "checksums") || + strings.HasSuffix(n, ".sha256") || + strings.HasSuffix(n, ".sha256sum") { + resp2, err := httpClient.Get(data.Assets[j].BrowserDownloadURL) + if err != nil { + continue + } + bs, err := io.ReadAll(resp2.Body) + resp2.Body.Close() + if err != nil { + continue + } + if h, ok := findHashInChecksumContent(bs, url); ok { + return url, h, nil + } + } + } + // No checksum found for the selected platform asset -> error + return "", "", fmt.Errorf("no checksum found for asset %s", url) + } + + // No platform match — require explicit platform+arch; fail fast. + return "", "", fmt.Errorf("no release asset matching platform %q and arch %q", platform, arch) +} + +func looksLikeDirectAssetURL(u string) bool { + if u == "" { + return false + } + lower := strings.ToLower(u) + if strings.HasSuffix(lower, ".zip") || + strings.HasSuffix(lower, ".tar.gz") || + strings.HasSuffix(lower, ".tgz") || + strings.HasSuffix(lower, ".tar") { + return true + } + if strings.Contains(lower, "/releases/download/") { + return true + } + return false +} + +func buildReleaseAPIURL(releaseURL string) string { + if releaseURL == "" { + return "" + } + if strings.Contains(releaseURL, "api.github.com") { + return releaseURL + } + u, err := url.Parse(releaseURL) + if err != nil { + return "" + } + if u.Host != "github.com" { + return "" + } + parts := strings.Split(strings.Trim(u.Path, "/"), "/") + if len(parts) < 2 { + return "" + } + owner := parts[0] + repo := parts[1] + // if tag specified + if len(parts) >= 5 && parts[2] == "releases" && parts[3] == "tag" { + tag := parts[4] + return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/tags/%s", owner, repo, tag) + } + // default to latest + return fmt.Sprintf("https://api.github.com/repos/%s/%s/releases/latest", owner, repo) +} + +// NOTE: helper functions to compute SHA256 from URL/path were removed +// after refactoring to stream the download and verify the checksum +// during the single download to avoid double-transfer. + +// findHashInChecksumContent attempts to locate a 64-hex SHA256 in the +// checksum file content that corresponds to assetURL. It returns the +// found hash (lowercase) and true, or "", false if not found. +func findHashInChecksumContent(bs []byte, assetURL string) (string, bool) { + s := strings.ToLower(string(bs)) + var assetBase string + if u, err := url.Parse(assetURL); err == nil { + assetBase = strings.ToLower(filepath.Base(u.Path)) + } else { + assetBase = strings.ToLower(filepath.Base(assetURL)) + } + re := regexp.MustCompile(`(?i)\b([a-f0-9]{64})\b`) + // prefer a line containing the asset filename + for _, line := range strings.Split(s, "\n") { + if strings.Contains(line, assetBase) { + if m := re.FindString(line); m != "" { + return m, true + } + } + } + // fallback: if there's exactly one unique 64-hex value, return it + matches := re.FindAllString(s, -1) + uniq := map[string]struct{}{} + for _, m := range matches { + uniq[m] = struct{}{} + } + if len(uniq) == 1 { + for k := range uniq { + return k, true + } + } + return "", false +} + +// progressWriter implements io.Writer and prints a simple progress +// line to stderr while bytes are written. It is intended to be used +// as one writer in an io.MultiWriter so we can stream-to-disk, compute +// the sha256, and update the progress display in a single pass. +type progressWriter struct { + total int64 + written int64 + last time.Time +} + +func (pw *progressWriter) Write(p []byte) (int, error) { + n := len(p) + pw.written += int64(n) + now := time.Now() + if pw.last.IsZero() || now.Sub(pw.last) >= 200*time.Millisecond || (pw.total > 0 && pw.written == pw.total) { + pw.print() + pw.last = now + } + return n, nil +} + +func (pw *progressWriter) print() { + if pw.total > 0 { + pct := float64(pw.written) * 100.0 / float64(pw.total) + fmt.Fprintf(os.Stderr, "\rDownloading: %s / %s (%.1f%%)", humanBytes(pw.written), humanBytes(pw.total), pct) + } else { + fmt.Fprintf(os.Stderr, "\rDownloading: %s", humanBytes(pw.written)) + } +} + +func (pw *progressWriter) Finish() { + pw.print() + fmt.Fprintln(os.Stderr, "") +} + +func humanBytes(n int64) string { + f := float64(n) + const ( + KB = 1024.0 + MB = KB * 1024.0 + GB = MB * 1024.0 + ) + switch { + case f >= GB: + return fmt.Sprintf("%.2f GB", f/GB) + case f >= MB: + return fmt.Sprintf("%.2f MB", f/MB) + case f >= KB: + return fmt.Sprintf("%.2f KB", f/KB) + default: + return fmt.Sprintf("%d B", n) + } +} + +// archAliases returns common name variants for an architecture string +// so we can match release asset names like "x86_64" vs Go's "amd64". +// archAliases returns name variants for an architecture string. +// If `arch` is empty or matches the local runtime.GOARCH, prefer the +// compile-time architecture aliases provided by archAliasesForLocal +// (implemented per-architecture via build tags). For other `arch` +// values we use a small synonyms map. +func archAliases(arch string) []string { + a := strings.ToLower(arch) + if syns, ok := archSynonyms[a]; ok { + return syns + } + return []string{a} +} + +var archSynonyms = map[string][]string{ + "amd64": {"amd64", "x86_64", "x64"}, + "x86_64": {"amd64", "x86_64", "x64"}, + "x64": {"amd64", "x86_64", "x64"}, + "386": {"386", "x86"}, + "x86": {"386", "x86"}, + "arm64": {"arm64", "aarch64"}, + "aarch64": {"arm64", "aarch64"}, + "arm": {"arm"}, +} + +func extractArchive(archivePath, destDir string) error { + lower := strings.ToLower(archivePath) + if strings.HasSuffix(lower, ".zip") { + return extractZip(archivePath, destDir) + } + // treat .tar.gz and .tgz as gzip+tar + if strings.HasSuffix(lower, ".tar.gz") || strings.HasSuffix(lower, ".tgz") { + return extractTarGz(archivePath, destDir) + } + if strings.HasSuffix(lower, ".tar") { + return extractTar(archivePath, destDir) + } + // fallback: try tar.gz + return extractTarGz(archivePath, destDir) +} + +func extractZip(archivePath, destDir string) error { + r, err := zip.OpenReader(archivePath) + if err != nil { + return err + } + defer r.Close() + destClean := filepath.Clean(destDir) + for _, f := range r.File { + target := filepath.Clean(filepath.Join(destClean, f.Name)) + if !strings.HasPrefix(target, destClean+string(os.PathSeparator)) && target != destClean { + return fmt.Errorf("path traversal detected: %s", f.Name) + } + if f.FileInfo().IsDir() { + if err := os.MkdirAll(target, f.FileInfo().Mode()); err != nil { + return err + } + continue + } + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + rc, err := f.Open() + if err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, f.FileInfo().Mode()) + if err != nil { + rc.Close() + return err + } + if _, err := io.Copy(out, rc); err != nil { + rc.Close() + out.Close() + return err + } + rc.Close() + out.Close() + } + return nil +} + +func extractTarGz(archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + gzr, err := gzip.NewReader(f) + if err != nil { + return err + } + defer gzr.Close() + tr := tar.NewReader(gzr) + return extractTarFromReader(tr, destDir) +} + +func extractTar(archivePath, destDir string) error { + f, err := os.Open(archivePath) + if err != nil { + return err + } + defer f.Close() + tr := tar.NewReader(f) + return extractTarFromReader(tr, destDir) +} + +// extractTarFromReader contains logic common to extracting entries from a +// tar.Reader and is used by both extractTarGz and extractTar to avoid +// duplicated code (golangci-lint: dupl). +func extractTarFromReader(tr *tar.Reader, destDir string) error { + for { + hdr, err := tr.Next() + if err == io.EOF { + break + } + if err != nil { + return err + } + target := filepath.Clean(filepath.Join(filepath.Clean(destDir), hdr.Name)) + if !strings.HasPrefix(target, filepath.Clean(destDir)+string(os.PathSeparator)) && + target != filepath.Clean(destDir) { + return fmt.Errorf("path traversal detected: %s", hdr.Name) + } + switch hdr.Typeflag { + case tar.TypeDir: + if err := os.MkdirAll(target, 0o755); err != nil { + return err + } + case tar.TypeReg: + if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil { + return err + } + out, err := os.OpenFile(target, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, os.FileMode(hdr.Mode)) + if err != nil { + return err + } + if _, err := io.Copy(out, tr); err != nil { + out.Close() + return err + } + out.Close() + } + } + return nil +} + +func findBinaryInDir(dir, programName string) (string, error) { + wanted := []string{programName} + if runtime.GOOS == "windows" { + wanted = append([]string{programName + ".exe"}, wanted...) + } else { + // also accept programs with .exe in archives targeting windows + wanted = append(wanted, programName+".exe") + } + + var found string + if err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error { + if err != nil || found != "" { + return err + } + if d.IsDir() { + return nil + } + base := filepath.Base(p) + for _, w := range wanted { + if base == w { + found = p + return io.EOF // use EOF to stop walking early + } + } + return nil + }); err != nil && err != io.EOF { + return "", err + } + if found == "" { + return "", fmt.Errorf("binary %q not found in archive", programName) + } + return found, nil +} + +// NewUpdateCommand returns a cobra command that triggers UpdateSelfFromRelease. +func NewUpdateCommand(binaryName string) *cobra.Command { + var urlStr, platform, arch string + cmd := &cobra.Command{ + Use: "update", + Short: "Check and apply updates from GitHub releases", + RunE: func(cmd *cobra.Command, args []string) error { + if platform == "" { + platform = runtime.GOOS + } + if arch == "" { + arch = runtime.GOARCH + } + fmt.Printf("Current version: %s\n", config.FormatVersion()) + if err := UpdateSelfFromRelease(urlStr, platform, arch, binaryName); err != nil { + return err + } + fmt.Println("Update applied; restart to use the new version.") + return nil + }, + } + cmd.Flags().StringVarP(&urlStr, "url", "u", "", "Direct URL to download release asset or release page") + cmd.Flags().StringVar(&platform, "platform", "", "Target platform (default: runtime.GOOS)") + cmd.Flags().StringVar(&arch, "arch", "", "Target arch (default: runtime.GOARCH)") + return cmd +} diff --git a/pkg/updater/updater_test.go b/pkg/updater/updater_test.go new file mode 100644 index 000000000..ff75432e4 --- /dev/null +++ b/pkg/updater/updater_test.go @@ -0,0 +1,97 @@ +package updater + +import ( + "io" + "os" + "path/filepath" + "strings" + "testing" +) + +// matchesMagic checks whether the file at path looks like a platform binary +// by inspecting magic bytes (ELF for linux, MZ for windows). +func matchesMagic(path, platform string) (bool, error) { + f, err := os.Open(path) + if err != nil { + return false, err + } + defer f.Close() + buf := make([]byte, 4) + n, err := f.Read(buf) + if err != nil && err != io.EOF { + return false, err + } + if n >= 4 && buf[0] == 0x7f && buf[1] == 'E' && buf[2] == 'L' && buf[3] == 'F' { + return strings.Contains(platform, "linux"), nil + } + if n >= 2 && buf[0] == 'M' && buf[1] == 'Z' { + return strings.Contains(platform, "windows"), nil + } + return false, nil +} + +// TestDownloadAndExtractRelease_RealPlatforms downloads the latest release +// asset for multiple platform/arch combos and inspects the extracted +// artifacts to ensure a binary-like file is present. This is a network test +// and is skipped in short mode. +func TestDownloadAndExtractRelease_RealPlatforms(t *testing.T) { + if testing.Short() { + t.Skip("skipping network tests in short mode") + } + + combos := []struct{ platform, arch string }{ + {"linux", "amd64"}, + {"linux", "arm64"}, + {"windows", "amd64"}, + {"windows", "arm64"}, + } + + apiURL := GetProdReleaseAPIURL() + for _, c := range combos { + t.Run(c.platform+"_"+c.arch, func(t *testing.T) { + assetURL, checksum, err := findAssetInfo(apiURL, c.platform, c.arch) + if err != nil { + // If no checksum could be located for this asset, skip this + // combo rather than failing — we require signed/checksummed + // releases for real-network tests. + t.Skipf("skipping %s/%s: %v", c.platform, c.arch, err) + } + t.Logf("asset URL: %s checksum: %s", assetURL, checksum) + + // Pass the release API URL (not the direct asset URL) so + // DownloadAndExtractRelease can locate and verify the asset. + dir, err := DownloadAndExtractRelease(apiURL, c.platform, c.arch) + if err != nil { + t.Fatalf("DownloadAndExtractRelease failed for %s/%s: %v", c.platform, c.arch, err) + } + defer os.RemoveAll(dir) + + var found bool + _ = filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil || d.IsDir() { + return err + } + info, err := d.Info() + if err != nil { + return err + } + if info.Size() < 64 { + return nil + } + ok, err := matchesMagic(path, c.platform) + if err != nil { + return err + } + if ok { + found = true + t.Logf("found artifact: %s (size=%d)", path, info.Size()) + // continue walking to list all + } + return nil + }) + if !found { + t.Fatalf("no binary-like artifact found for %s/%s", c.platform, c.arch) + } + }) + } +} diff --git a/web/backend/api/router.go b/web/backend/api/router.go index 3823fe08c..c6781baf1 100644 --- a/web/backend/api/router.go +++ b/web/backend/api/router.go @@ -81,6 +81,9 @@ func (h *Handler) RegisterRoutes(mux *http.ServeMux) { // Launcher service parameters (port/public) h.registerLauncherConfigRoutes(mux) + // Self-update endpoint (requires dashboard auth) + h.registerUpdateRoutes(mux) + // Runtime build/version metadata h.registerVersionRoutes(mux) diff --git a/web/backend/api/update.go b/web/backend/api/update.go new file mode 100644 index 000000000..2ba862631 --- /dev/null +++ b/web/backend/api/update.go @@ -0,0 +1,52 @@ +package api + +import ( + "encoding/json" + "net/http" + + "github.com/sipeed/picoclaw/pkg/updater" +) + +// registerUpdateRoutes registers the self-update endpoint. +func (h *Handler) registerUpdateRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/update", h.handleUpdate) +} + +type updateRequest struct { + URL string `json:"url,omitempty"` + Binary string `json:"binary,omitempty"` +} + +type updateResponse struct { + Status string `json:"status"` + Message string `json:"message,omitempty"` +} + +func (h *Handler) handleUpdate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.WriteHeader(http.StatusMethodNotAllowed) + _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "method not allowed"}) + return + } + + dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)) + var req updateRequest + if err := dec.Decode(&req); err != nil { + w.WriteHeader(http.StatusBadRequest) + _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: "invalid request body"}) + return + } + + binary := req.Binary + if binary == "" { + binary = "picoclaw-launcher" + } + + if err := updater.UpdateSelfFromRelease(req.URL, "", "", binary); err != nil { + w.WriteHeader(http.StatusInternalServerError) + _ = json.NewEncoder(w).Encode(updateResponse{Status: "error", Message: err.Error()}) + return + } + + _ = json.NewEncoder(w).Encode(updateResponse{Status: "ok", Message: "update applied; restart to use new version"}) +} From 9ac21c5908db82b4a879569c5dffaa03c8ee14fe Mon Sep 17 00:00:00 2001 From: Cytown Date: Wed, 1 Apr 2026 23:44:41 +0800 Subject: [PATCH 05/16] add missing recover panic in subturn.go (#2253) --- pkg/agent/subturn.go | 1 + 1 file changed, 1 insertion(+) diff --git a/pkg/agent/subturn.go b/pkg/agent/subturn.go index 82a2a2010..9447f1384 100644 --- a/pkg/agent/subturn.go +++ b/pkg/agent/subturn.go @@ -511,6 +511,7 @@ func deliverSubTurnResult(al *AgentLoop, parentTS *turnState, childID string, re // We use defer/recover to catch any unlikely channel panics if it were ever closed. defer func() { if r := recover(); r != nil { + logger.RecoverPanicNoExit(r) logger.WarnCF("subturn", "recovered panic sending to pendingResults", map[string]any{ "parent_id": parentTS.turnID, "child_id": childID, From bbcfeaa361845c4e903b086b9067cdd105335460 Mon Sep 17 00:00:00 2001 From: LC <64722907+lc6464@users.noreply.github.com> Date: Wed, 1 Apr 2026 23:50:29 +0800 Subject: [PATCH 06/16] feat(provider): add Venice AI support and update related documentation (#2238) * feat(provider): add Venice AI support and update related documentation * revert(asr): restore asr files to previous commit * feat(config): add Venice API base URL and local LM Studio configuration * fix(config): update Venice API base URL to correct endpoint --- config/config.example.json | 5 ++++ docs/providers.md | 2 ++ docs/zh/providers.md | 2 ++ pkg/config/defaults.go | 14 ++++++++++ pkg/providers/factory_provider.go | 3 +- pkg/providers/factory_provider_test.go | 29 ++++++++++++++++++++ pkg/providers/openai_compat/provider.go | 1 + pkg/providers/openai_compat/provider_test.go | 8 ++++++ 8 files changed, 63 insertions(+), 1 deletion(-) diff --git a/config/config.example.json b/config/config.example.json index 95fe24e0b..bedd543d7 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -48,6 +48,11 @@ "model": "deepseek/deepseek-chat", "api_key": "sk-your-deepseek-key" }, + { + "model_name": "venice-uncensored", + "model": "venice/venice-uncensored", + "api_key": "your-venice-api-key" + }, { "model_name": "lmstudio-local", "model": "lmstudio/openai/gpt-oss-20b" diff --git a/docs/providers.md b/docs/providers.md index f45aa5f3b..b0dfa0bc8 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -16,6 +16,7 @@ | `openrouter` | LLM (recommended, access to all models) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude direct) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT direct) | [platform.openai.com](https://platform.openai.com) | +| `venice` | LLM (Venice AI direct) | [venice.ai](https://venice.ai) | | `deepseek` | LLM (DeepSeek direct) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (Qwen direct) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **Voice transcription** (Whisper) | [console.groq.com](https://console.groq.com) | @@ -46,6 +47,7 @@ This design also enables **multi-agent support** with flexible provider selectio | Vendor | `model` Prefix | Default API Base | Protocol | API Key | | ------------------- | ----------------- |-----------------------------------------------------| --------- | ---------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [Get Key](https://platform.openai.com) | +| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [Get Key](https://venice.ai) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [Get Key](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [Get Key](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **Z.AI Coding Plan** | `openai/` | `https://api.z.ai/api/coding/paas/v4` | OpenAI | [Get Key](https://z.ai/manage-apikey/apikey-list) | diff --git a/docs/zh/providers.md b/docs/zh/providers.md index 04b2f7a88..43c4f26db 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -15,6 +15,7 @@ | `openrouter` | LLM (推荐,可访问所有模型) | [openrouter.ai](https://openrouter.ai) | | `anthropic` | LLM (Claude 直连) | [console.anthropic.com](https://console.anthropic.com) | | `openai` | LLM (GPT 直连) | [platform.openai.com](https://platform.openai.com) | +| `venice` | LLM (Venice AI 直连) | [venice.ai](https://venice.ai) | | `deepseek` | LLM (DeepSeek 直连) | [platform.deepseek.com](https://platform.deepseek.com) | | `qwen` | LLM (通义千问) | [dashscope.console.aliyun.com](https://dashscope.console.aliyun.com) | | `groq` | LLM + **语音转录** (Whisper) | [console.groq.com](https://console.groq.com) | @@ -44,6 +45,7 @@ | 厂商 | `model` 前缀 | 默认 API Base | 协议 | 获取 API Key | | ------------------- | ----------------- | --------------------------------------------------- | --------- | ----------------------------------------------------------------- | | **OpenAI** | `openai/` | `https://api.openai.com/v1` | OpenAI | [获取密钥](https://platform.openai.com) | +| **Venice AI** | `venice/` | `https://api.venice.ai/api/v1` | OpenAI | [获取密钥](https://venice.ai) | | **Anthropic** | `anthropic/` | `https://api.anthropic.com/v1` | Anthropic | [获取密钥](https://console.anthropic.com) | | **智谱 AI (GLM)** | `zhipu/` | `https://open.bigmodel.cn/api/paas/v4` | OpenAI | [获取密钥](https://open.bigmodel.cn/usercenter/proj-mgmt/apikeys) | | **DeepSeek** | `deepseek/` | `https://api.deepseek.com/v1` | OpenAI | [获取密钥](https://platform.deepseek.com) | diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index 6eac5d8b9..a9a107975 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -185,6 +185,13 @@ func DefaultConfig() *Config { APIBase: "https://api.deepseek.com/v1", }, + // Venice AI - https://venice.ai + { + ModelName: "venice-uncensored", + Model: "venice/venice-uncensored", + APIBase: "https://api.venice.ai/api/v1", + }, + // Google Gemini - https://ai.google.dev/ { ModelName: "gemini-2.0-flash", @@ -335,6 +342,13 @@ func DefaultConfig() *Config { APIBase: "http://localhost:8000/v1", }, + // LM Studio (local) - http://localhost:1234 + { + ModelName: "lmstudio-local", + Model: "lmstudio/openai/gpt-oss-20b", + APIBase: "http://localhost:1234/v1", + }, + // Azure OpenAI - https://portal.azure.com // model_name is a user-friendly alias; the model field's path after "azure/" is your deployment name { diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index 16b2ead10..fb5191bf8 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -24,6 +24,7 @@ type protocolMeta struct { var protocolMetaByName = map[string]protocolMeta{ "openai": {defaultAPIBase: "https://api.openai.com/v1"}, + "venice": {defaultAPIBase: "https://api.venice.ai/api/v1"}, "openrouter": {defaultAPIBase: "https://openrouter.ai/api/v1"}, "litellm": {defaultAPIBase: "http://localhost:4000/v1"}, "lmstudio": {defaultAPIBase: "http://localhost:1234/v1", emptyAPIKeyAllowed: true}, @@ -209,7 +210,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err } return provider, modelID, nil - case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", + case "litellm", "lmstudio", "openrouter", "groq", "zhipu", "gemini", "nvidia", "venice", "ollama", "moonshot", "shengsuanyun", "deepseek", "cerebras", "vivgrid", "volcengine", "vllm", "qwen", "qwen-intl", "qwen-international", "dashscope-intl", "qwen-us", "dashscope-us", "mistral", "avian", "longcat", "modelscope", "novita", diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index 588b81650..e2eafb934 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -112,6 +112,7 @@ func TestCreateProviderFromConfig_DefaultAPIBase(t *testing.T) { protocol string }{ {"openai", "openai"}, + {"venice", "venice"}, {"groq", "groq"}, {"novita", "novita"}, {"openrouter", "openrouter"}, @@ -160,6 +161,12 @@ func TestGetDefaultAPIBase_LMStudio(t *testing.T) { } } +func TestGetDefaultAPIBase_Venice(t *testing.T) { + if got := getDefaultAPIBase("venice"); got != "https://api.venice.ai/api/v1" { + t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "venice", got, "https://api.venice.ai/api/v1") + } +} + func TestCreateProviderFromConfig_LiteLLM(t *testing.T) { cfg := &config.ModelConfig{ ModelName: "test-litellm", @@ -362,6 +369,28 @@ func TestCreateProviderFromConfig_Mimo(t *testing.T) { } } +func TestCreateProviderFromConfig_Venice(t *testing.T) { + cfg := &config.ModelConfig{ + ModelName: "test-venice", + Model: "venice/venice-uncensored", + } + cfg.SetAPIKey("test-key") + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + if modelID != "venice-uncensored" { + t.Errorf("modelID = %q, want %q", modelID, "venice-uncensored") + } + if _, ok := provider.(*HTTPProvider); !ok { + t.Fatalf("expected *HTTPProvider, got %T", provider) + } +} + func TestGetDefaultAPIBase_Mimo(t *testing.T) { if got := getDefaultAPIBase("mimo"); got != "https://api.xiaomimimo.com/v1" { t.Fatalf("getDefaultAPIBase(%q) = %q, want %q", "mimo", got, "https://api.xiaomimimo.com/v1") diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index aa9473731..4ff42506f 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -44,6 +44,7 @@ const defaultRequestTimeout = common.DefaultRequestTimeout var stripModelPrefixProviders = map[string]struct{}{ "litellm": {}, + "venice": {}, "moonshot": {}, "nvidia": {}, "groq": {}, diff --git a/pkg/providers/openai_compat/provider_test.go b/pkg/providers/openai_compat/provider_test.go index 823b0ff28..30aa76eb3 100644 --- a/pkg/providers/openai_compat/provider_test.go +++ b/pkg/providers/openai_compat/provider_test.go @@ -479,6 +479,11 @@ func TestProviderChat_StripsKnownProviderPrefixes(t *testing.T) { input: "lmstudio/openai/gpt-oss-20b", wantModel: "openai/gpt-oss-20b", }, + { + name: "strips venice prefix", + input: "venice/venice-uncensored", + wantModel: "venice-uncensored", + }, { name: "strips deepseek prefix", input: "deepseek/deepseek-chat", @@ -587,6 +592,9 @@ func TestNormalizeModel_UsesAPIBase(t *testing.T) { if got := normalizeModel("lmstudio/openai/gpt-oss-20b", "http://localhost:1234/v1"); got != "openai/gpt-oss-20b" { t.Fatalf("normalizeModel(lmstudio) = %q, want %q", got, "openai/gpt-oss-20b") } + if got := normalizeModel("venice/venice-uncensored", "https://api.venice.ai/api/v1"); got != "venice-uncensored" { + t.Fatalf("normalizeModel(venice) = %q, want %q", got, "venice-uncensored") + } if got := normalizeModel("openrouter/auto", "https://openrouter.ai/api/v1"); got != "openrouter/auto" { t.Fatalf("normalizeModel(openrouter) = %q, want %q", got, "openrouter/auto") } From 2973b30ad7029eacc4665216c4cfcebc3626c722 Mon Sep 17 00:00:00 2001 From: Cytown Date: Wed, 1 Apr 2026 23:56:46 +0800 Subject: [PATCH 07/16] implement create dmg for macOS 10.11 & above (#2252) --- .github/workflows/create_dmg.yml | 62 ++++++++++++++++++++++++++++++++ Makefile | 17 +++++---- scripts/build-macos-app.sh | 22 ++++++------ 3 files changed, 82 insertions(+), 19 deletions(-) create mode 100644 .github/workflows/create_dmg.yml diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml new file mode 100644 index 000000000..b47247fc2 --- /dev/null +++ b/.github/workflows/create_dmg.yml @@ -0,0 +1,62 @@ +name: Create macOS DMG +on: + workflow_dispatch: + +jobs: + build: + name: Build ${{ matrix.arch }} + runs-on: macos-latest + strategy: + matrix: + # This creates two parallel jobs + arch: [arm64, amd64] + + steps: + - name: Checkout code + uses: actions/checkout@v6 + with: + ref: main + + # 1. 安装指定版本的 Go (可选,但推荐) + - name: Setup Go + uses: actions/setup-go@v6 + with: + go-version-file: go.mod + + # 2. 安装 pnpm + - name: Install pnpm + run: brew install pnpm + + # 3. 运行你的 Makefile 编译二进制文件 + - name: Build with Make + run: make build ARCH=${{ matrix.arch }} && make build-macos-app ARCH=${{ matrix.arch }} + + # 4. 签名 + - name: Ad-hoc Sign + run: codesign --force --deep --sign - "build/PicoClaw Launcher.app" + + # 5. 安装打包工具 + - name: Install create-dmg + run: brew install create-dmg + + # 6. 执行打包命令 + - name: Create DMG + run: | + mkdir -p dist + create-dmg \ + --volname "PicoClaw Installer" \ + --window-pos 200 120 \ + --window-size 800 400 \ + --icon-size 100 \ + --icon "PicoClaw Launcher.app" 200 190 \ + --hide-extension "PicoClaw Launcher.app" \ + --app-drop-link 600 185 \ + "dist/picoclaw-${{ matrix.arch }}.dmg" \ + "build/PicoClaw Launcher.app" + + # 6. 上传文件到 GitHub Artifacts (供你下载) + - name: Upload DMG + uses: actions/upload-artifact@v4 + with: + name: macos-dmg-${{ matrix.arch }} + path: dist/*.dmg \ No newline at end of file diff --git a/Makefile b/Makefile index 992182775..21d8bdeac 100644 --- a/Makefile +++ b/Makefile @@ -93,13 +93,13 @@ ifeq ($(UNAME_S),Linux) endif else ifeq ($(UNAME_S),Darwin) PLATFORM=darwin - WEB_GO=CGO_ENABLED=1 go + WEB_GO=CGO_LDFLAGS="-mmacosx-version-min=10.11" CGO_CFLAGS="-mmacosx-version-min=10.11" CGO_ENABLED=1 go ifeq ($(UNAME_M),x86_64) - ARCH=amd64 + ARCH?=amd64 else ifeq ($(UNAME_M),arm64) - ARCH=arm64 + ARCH?=arm64 else - ARCH=$(UNAME_M) + ARCH?=$(UNAME_M) endif else PLATFORM=$(UNAME_S) @@ -122,7 +122,7 @@ generate: build: generate @echo "Building $(BINARY_NAME) for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @$(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) + @GOARCH=${ARCH} $(GO) build $(GOFLAGS) -ldflags "$(LDFLAGS)" -o $(BINARY_PATH) ./$(CMD_DIR) @echo "Build complete: $(BINARY_PATH)" @ln -sf $(BINARY_NAME)-$(PLATFORM)-$(ARCH) $(BUILD_DIR)/$(BINARY_NAME) @@ -130,7 +130,7 @@ build: generate build-launcher: @echo "Building picoclaw-launcher for $(PLATFORM)/$(ARCH)..." @mkdir -p $(BUILD_DIR) - @$(MAKE) -C web build \ + @GOARCH=${ARCH} $(MAKE) -C web build \ OUTPUT="$(CURDIR)/$(BUILD_DIR)/picoclaw-launcher-$(PLATFORM)-$(ARCH)" \ WEB_GO='$(WEB_GO)' \ GO_BUILD_TAGS='$(GO_BUILD_TAGS)' \ @@ -324,14 +324,13 @@ docker-clean: ## build-macos-app: Build PicoClaw macOS .app bundle (no terminal window) -build-macos-app: +build-macos-app:build-launcher @echo "Building macOS .app bundle..." @if [ "$(UNAME_S)" != "Darwin" ]; then \ echo "Error: This target is only available on macOS"; \ exit 1; \ fi - @cd web && $(MAKE) build && cd .. - @./scripts/build-macos-app.sh $(BINARY_NAME)-$(PLATFORM)-$(ARCH) + @./scripts/build-macos-app.sh $(PLATFORM)-$(ARCH) @echo "macOS .app bundle created: $(BUILD_DIR)/PicoClaw.app" ## help: Show this help message diff --git a/scripts/build-macos-app.sh b/scripts/build-macos-app.sh index 76cc72938..df2100aec 100755 --- a/scripts/build-macos-app.sh +++ b/scripts/build-macos-app.sh @@ -10,6 +10,8 @@ if [ -z "$EXECUTABLE" ]; then exit 1 fi +LAUNCHER_EXECUTABLE="picoclaw-launcher-${EXECUTABLE}" +EXECUTABLE="picoclaw-${EXECUTABLE}" echo "executable: $EXECUTABLE" APP_NAME="PicoClaw Launcher" @@ -33,17 +35,17 @@ mkdir -p "$APP_RESOURCES" # Copy executable echo "Copying executable..." -if [ -f "./web/build/${APP_EXECUTABLE}" ]; then - cp "./web/build/${APP_EXECUTABLE}" "${APP_MACOS}/" +if [ -f "./build/${LAUNCHER_EXECUTABLE}" ]; then + cp "./build/${LAUNCHER_EXECUTABLE}" "${APP_MACOS}/${APP_EXECUTABLE}" else - echo "Error: ./web/build/${APP_EXECUTABLE} not found. Please build the web backend first." - echo "Run: make build in web dir" + echo "Error: ./build/${LAUNCHER_EXECUTABLE} not found. Please build the web backend first." + echo "Run: make build-launcher" exit 1 fi -if [ -f "./build/picoclaw" ]; then - cp "./build/picoclaw" "${APP_MACOS}/" +if [ -f "./build/${EXECUTABLE}" ]; then + cp "./build/${EXECUTABLE}" "${APP_MACOS}/picoclaw" else - echo "Error: ./build/picoclaw not found. Please build the main file first." + echo "Error: ./build/${EXECUTABLE} not found. Please build the main file first." echo "Run: make build" exit 1 fi @@ -76,10 +78,10 @@ cat > "${APP_CONTENTS}/Info.plist" << 'EOF' NSSupportsAutomaticGraphicsSwitching - LSRequiresCarbon - LSUIElement - 1 + + LSMinimumSystemVersion + 10.11 EOF From 7eba27c3c464ff186eb623b7b8b9878c833b8b93 Mon Sep 17 00:00:00 2001 From: Liu Yuan Date: Thu, 2 Apr 2026 00:08:15 +0800 Subject: [PATCH 08/16] feat: add ContextManager abstraction for pluggable context management (#2203) - Define ContextManager interface with Assemble/Compact/Ingest methods - Implement legacyContextManager wrapping existing summarization logic - Wire Assemble (before BuildMessages), Compact (post-turn + overflow), and Ingest (after message persistence) into agent loop - Add ContextManager config field and factory registry with config passthrough - Remove old maybeSummarize/summarizeSession/summarizeBatch/etc from loop.go - All existing tests pass with default (legacy) config Co-authored-by: Liu Yuan --- pkg/agent/context_legacy.go | 379 +++++++++++++++ pkg/agent/context_manager.go | 89 ++++ pkg/agent/context_manager_test.go | 764 ++++++++++++++++++++++++++++++ pkg/agent/eventbus_test.go | 5 +- pkg/agent/events.go | 2 + pkg/agent/loop.go | 444 ++++------------- pkg/agent/turn.go | 18 + pkg/config/config.go | 34 +- 8 files changed, 1354 insertions(+), 381 deletions(-) create mode 100644 pkg/agent/context_legacy.go create mode 100644 pkg/agent/context_manager.go create mode 100644 pkg/agent/context_manager_test.go diff --git a/pkg/agent/context_legacy.go b/pkg/agent/context_legacy.go new file mode 100644 index 000000000..23402460e --- /dev/null +++ b/pkg/agent/context_legacy.go @@ -0,0 +1,379 @@ +package agent + +import ( + "context" + "fmt" + "strings" + "sync" + "time" + + "github.com/sipeed/picoclaw/pkg/logger" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// legacyContextManager wraps the existing summarization/compression logic +// as a ContextManager implementation. It is the default when no other +// ContextManager is configured. +type legacyContextManager struct { + al *AgentLoop + summarizing sync.Map // dedup for async Compact (post-turn) +} + +func (m *legacyContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { + // Legacy: read history from session, return as-is. + // Budget enforcement happens in BuildMessages caller via + // isOverContextBudget + forceCompression. + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return &AssembleResponse{}, nil + } + history := agent.Sessions.GetHistory(req.SessionKey) + summary := agent.Sessions.GetSummary(req.SessionKey) + return &AssembleResponse{ + History: history, + Summary: summary, + }, nil +} + +func (m *legacyContextManager) Compact(_ context.Context, req *CompactRequest) error { + switch req.Reason { + case ContextCompressReasonProactive, ContextCompressReasonRetry: + // Sync emergency compression — budget exceeded. + if result, ok := m.forceCompression(req.SessionKey); ok { + m.al.emitEvent( + EventKindContextCompress, + m.al.newTurnEventScope("", req.SessionKey).meta(0, "forceCompression", "turn.context.compress"), + ContextCompressPayload{ + Reason: req.Reason, + DroppedMessages: result.DroppedMessages, + RemainingMessages: result.RemainingMessages, + }, + ) + } + case ContextCompressReasonSummarize: + m.maybeSummarize(req.SessionKey) + } + return nil +} + +func (m *legacyContextManager) Ingest(_ context.Context, _ *IngestRequest) error { + // Legacy: no-op. Messages are persisted by Sessions JSONL. + return nil +} + +// maybeSummarize triggers summarization if the session history exceeds thresholds. +// It runs asynchronously in a goroutine. +func (m *legacyContextManager) maybeSummarize(sessionKey string) { + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return + } + + newHistory := agent.Sessions.GetHistory(sessionKey) + tokenEstimate := m.estimateTokens(newHistory) + threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 + + if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { + summarizeKey := agent.ID + ":" + sessionKey + if _, loading := m.summarizing.LoadOrStore(summarizeKey, true); !loading { + go func() { + defer m.summarizing.Delete(summarizeKey) + defer func() { + if r := recover(); r != nil { + logger.WarnCF("agent", "Summarization panic recovered", map[string]any{ + "session_key": sessionKey, + "panic": r, + }) + } + }() + logger.Debug("Memory threshold reached. Optimizing conversation history...") + m.summarizeSession(agent, sessionKey) + }() + } + } +} + +type compressionResult struct { + DroppedMessages int + RemainingMessages int +} + +// forceCompression aggressively reduces context when the limit is hit. +// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response +// cycle, as defined in #1316), so tool-call sequences are never split. +func (m *legacyContextManager) forceCompression(sessionKey string) (compressionResult, bool) { + agent := m.al.registry.GetDefaultAgent() + if agent == nil { + return compressionResult{}, false + } + + history := agent.Sessions.GetHistory(sessionKey) + if len(history) <= 2 { + return compressionResult{}, false + } + + turns := parseTurnBoundaries(history) + var mid int + if len(turns) >= 2 { + mid = turns[len(turns)/2] + } else { + mid = findSafeBoundary(history, len(history)/2) + } + var keptHistory []providers.Message + if mid <= 0 { + for i := len(history) - 1; i >= 0; i-- { + if history[i].Role == "user" { + keptHistory = []providers.Message{history[i]} + break + } + } + } else { + keptHistory = history[mid:] + } + + droppedCount := len(history) - len(keptHistory) + + existingSummary := agent.Sessions.GetSummary(sessionKey) + compressionNote := fmt.Sprintf( + "[Emergency compression dropped %d oldest messages due to context limit]", + droppedCount, + ) + if existingSummary != "" { + compressionNote = existingSummary + "\n\n" + compressionNote + } + agent.Sessions.SetSummary(sessionKey, compressionNote) + + agent.Sessions.SetHistory(sessionKey, keptHistory) + agent.Sessions.Save(sessionKey) + + logger.WarnCF("agent", "Forced compression executed", map[string]any{ + "session_key": sessionKey, + "dropped_msgs": droppedCount, + "new_count": len(keptHistory), + }) + + return compressionResult{ + DroppedMessages: droppedCount, + RemainingMessages: len(keptHistory), + }, true +} + +func (m *legacyContextManager) summarizeSession(agent *AgentInstance, sessionKey string) { + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + + history := agent.Sessions.GetHistory(sessionKey) + summary := agent.Sessions.GetSummary(sessionKey) + + if len(history) <= 4 { + return + } + + safeCut := findSafeBoundary(history, len(history)-4) + if safeCut <= 0 { + return + } + keepCount := len(history) - safeCut + toSummarize := history[:safeCut] + + maxMessageTokens := agent.ContextWindow / 2 + validMessages := make([]providers.Message, 0) + omitted := false + + for _, msg := range toSummarize { + if msg.Role != "user" && msg.Role != "assistant" { + continue + } + msgTokens := len(msg.Content) / 2 + if msgTokens > maxMessageTokens { + omitted = true + continue + } + validMessages = append(validMessages, msg) + } + + if len(validMessages) == 0 { + return + } + + const ( + maxSummarizationMessages = 10 + llmMaxRetries = 3 + ) + + var finalSummary string + if len(validMessages) > maxSummarizationMessages { + mid := len(validMessages) / 2 + mid = m.findNearestUserMessage(validMessages, mid) + + part1 := validMessages[:mid] + part2 := validMessages[mid:] + + s1, _ := m.summarizeBatch(ctx, agent, part1, "") + s2, _ := m.summarizeBatch(ctx, agent, part2, "") + + mergePrompt := fmt.Sprintf( + "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", + s1, s2, + ) + + resp, err := m.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) + if err == nil && resp.Content != "" { + finalSummary = resp.Content + } else { + finalSummary = s1 + " " + s2 + } + } else { + finalSummary, _ = m.summarizeBatch(ctx, agent, validMessages, summary) + } + + if omitted && finalSummary != "" { + finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" + } + + if finalSummary != "" { + agent.Sessions.SetSummary(sessionKey, finalSummary) + agent.Sessions.TruncateHistory(sessionKey, keepCount) + agent.Sessions.Save(sessionKey) + m.al.emitEvent( + EventKindSessionSummarize, + m.al.newTurnEventScope(agent.ID, sessionKey).meta(0, "summarizeSession", "turn.session.summarize"), + SessionSummarizePayload{ + SummarizedMessages: len(validMessages), + KeptMessages: keepCount, + SummaryLen: len(finalSummary), + OmittedOversized: omitted, + }, + ) + } +} + +func (m *legacyContextManager) findNearestUserMessage(messages []providers.Message, mid int) int { + originalMid := mid + + for mid > 0 && messages[mid].Role != "user" { + mid-- + } + + if messages[mid].Role == "user" { + return mid + } + + mid = originalMid + for mid < len(messages) && messages[mid].Role != "user" { + mid++ + } + + if mid < len(messages) { + return mid + } + + return originalMid +} + +func (m *legacyContextManager) retryLLMCall( + ctx context.Context, + agent *AgentInstance, + prompt string, + maxRetries int, +) (*providers.LLMResponse, error) { + const llmTemperature = 0.3 + + var resp *providers.LLMResponse + var err error + + for attempt := 0; attempt < maxRetries; attempt++ { + m.al.activeRequests.Add(1) + resp, err = func() (*providers.LLMResponse, error) { + defer m.al.activeRequests.Done() + return agent.Provider.Chat( + ctx, + []providers.Message{{Role: "user", Content: prompt}}, + nil, + agent.Model, + map[string]any{ + "max_tokens": agent.MaxTokens, + "temperature": llmTemperature, + "prompt_cache_key": agent.ID, + }, + ) + }() + + if err == nil && resp != nil && resp.Content != "" { + return resp, nil + } + if attempt < maxRetries-1 { + time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) + } + } + + return resp, err +} + +func (m *legacyContextManager) summarizeBatch( + ctx context.Context, + agent *AgentInstance, + batch []providers.Message, + existingSummary string, +) (string, error) { + const ( + llmMaxRetries = 3 + fallbackMinContentLength = 200 + fallbackMaxContentPercent = 10 + ) + + var sb strings.Builder + sb.WriteString("Provide a concise summary of this conversation segment, preserving core context and key points.\n") + if existingSummary != "" { + sb.WriteString("Existing context: ") + sb.WriteString(existingSummary) + sb.WriteString("\n") + } + sb.WriteString("\nCONVERSATION:\n") + for _, msg := range batch { + fmt.Fprintf(&sb, "%s: %s\n", msg.Role, msg.Content) + } + prompt := sb.String() + + response, err := m.retryLLMCall(ctx, agent, prompt, llmMaxRetries) + if err == nil && response.Content != "" { + return strings.TrimSpace(response.Content), nil + } + + var fallback strings.Builder + fallback.WriteString("Conversation summary: ") + for i, msg := range batch { + if i > 0 { + fallback.WriteString(" | ") + } + content := strings.TrimSpace(msg.Content) + runes := []rune(content) + if len(runes) == 0 { + fallback.WriteString(fmt.Sprintf("%s: ", msg.Role)) + continue + } + + keepLength := len(runes) * fallbackMaxContentPercent / 100 + if keepLength < fallbackMinContentLength { + keepLength = fallbackMinContentLength + } + if keepLength > len(runes) { + keepLength = len(runes) + } + + content = string(runes[:keepLength]) + if keepLength < len(runes) { + content += "..." + } + fallback.WriteString(fmt.Sprintf("%s: %s", msg.Role, content)) + } + return fallback.String(), nil +} + +func (m *legacyContextManager) estimateTokens(messages []providers.Message) int { + total := 0 + for _, msg := range messages { + total += estimateMessageTokens(msg) + } + return total +} diff --git a/pkg/agent/context_manager.go b/pkg/agent/context_manager.go new file mode 100644 index 000000000..cc8904ccf --- /dev/null +++ b/pkg/agent/context_manager.go @@ -0,0 +1,89 @@ +package agent + +import ( + "context" + "encoding/json" + "fmt" + "sync" + + "github.com/sipeed/picoclaw/pkg/providers" +) + +// ContextManager manages conversation context via a pluggable strategy. +// Exactly ONE ContextManager is active per AgentLoop, selected by config. +// The default ("legacy") preserves current summarization behavior. +type ContextManager interface { + // Assemble builds budget-aware context from the ContextManager's own storage. + // Called before BuildMessages. Returns assembled messages ready for LLM. + Assemble(ctx context.Context, req *AssembleRequest) (*AssembleResponse, error) + + // Compact compresses conversation history. + // Called after turn completes (may be async internally) and on context overflow (sync). + Compact(ctx context.Context, req *CompactRequest) error + + // Ingest records a message into the ContextManager's own storage. + // Called after each message is persisted to session JSONL. + Ingest(ctx context.Context, req *IngestRequest) error +} + +// AssembleRequest is the input to Assemble. +type AssembleRequest struct { + SessionKey string // session identifier + Budget int // context window in tokens + MaxTokens int // max response tokens +} + +// AssembleResponse is the output of Assemble. +type AssembleResponse struct { + History []providers.Message // assembled conversation history for BuildMessages + Summary string // conversation summary embedded into system prompt by BuildMessages +} + +// CompactRequest is the input to Compact. +type CompactRequest struct { + SessionKey string // session identifier + Reason ContextCompressReason // proactive_budget | llm_retry | summarize +} + +// IngestRequest is the input to Ingest. +type IngestRequest struct { + SessionKey string // session identifier + Message providers.Message // the message just persisted +} + +// ContextManagerFactory constructs a ContextManager from config. +// al provides access to the AgentLoop's runtime resources (provider, model, workspace, etc.) +// cfg is the raw JSON configuration from config.json (may be nil). +type ContextManagerFactory func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) + +var ( + cmRegistryMu sync.RWMutex + cmRegistry = map[string]ContextManagerFactory{} +) + +// RegisterContextManager registers a named ContextManager factory. +func RegisterContextManager(name string, factory ContextManagerFactory) error { + if name == "" { + return fmt.Errorf("context manager name is required") + } + if factory == nil { + return fmt.Errorf("context manager %q factory is nil", name) + } + + cmRegistryMu.Lock() + defer cmRegistryMu.Unlock() + + if _, exists := cmRegistry[name]; exists { + return fmt.Errorf("context manager %q is already registered", name) + } + cmRegistry[name] = factory + return nil +} + +func lookupContextManager(name string) (ContextManagerFactory, bool) { + cmRegistryMu.RLock() + defer cmRegistryMu.RUnlock() + + f, ok := cmRegistry[name] + return f, ok +} diff --git a/pkg/agent/context_manager_test.go b/pkg/agent/context_manager_test.go new file mode 100644 index 000000000..6bde5e1a9 --- /dev/null +++ b/pkg/agent/context_manager_test.go @@ -0,0 +1,764 @@ +package agent + +import ( + "context" + "encoding/json" + "os" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/config" + "github.com/sipeed/picoclaw/pkg/providers" +) + +// --------------------------------------------------------------------------- +// Factory registry tests +// --------------------------------------------------------------------------- + +func TestRegisterContextManager_Success(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + } + if err := RegisterContextManager("test_cm", factory); err != nil { + t.Fatalf("unexpected error: %v", err) + } + + f, ok := lookupContextManager("test_cm") + if !ok { + t.Fatal("expected factory to be registered") + } + if f == nil { + t.Fatal("expected non-nil factory") + } +} + +func TestRegisterContextManager_EmptyName(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + err := RegisterContextManager("", func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + }) + if err == nil { + t.Fatal("expected error for empty name") + } + if !strings.Contains(err.Error(), "name is required") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRegisterContextManager_NilFactory(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + err := RegisterContextManager("nil_factory", nil) + if err == nil { + t.Fatal("expected error for nil factory") + } + if !strings.Contains(err.Error(), "factory is nil") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestRegisterContextManager_Duplicate(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + } + if err := RegisterContextManager("dup_cm", factory); err != nil { + t.Fatalf("first registration failed: %v", err) + } + err := RegisterContextManager("dup_cm", factory) + if err == nil { + t.Fatal("expected error for duplicate registration") + } + if !strings.Contains(err.Error(), "already registered") { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestLookupContextManager_Unknown(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + _, ok := lookupContextManager("nonexistent") + if ok { + t.Fatal("expected lookup to fail for unknown name") + } +} + +// --------------------------------------------------------------------------- +// resolveContextManager tests +// --------------------------------------------------------------------------- + +func TestResolveContextManager_Default(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "", // default → legacy + }, + }, + } + al := newCMTestAgentLoop(cfg) + + cm := al.contextManager + if cm == nil { + t.Fatal("expected non-nil context manager") + } + if _, ok := cm.(*legacyContextManager); !ok { + t.Fatalf("expected *legacyContextManager, got %T", cm) + } +} + +func TestResolveContextManager_ExplicitLegacy(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "legacy", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + if _, ok := al.contextManager.(*legacyContextManager); !ok { + t.Fatalf("expected *legacyContextManager, got %T", al.contextManager) + } +} + +func TestResolveContextManager_UnknownFallsBackToLegacy(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "unknown_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + if _, ok := al.contextManager.(*legacyContextManager); !ok { + t.Fatalf("expected fallback to *legacyContextManager, got %T", al.contextManager) + } +} + +func TestResolveContextManager_RegisteredFactory(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return &noopContextManager{}, nil + } + if err := RegisterContextManager("custom_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "custom_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + if _, ok := al.contextManager.(*noopContextManager); !ok { + t.Fatalf("expected *noopContextManager, got %T", al.contextManager) + } +} + +func TestResolveContextManager_FactoryError(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return nil, os.ErrPermission + } + if err := RegisterContextManager("broken_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "broken_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + // Should fall back to legacy when factory returns error + if _, ok := al.contextManager.(*legacyContextManager); !ok { + t.Fatalf("expected fallback to *legacyContextManager on factory error, got %T", al.contextManager) + } +} + +// --------------------------------------------------------------------------- +// Legacy Assemble tests +// --------------------------------------------------------------------------- + +func TestLegacyAssemble_Passthrough(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + agent := al.registry.GetDefaultAgent() + if agent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "hello"}, + {Role: "assistant", Content: "hi there"}, + } + agent.Sessions.SetHistory("test-session", history) + + resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{ + SessionKey: "test-session", + Budget: 8000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.History) != len(history) { + t.Fatalf("expected %d messages, got %d", len(history), len(resp.History)) + } + for i, msg := range resp.History { + if msg.Content != history[i].Content || msg.Role != history[i].Role { + t.Fatalf("message %d mismatch: want %+v, got %+v", i, history[i], msg) + } + } +} + +func TestLegacyAssemble_EmptyHistory(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + resp, err := al.contextManager.Assemble(context.Background(), &AssembleRequest{ + SessionKey: "test-session", + Budget: 8000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.History) != 0 { + t.Fatalf("expected empty messages, got %d", len(resp.History)) + } +} + +// --------------------------------------------------------------------------- +// Legacy Compact overflow tests +// --------------------------------------------------------------------------- + +func TestLegacyCompact_Overflow(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "msg 1"}, + {Role: "assistant", Content: "resp 1"}, + {Role: "user", Content: "msg 2"}, + {Role: "assistant", Content: "resp 2"}, + {Role: "user", Content: "msg 3"}, + } + defaultAgent.Sessions.SetHistory("session-overflow", history) + + sub := al.SubscribeEvents(16) + defer al.UnsubscribeEvents(sub.ID) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-overflow", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // After overflow compression, history should be shorter + newHistory := defaultAgent.Sessions.GetHistory("session-overflow") + if len(newHistory) >= len(history) { + t.Fatalf("expected compressed history, got %d messages (was %d)", len(newHistory), len(history)) + } + + // Summary should contain compression note + summary := defaultAgent.Sessions.GetSummary("session-overflow") + if !strings.Contains(summary, "Emergency compression") { + t.Fatalf("expected compression note in summary, got %q", summary) + } + + // Event should carry the proactive reason + events := collectEventStream(sub.C) + compressEvt, ok := findEvent(events, EventKindContextCompress) + if !ok { + t.Fatal("expected context compress event") + } + payload, ok := compressEvt.Payload.(ContextCompressPayload) + if !ok { + t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload) + } + if payload.Reason != ContextCompressReasonRetry { + t.Fatalf("expected retry reason, got %q", payload.Reason) + } +} + +func TestLegacyCompact_Overflow_ProactiveReason(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "msg 1"}, + {Role: "assistant", Content: "resp 1"}, + {Role: "user", Content: "msg 2"}, + {Role: "assistant", Content: "resp 2"}, + {Role: "user", Content: "msg 3"}, + } + defaultAgent.Sessions.SetHistory("session-proactive", history) + + sub := al.SubscribeEvents(16) + defer al.UnsubscribeEvents(sub.ID) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-proactive", + Reason: ContextCompressReasonProactive, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + events := collectEventStream(sub.C) + compressEvt, ok := findEvent(events, EventKindContextCompress) + if !ok { + t.Fatal("expected context compress event") + } + payload, ok := compressEvt.Payload.(ContextCompressPayload) + if !ok { + t.Fatalf("expected ContextCompressPayload, got %T", compressEvt.Payload) + } + if payload.Reason != ContextCompressReasonProactive { + t.Fatalf("expected proactive reason, got %q", payload.Reason) + } +} + +func TestLegacyCompact_Overflow_TooShortToCompress(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + history := []providers.Message{ + {Role: "user", Content: "only one"}, + } + defaultAgent.Sessions.SetHistory("session-tiny", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-tiny", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // History should be unchanged (too short to compress) + newHistory := defaultAgent.Sessions.GetHistory("session-tiny") + if len(newHistory) != len(history) { + t.Fatalf("expected history unchanged, got %d messages (was %d)", len(newHistory), len(history)) + } +} + +// --------------------------------------------------------------------------- +// Legacy Compact post-turn tests +// --------------------------------------------------------------------------- + +func TestLegacyCompact_PostTurn_BelowThreshold(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // Small history, below summarization thresholds + history := []providers.Message{ + {Role: "user", Content: "hi"}, + {Role: "assistant", Content: "hello"}, + } + defaultAgent.Sessions.SetHistory("session-small", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-small", + Reason: ContextCompressReasonSummarize, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // History should remain unchanged + newHistory := defaultAgent.Sessions.GetHistory("session-small") + if len(newHistory) != len(history) { + t.Fatalf("expected unchanged history, got %d messages (was %d)", len(newHistory), len(history)) + } +} + +func TestLegacyCompact_PostTurn_ExceedsMessageThreshold(t *testing.T) { + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextWindow: 8000, + SummarizeMessageThreshold: 2, + SummarizeTokenPercent: 75, + }, + }, + } + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "summary"}) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // 6 messages > threshold of 2 + history := []providers.Message{ + {Role: "user", Content: "q1"}, + {Role: "assistant", Content: "a1"}, + {Role: "user", Content: "q2"}, + {Role: "assistant", Content: "a2"}, + {Role: "user", Content: "q3"}, + {Role: "assistant", Content: "a3"}, + } + defaultAgent.Sessions.SetHistory("session-threshold", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-threshold", + Reason: ContextCompressReasonSummarize, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Wait for async summarization to complete via event + sub := al.SubscribeEvents(16) + defer al.UnsubscribeEvents(sub.ID) + + waitForEvent(t, sub.C, 5*time.Second, func(evt Event) bool { + return evt.Kind == EventKindSessionSummarize + }) + + newHistory := defaultAgent.Sessions.GetHistory("session-threshold") + if len(newHistory) >= len(history) { + t.Fatalf("expected summarization to reduce history from %d messages, got %d", len(history), len(newHistory)) + } +} + +// --------------------------------------------------------------------------- +// Legacy Ingest tests +// --------------------------------------------------------------------------- + +func TestLegacyIngest_NoOp(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + err := al.contextManager.Ingest(context.Background(), &IngestRequest{ + SessionKey: "session-ingest", + Message: providers.Message{Role: "user", Content: "test"}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +// --------------------------------------------------------------------------- +// Mock ContextManager — verifies dispatch through AgentLoop +// --------------------------------------------------------------------------- + +func TestAgentLoop_UsesCustomContextManager(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + mock := &trackingContextManager{} + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return mock, nil + } + if err := RegisterContextManager("tracking_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "tracking_cm", + }, + }, + } + al := newCMTestAgentLoop(cfg) + + // Verify the mock was installed + if al.contextManager != mock { + t.Fatalf("expected mock context manager, got %T", al.contextManager) + } + + // Direct method calls + _, err := mock.Assemble(context.Background(), &AssembleRequest{ + SessionKey: "s1", + Budget: 8000, + MaxTokens: 4096, + }) + if err != nil { + t.Fatalf("Assemble error: %v", err) + } + if mock.assembleCalls.Load() != 1 { + t.Fatalf("expected 1 assemble call, got %d", mock.assembleCalls.Load()) + } + + err = mock.Compact(context.Background(), &CompactRequest{ + SessionKey: "s1", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("Compact error: %v", err) + } + if mock.compactCalls.Load() != 1 { + t.Fatalf("expected 1 compact call, got %d", mock.compactCalls.Load()) + } + + err = mock.Ingest(context.Background(), &IngestRequest{ + SessionKey: "s1", + Message: providers.Message{Role: "user", Content: "test"}, + }) + if err != nil { + t.Fatalf("Ingest error: %v", err) + } + if mock.ingestCalls.Load() != 1 { + t.Fatalf("expected 1 ingest call, got %d", mock.ingestCalls.Load()) + } +} + +func TestIngestCalledDuringTurn(t *testing.T) { + cleanup := resetCMRegistry() + defer cleanup() + + mock := &trackingContextManager{} + factory := func(cfg json.RawMessage, al *AgentLoop) (ContextManager, error) { + return mock, nil + } + if err := RegisterContextManager("ingest_track_cm", factory); err != nil { + t.Fatalf("register failed: %v", err) + } + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + ContextManager: "ingest_track_cm", + }, + }, + } + + msgBus := bus.NewMessageBus() + al := NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "done"}) + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // Run a turn — ingestMessage is called for user message and final assistant message + _, err := al.runAgentLoop(context.Background(), defaultAgent, processOptions{ + SessionKey: "session-ingest-turn", + Channel: "cli", + ChatID: "direct", + UserMessage: "test ingest", + DefaultResponse: defaultResponse, + EnableSummary: false, + SendResponse: false, + }) + if err != nil { + t.Fatalf("runAgentLoop failed: %v", err) + } + + // Should have at least 2 ingest calls: user message + final assistant message + if mock.ingestCalls.Load() < 2 { + t.Fatalf("expected >= 2 ingest calls during turn, got %d", mock.ingestCalls.Load()) + } +} + +// --------------------------------------------------------------------------- +// forceCompression edge cases (via legacy Compact) +// --------------------------------------------------------------------------- + +func TestLegacyCompact_Overflow_SingleTurnKeepsLastUserMessage(t *testing.T) { + cfg := testConfig(t) + al := newCMTestAgentLoop(cfg) + + defaultAgent := al.registry.GetDefaultAgent() + if defaultAgent == nil { + t.Fatal("expected default agent") + } + + // History with only 2 messages — forceCompression should still handle it + history := []providers.Message{ + {Role: "user", Content: "first question"}, + {Role: "assistant", Content: "first answer"}, + } + defaultAgent.Sessions.SetHistory("session-2msg", history) + + err := al.contextManager.Compact(context.Background(), &CompactRequest{ + SessionKey: "session-2msg", + Reason: ContextCompressReasonRetry, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + newHistory := defaultAgent.Sessions.GetHistory("session-2msg") + // With 2 messages, forceCompression returns false (len <= 2), so no compression + if len(newHistory) != len(history) { + t.Fatalf("expected no compression for 2-message history, got %d", len(newHistory)) + } +} + +// --------------------------------------------------------------------------- +// Test helpers +// --------------------------------------------------------------------------- + +// noopContextManager is a minimal ContextManager that does nothing. +type noopContextManager struct{} + +func (m *noopContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { + return &AssembleResponse{}, nil +} +func (m *noopContextManager) Compact(_ context.Context, _ *CompactRequest) error { return nil } +func (m *noopContextManager) Ingest(_ context.Context, _ *IngestRequest) error { return nil } + +// trackingContextManager tracks call counts for each method. +type trackingContextManager struct { + assembleCalls atomic.Int64 + compactCalls atomic.Int64 + ingestCalls atomic.Int64 + mu sync.Mutex + lastAssemble *AssembleRequest + lastCompact *CompactRequest + lastIngest *IngestRequest +} + +func (m *trackingContextManager) Assemble(_ context.Context, req *AssembleRequest) (*AssembleResponse, error) { + m.assembleCalls.Add(1) + m.mu.Lock() + m.lastAssemble = req + m.mu.Unlock() + return &AssembleResponse{}, nil +} + +func (m *trackingContextManager) Compact(_ context.Context, req *CompactRequest) error { + m.compactCalls.Add(1) + m.mu.Lock() + m.lastCompact = req + m.mu.Unlock() + return nil +} + +func (m *trackingContextManager) Ingest(_ context.Context, req *IngestRequest) error { + m.ingestCalls.Add(1) + m.mu.Lock() + m.lastIngest = req + m.mu.Unlock() + return nil +} + +// resetCMRegistry clears the global factory registry and returns a cleanup +// function that restores the original state after the test. +func resetCMRegistry() func() { + cmRegistryMu.Lock() + original := make(map[string]ContextManagerFactory, len(cmRegistry)) + for k, v := range cmRegistry { + original[k] = v + } + cmRegistry = make(map[string]ContextManagerFactory) + cmRegistryMu.Unlock() + + return func() { + cmRegistryMu.Lock() + cmRegistry = original + cmRegistryMu.Unlock() + } +} + +func testConfig(t *testing.T) *config.Config { + t.Helper() + return &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: t.TempDir(), + ModelName: "test-model", + MaxTokens: 4096, + MaxToolIterations: 10, + }, + }, + } +} + +func newCMTestAgentLoop(cfg *config.Config) *AgentLoop { + msgBus := bus.NewMessageBus() + return NewAgentLoop(cfg, msgBus, &simpleMockProvider{response: "test"}) +} diff --git a/pkg/agent/eventbus_test.go b/pkg/agent/eventbus_test.go index 19a1ea9eb..2785d70a5 100644 --- a/pkg/agent/eventbus_test.go +++ b/pkg/agent/eventbus_test.go @@ -472,8 +472,9 @@ func TestAgentLoop_EmitsSessionSummarizeEvent(t *testing.T) { sub := al.SubscribeEvents(16) defer al.UnsubscribeEvents(sub.ID) - turnScope := al.newTurnEventScope(defaultAgent.ID, "session-1") - al.summarizeSession(defaultAgent, "session-1", turnScope) + // Use legacyContextManager's summarizeSession via contextManager interface + lcm := &legacyContextManager{al: al} + lcm.summarizeSession(defaultAgent, "session-1") events := collectEventStream(sub.C) summaryEvt, ok := findEvent(events, EventKindSessionSummarize) diff --git a/pkg/agent/events.go b/pkg/agent/events.go index f4562b360..615eacf9f 100644 --- a/pkg/agent/events.go +++ b/pkg/agent/events.go @@ -167,6 +167,8 @@ const ( ContextCompressReasonProactive ContextCompressReason = "proactive_budget" // ContextCompressReasonRetry indicates compression during context-error retry handling. ContextCompressReasonRetry ContextCompressReason = "llm_retry" + // ContextCompressReasonSummarize indicates post-turn async summarization. + ContextCompressReasonSummarize ContextCompressReason = "summarize" ) // ContextCompressPayload describes a forced history compression. diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 15535e138..624ff261b 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -48,7 +48,7 @@ type AgentLoop struct { // Runtime state running atomic.Bool - summarizing sync.Map + contextManager ContextManager fallback *providers.FallbackChain channelManager *channels.Manager mediaStore media.MediaStore @@ -137,13 +137,13 @@ func NewAgentLoop( registry: registry, state: stateManager, eventBus: eventBus, - summarizing: sync.Map{}, fallback: fallbackChain, cmdRegistry: commands.NewRegistry(commands.BuiltinDefinitions()), steering: newSteeringQueue(parseSteeringMode(cfg.Agents.Defaults.SteeringMode)), } al.hooks = NewHookManager(eventBus) configureHookManagerFromConfig(al.hooks, cfg) + al.contextManager = al.resolveContextManager() // Register shared tools to all agents (now that al is created) registerSharedTools(al, cfg, msgBus, registry, provider) @@ -1690,8 +1690,15 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er var history []providers.Message var summary string if !ts.opts.NoHistory { - history = ts.agent.Sessions.GetHistory(ts.sessionKey) - summary = ts.agent.Sessions.GetSummary(ts.sessionKey) + // ContextManager assembles budget-aware history and summary. + if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary + } } ts.captureRestorePoint(history, summary) @@ -1716,22 +1723,27 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er if isOverContextBudget(ts.agent.ContextWindow, messages, toolDefs, ts.agent.MaxTokens) { logger.WarnCF("agent", "Proactive compression: context budget exceeded before LLM call", map[string]any{"session_key": ts.sessionKey}) - if compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok { - al.emitEvent( - EventKindContextCompress, - ts.eventMeta("runTurn", "turn.context.compress"), - ContextCompressPayload{ - Reason: ContextCompressReasonProactive, - DroppedMessages: compression.DroppedMessages, - RemainingMessages: compression.RemainingMessages, - }, - ) - ts.refreshRestorePointFromSession(ts.agent) + if err := al.contextManager.Compact(turnCtx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonProactive, + }); err != nil { + logger.WarnCF("agent", "Proactive compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": err.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + // Re-assemble from CM after compact. + if resp, err := al.contextManager.Assemble(turnCtx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); err == nil && resp != nil { + history = resp.History + summary = resp.Summary } - newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey) - newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey) messages = ts.agent.ContextBuilder.BuildMessages( - newHistory, newSummary, ts.userMessage, + history, summary, ts.userMessage, ts.media, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName, activeSkillNames(ts.agent, ts.opts)..., @@ -1753,6 +1765,7 @@ func (al *AgentLoop) runTurn(ctx context.Context, ts *turnState) (turnResult, er ts.agent.Sessions.AddMessage(ts.sessionKey, rootMsg.Role, rootMsg.Content) } ts.recordPersistedMessage(rootMsg) + ts.ingestMessage(turnCtx, al, rootMsg) } activeCandidates, activeModel, usedLight := al.selectCandidates(ts.agent, ts.userMessage, messages) @@ -2096,23 +2109,27 @@ turnLoop: }) } - if compression, ok := al.forceCompression(ts.agent, ts.sessionKey); ok { - al.emitEvent( - EventKindContextCompress, - ts.eventMeta("runTurn", "turn.context.compress"), - ContextCompressPayload{ - Reason: ContextCompressReasonRetry, - DroppedMessages: compression.DroppedMessages, - RemainingMessages: compression.RemainingMessages, - }, - ) - ts.refreshRestorePointFromSession(ts.agent) + if compactErr := al.contextManager.Compact(turnCtx, &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonRetry, + }); compactErr != nil { + logger.WarnCF("agent", "Context overflow compact failed", map[string]any{ + "session_key": ts.sessionKey, + "error": compactErr.Error(), + }) + } + ts.refreshRestorePointFromSession(ts.agent) + // Re-assemble from CM after compact. + if asmResp, asmErr := al.contextManager.Assemble(turnCtx, &AssembleRequest{ + SessionKey: ts.sessionKey, + Budget: ts.agent.ContextWindow, + MaxTokens: ts.agent.MaxTokens, + }); asmErr == nil && asmResp != nil { + history = asmResp.History + summary = asmResp.Summary } - - newHistory := ts.agent.Sessions.GetHistory(ts.sessionKey) - newSummary := ts.agent.Sessions.GetSummary(ts.sessionKey) messages = ts.agent.ContextBuilder.BuildMessages( - newHistory, newSummary, "", + history, summary, "", nil, ts.channel, ts.chatID, ts.opts.SenderID, ts.opts.SenderDisplayName, activeSkillNames(ts.agent, ts.opts)..., ) @@ -2285,6 +2302,7 @@ turnLoop: if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, assistantMsg) ts.recordPersistedMessage(assistantMsg) + ts.ingestMessage(turnCtx, al, assistantMsg) } ts.setPhase(TurnPhaseTools) @@ -2624,6 +2642,7 @@ turnLoop: if !ts.opts.NoHistory { ts.agent.Sessions.AddFullMessage(ts.sessionKey, toolResultMsg) ts.recordPersistedMessage(toolResultMsg) + ts.ingestMessage(turnCtx, al, toolResultMsg) } if steerMsgs := al.dequeueSteeringMessagesForScope(ts.sessionKey); len(steerMsgs) > 0 { @@ -2723,6 +2742,7 @@ turnLoop: if !ts.opts.NoHistory { ts.agent.Sessions.AddMessage(ts.sessionKey, summaryMsg.Role, summaryMsg.Content) ts.recordPersistedMessage(summaryMsg) + ts.ingestMessage(turnCtx, al, summaryMsg) if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { turnStatus = TurnEndStatusError al.emitEvent( @@ -2737,7 +2757,7 @@ turnLoop: } } if ts.opts.EnableSummary { - al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope) + al.contextManager.Compact(turnCtx, &CompactRequest{SessionKey: ts.sessionKey, Reason: ContextCompressReasonSummarize}) } ts.setPhase(TurnPhaseCompleted) @@ -2792,6 +2812,7 @@ turnLoop: finalMsg := providers.Message{Role: "assistant", Content: finalContent} ts.agent.Sessions.AddMessage(ts.sessionKey, finalMsg.Role, finalMsg.Content) ts.recordPersistedMessage(finalMsg) + ts.ingestMessage(turnCtx, al, finalMsg) if err := ts.agent.Sessions.Save(ts.sessionKey); err != nil { turnStatus = TurnEndStatusError al.emitEvent( @@ -2807,7 +2828,13 @@ turnLoop: } if ts.opts.EnableSummary { - al.maybeSummarize(ts.agent, ts.sessionKey, ts.scope) + al.contextManager.Compact( + turnCtx, + &CompactRequest{ + SessionKey: ts.sessionKey, + Reason: ContextCompressReasonSummarize, + }, + ) } ts.setPhase(TurnPhaseCompleted) @@ -2886,103 +2913,28 @@ func (al *AgentLoop) selectCandidates( return agent.LightCandidates, resolvedCandidateModel(agent.LightCandidates, agent.Router.LightModel()), true } -// maybeSummarize triggers summarization if the session history exceeds thresholds. -func (al *AgentLoop) maybeSummarize(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { - newHistory := agent.Sessions.GetHistory(sessionKey) - tokenEstimate := al.estimateTokens(newHistory) - threshold := agent.ContextWindow * agent.SummarizeTokenPercent / 100 - - if len(newHistory) > agent.SummarizeMessageThreshold || tokenEstimate > threshold { - summarizeKey := agent.ID + ":" + sessionKey - if _, loading := al.summarizing.LoadOrStore(summarizeKey, true); !loading { - go func() { - defer al.summarizing.Delete(summarizeKey) - logger.Debug("Memory threshold reached. Optimizing conversation history...") - al.summarizeSession(agent, sessionKey, turnScope) - }() - } +// resolveContextManager selects the ContextManager implementation based on config. +func (al *AgentLoop) resolveContextManager() ContextManager { + name := al.cfg.Agents.Defaults.ContextManager + if name == "" || name == "legacy" { + return &legacyContextManager{al: al} } -} - -type compressionResult struct { - DroppedMessages int - RemainingMessages int -} - -// forceCompression aggressively reduces context when the limit is hit. -// It drops the oldest ~50% of Turns (a Turn is a complete user→LLM→response -// cycle, as defined in #1316), so tool-call sequences are never split. -// -// If the history is a single Turn with no safe split point, the function -// falls back to keeping only the most recent user message. This breaks -// Turn atomicity as a last resort to avoid a context-exceeded loop. -// -// Session history contains only user/assistant/tool messages — the system -// prompt is built dynamically by BuildMessages and is NOT stored here. -// The compression note is recorded in the session summary so that -// BuildMessages can include it in the next system prompt. -func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) (compressionResult, bool) { - history := agent.Sessions.GetHistory(sessionKey) - if len(history) <= 2 { - return compressionResult{}, false + factory, ok := lookupContextManager(name) + if !ok { + logger.WarnCF("agent", "Unknown context manager, falling back to legacy", map[string]any{ + "name": name, + }) + return &legacyContextManager{al: al} } - - // Split at a Turn boundary so no tool-call sequence is torn apart. - // parseTurnBoundaries gives us the start of each Turn; we drop the - // oldest half of Turns and keep the most recent ones. - turns := parseTurnBoundaries(history) - var mid int - if len(turns) >= 2 { - mid = turns[len(turns)/2] - } else { - // Fewer than 2 Turns — fall back to message-level midpoint - // aligned to the nearest Turn boundary. - mid = findSafeBoundary(history, len(history)/2) + cm, err := factory(al.cfg.Agents.Defaults.ContextManagerConfig, al) + if err != nil { + logger.WarnCF("agent", "Failed to create context manager, falling back to legacy", map[string]any{ + "name": name, + "error": err.Error(), + }) + return &legacyContextManager{al: al} } - var keptHistory []providers.Message - if mid <= 0 { - // No safe Turn boundary — the entire history is a single Turn - // (e.g. one user message followed by a massive tool response). - // Keeping everything would leave the agent stuck in a context- - // exceeded loop, so fall back to keeping only the most recent - // user message. This breaks Turn atomicity as a last resort. - for i := len(history) - 1; i >= 0; i-- { - if history[i].Role == "user" { - keptHistory = []providers.Message{history[i]} - break - } - } - } else { - keptHistory = history[mid:] - } - - droppedCount := len(history) - len(keptHistory) - - // Record compression in the session summary so BuildMessages includes it - // in the system prompt. We do not modify history messages themselves. - existingSummary := agent.Sessions.GetSummary(sessionKey) - compressionNote := fmt.Sprintf( - "[Emergency compression dropped %d oldest messages due to context limit]", - droppedCount, - ) - if existingSummary != "" { - compressionNote = existingSummary + "\n\n" + compressionNote - } - agent.Sessions.SetSummary(sessionKey, compressionNote) - - agent.Sessions.SetHistory(sessionKey, keptHistory) - agent.Sessions.Save(sessionKey) - - logger.WarnCF("agent", "Forced compression executed", map[string]any{ - "session_key": sessionKey, - "dropped_msgs": droppedCount, - "new_count": len(keptHistory), - }) - - return compressionResult{ - DroppedMessages: droppedCount, - RemainingMessages: len(keptHistory), - }, true + return cm } // GetStartupInfo returns information about loaded tools and skills for logging. @@ -3074,247 +3026,13 @@ func formatToolsForLog(toolDefs []providers.ToolDefinition) string { } // summarizeSession summarizes the conversation history for a session. -func (al *AgentLoop) summarizeSession(agent *AgentInstance, sessionKey string, turnScope turnEventScope) { - ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) - defer cancel() - - history := agent.Sessions.GetHistory(sessionKey) - summary := agent.Sessions.GetSummary(sessionKey) - - // Keep the most recent Turns for continuity, aligned to a Turn boundary - // so that no tool-call sequence is split. - if len(history) <= 4 { - return - } - - safeCut := findSafeBoundary(history, len(history)-4) - if safeCut <= 0 { - return - } - keepCount := len(history) - safeCut - toSummarize := history[:safeCut] - - // Oversized Message Guard - maxMessageTokens := agent.ContextWindow / 2 - validMessages := make([]providers.Message, 0) - omitted := false - - for _, m := range toSummarize { - if m.Role != "user" && m.Role != "assistant" { - continue - } - msgTokens := len(m.Content) / 2 - if msgTokens > maxMessageTokens { - omitted = true - continue - } - validMessages = append(validMessages, m) - } - - if len(validMessages) == 0 { - return - } - - const ( - maxSummarizationMessages = 10 - llmMaxRetries = 3 - llmTemperature = 0.3 - fallbackMaxContentLength = 200 - ) - - // Multi-Part Summarization - var finalSummary string - if len(validMessages) > maxSummarizationMessages { - mid := len(validMessages) / 2 - - mid = al.findNearestUserMessage(validMessages, mid) - - part1 := validMessages[:mid] - part2 := validMessages[mid:] - - s1, _ := al.summarizeBatch(ctx, agent, part1, "") - s2, _ := al.summarizeBatch(ctx, agent, part2, "") - - mergePrompt := fmt.Sprintf( - "Merge these two conversation summaries into one cohesive summary:\n\n1: %s\n\n2: %s", - s1, - s2, - ) - - resp, err := al.retryLLMCall(ctx, agent, mergePrompt, llmMaxRetries) - if err == nil && resp.Content != "" { - finalSummary = resp.Content - } else { - finalSummary = s1 + " " + s2 - } - } else { - finalSummary, _ = al.summarizeBatch(ctx, agent, validMessages, summary) - } - - if omitted && finalSummary != "" { - finalSummary += "\n[Note: Some oversized messages were omitted from this summary for efficiency.]" - } - - if finalSummary != "" { - agent.Sessions.SetSummary(sessionKey, finalSummary) - agent.Sessions.TruncateHistory(sessionKey, keepCount) - agent.Sessions.Save(sessionKey) - al.emitEvent( - EventKindSessionSummarize, - turnScope.meta(0, "summarizeSession", "turn.session.summarize"), - SessionSummarizePayload{ - SummarizedMessages: len(validMessages), - KeptMessages: keepCount, - SummaryLen: len(finalSummary), - OmittedOversized: omitted, - }, - ) - } -} - // findNearestUserMessage finds the nearest user message to the given index. // It searches backward first, then forward if no user message is found. -func (al *AgentLoop) findNearestUserMessage(messages []providers.Message, mid int) int { - originalMid := mid - - for mid > 0 && messages[mid].Role != "user" { - mid-- - } - - if messages[mid].Role == "user" { - return mid - } - - mid = originalMid - for mid < len(messages) && messages[mid].Role != "user" { - mid++ - } - - if mid < len(messages) { - return mid - } - - return originalMid -} - // retryLLMCall calls the LLM with retry logic. -func (al *AgentLoop) retryLLMCall( - ctx context.Context, - agent *AgentInstance, - prompt string, - maxRetries int, -) (*providers.LLMResponse, error) { - const ( - llmTemperature = 0.3 - ) - - var resp *providers.LLMResponse - var err error - - for attempt := 0; attempt < maxRetries; attempt++ { - al.activeRequests.Add(1) - resp, err = func() (*providers.LLMResponse, error) { - defer al.activeRequests.Done() - return agent.Provider.Chat( - ctx, - []providers.Message{{Role: "user", Content: prompt}}, - nil, - agent.Model, - map[string]any{ - "max_tokens": agent.MaxTokens, - "temperature": llmTemperature, - "prompt_cache_key": agent.ID, - }, - ) - }() - - if err == nil && resp != nil && resp.Content != "" { - return resp, nil - } - if attempt < maxRetries-1 { - time.Sleep(time.Duration(attempt+1) * 100 * time.Millisecond) - } - } - - return resp, err -} - // summarizeBatch summarizes a batch of messages. -func (al *AgentLoop) summarizeBatch( - ctx context.Context, - agent *AgentInstance, - batch []providers.Message, - existingSummary string, -) (string, error) { - const ( - llmMaxRetries = 3 - llmTemperature = 0.3 - fallbackMinContentLength = 200 - fallbackMaxContentPercent = 10 - ) - - var sb strings.Builder - sb.WriteString( - "Provide a concise summary of this conversation segment, preserving core context and key points.\n", - ) - if existingSummary != "" { - sb.WriteString("Existing context: ") - sb.WriteString(existingSummary) - sb.WriteString("\n") - } - sb.WriteString("\nCONVERSATION:\n") - for _, m := range batch { - fmt.Fprintf(&sb, "%s: %s\n", m.Role, m.Content) - } - prompt := sb.String() - - response, err := al.retryLLMCall(ctx, agent, prompt, llmMaxRetries) - if err == nil && response.Content != "" { - return strings.TrimSpace(response.Content), nil - } - - var fallback strings.Builder - fallback.WriteString("Conversation summary: ") - for i, m := range batch { - if i > 0 { - fallback.WriteString(" | ") - } - content := strings.TrimSpace(m.Content) - runes := []rune(content) - if len(runes) == 0 { - fallback.WriteString(fmt.Sprintf("%s: ", m.Role)) - continue - } - - keepLength := len(runes) * fallbackMaxContentPercent / 100 - if keepLength < fallbackMinContentLength { - keepLength = fallbackMinContentLength - } - - if keepLength > len(runes) { - keepLength = len(runes) - } - - content = string(runes[:keepLength]) - if keepLength < len(runes) { - content += "..." - } - fallback.WriteString(fmt.Sprintf("%s: %s", m.Role, content)) - } - return fallback.String(), nil -} - // estimateTokens estimates the number of tokens in a message list. // Counts Content, ToolCalls arguments, and ToolCallID metadata so that // tool-heavy conversations are not systematically undercounted. -func (al *AgentLoop) estimateTokens(messages []providers.Message) int { - total := 0 - for _, m := range messages { - total += estimateMessageTokens(m) - } - return total -} - func (al *AgentLoop) handleCommand( ctx context.Context, msg bus.InboundMessage, diff --git a/pkg/agent/turn.go b/pkg/agent/turn.go index e4970c519..8f099ed1d 100644 --- a/pkg/agent/turn.go +++ b/pkg/agent/turn.go @@ -8,6 +8,7 @@ import ( "time" "github.com/sipeed/picoclaw/pkg/bus" + "github.com/sipeed/picoclaw/pkg/logger" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" "github.com/sipeed/picoclaw/pkg/tools" @@ -338,6 +339,23 @@ func (ts *turnState) refreshRestorePointFromSession(agent *AgentInstance) { ts.captureRestorePoint(history, summary) } +// ingestMessage calls the ContextManager's Ingest method for a persisted message. +// Errors are logged but never block the turn. +func (ts *turnState) ingestMessage(ctx context.Context, al *AgentLoop, msg providers.Message) { + if al.contextManager == nil { + return + } + if err := al.contextManager.Ingest(ctx, &IngestRequest{ + SessionKey: ts.sessionKey, + Message: msg, + }); err != nil { + logger.WarnCF("agent", "Context manager ingest failed", map[string]any{ + "session_key": ts.sessionKey, + "error": err.Error(), + }) + } +} + func (ts *turnState) restoreSession(agent *AgentInstance) error { ts.mu.RLock() history := append([]providers.Message(nil), ts.restorePointHistory...) diff --git a/pkg/config/config.go b/pkg/config/config.go index 7a11d1ab7..a35689bc1 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -226,26 +226,28 @@ type ToolFeedbackConfig struct { } type AgentDefaults struct { - Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` - RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` - AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` - Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` - ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` + Workspace string `json:"workspace" env:"PICOCLAW_AGENTS_DEFAULTS_WORKSPACE"` + RestrictToWorkspace bool `json:"restrict_to_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE"` + AllowReadOutsideWorkspace bool `json:"allow_read_outside_workspace" env:"PICOCLAW_AGENTS_DEFAULTS_ALLOW_READ_OUTSIDE_WORKSPACE"` + Provider string `json:"provider" env:"PICOCLAW_AGENTS_DEFAULTS_PROVIDER"` + ModelName string `json:"model_name" env:"PICOCLAW_AGENTS_DEFAULTS_MODEL_NAME"` ModelFallbacks []string `json:"model_fallbacks,omitempty"` - ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` + ImageModel string `json:"image_model,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_IMAGE_MODEL"` ImageModelFallbacks []string `json:"image_model_fallbacks,omitempty"` - MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` - ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` - Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` - MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` - SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` - SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` - MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` + MaxTokens int `json:"max_tokens" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOKENS"` + ContextWindow int `json:"context_window,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_WINDOW"` + Temperature *float64 `json:"temperature,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_TEMPERATURE"` + MaxToolIterations int `json:"max_tool_iterations" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_TOOL_ITERATIONS"` + SummarizeMessageThreshold int `json:"summarize_message_threshold" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_MESSAGE_THRESHOLD"` + SummarizeTokenPercent int `json:"summarize_token_percent" env:"PICOCLAW_AGENTS_DEFAULTS_SUMMARIZE_TOKEN_PERCENT"` + MaxMediaSize int `json:"max_media_size,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_MAX_MEDIA_SIZE"` Routing *RoutingConfig `json:"routing,omitempty"` - SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" - SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` + SteeringMode string `json:"steering_mode,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_STEERING_MODE"` // "one-at-a-time" (default) or "all" + SubTurn SubTurnConfig `json:"subturn" envPrefix:"PICOCLAW_AGENTS_DEFAULTS_SUBTURN_"` ToolFeedback ToolFeedbackConfig `json:"tool_feedback,omitempty"` - SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker + SplitOnMarker bool `json:"split_on_marker" env:"PICOCLAW_AGENTS_DEFAULTS_SPLIT_ON_MARKER"` // split messages on <|[SPLIT]|> marker + ContextManager string `json:"context_manager,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER"` + ContextManagerConfig json.RawMessage `json:"context_manager_config,omitempty" env:"PICOCLAW_AGENTS_DEFAULTS_CONTEXT_MANAGER_CONFIG"` } const DefaultMaxMediaSize = 20 * 1024 * 1024 // 20 MB From 2c446e1e07f44bdcc3b772cc119b2e2481b00e7d Mon Sep 17 00:00:00 2001 From: Cytown Date: Thu, 2 Apr 2026 11:44:13 +0800 Subject: [PATCH 09/16] feat: add userAgent config for ModelConfig (#2242) * feat: add userAgent config for ModelConfig * update docs for ModelConfig.userAgent * make defaut userAgent to PicoClaw and add test case --- docs/fr/providers.md | 19 ++++ docs/ja/providers.md | 19 ++++ docs/providers.md | 19 ++++ docs/pt-br/providers.md | 19 ++++ docs/vi/providers.md | 19 ++++ docs/zh/providers.md | 19 ++++ pkg/config/config.go | 2 + pkg/providers/anthropic_messages/provider.go | 15 ++- .../anthropic_messages/provider_test.go | 6 +- pkg/providers/azure/provider.go | 18 +++- pkg/providers/azure/provider_test.go | 32 +++--- pkg/providers/factory_provider.go | 12 +++ pkg/providers/factory_provider_test.go | 101 ++++++++++++++++++ pkg/providers/http_provider.go | 5 +- pkg/providers/openai_compat/provider.go | 10 ++ 15 files changed, 286 insertions(+), 29 deletions(-) diff --git a/docs/fr/providers.md b/docs/fr/providers.md index d0da81897..3305ec5ee 100644 --- a/docs/fr/providers.md +++ b/docs/fr/providers.md @@ -99,6 +99,24 @@ Cette conception permet également le **support multi-agents** avec une sélecti } ``` +#### Champs d'entrée `model_list` + +| Champ | Type | Requis | Description | +|-------|------|--------|-------------| +| `model_name` | string | Oui | Nom unique pour référencer ce modèle dans la config agent | +| `model` | string | Oui | Identifiant fournisseur/modèle (ex : `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Oui* | Clé(s) API pour l'authentification. Plusieurs clés permettent la rotation par requête. Non requis pour les fournisseurs locaux (Ollama, LM Studio, VLLM) | +| `api_base` | string | Non | Remplace l'URL de base API par défaut | +| `proxy` | string | Non | URL du proxy HTTP pour cette entrée de modèle | +| `user_agent` | string | Non | En-tête `User-Agent` personnalisé pour les requêtes API (supporté par les providers OpenAI-compatible, Anthropic et Azure) | +| `request_timeout` | int | Non | Délai d'expiration de la requête en secondes (la valeur par défaut varie selon le provider) | +| `max_tokens_field` | string | Non | Remplace le nom du champ max tokens dans le corps de la requête (ex : `max_completion_tokens` pour les modèles o1) | +| `thinking_level` | string | Non | Niveau de pensée étendue : `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | +| `extra_body` | object | Non | Champs supplémentaires à injecter dans chaque corps de requête | +| `rpm` | int | Non | Limite de requêtes par minute | +| `fallbacks` | string[] | Non | Noms des modèles de secours pour le basculement automatique | +| `enabled` | bool | Non | Activer ou désactiver cette entrée de modèle (par défaut : `true`) | + #### Exemples par Vendor **OpenAI** @@ -190,6 +208,7 @@ Pour l'accès direct à l'API Anthropic ou les endpoints personnalisés qui ne p "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/ja/providers.md b/docs/ja/providers.md index e29c113f3..878530966 100644 --- a/docs/ja/providers.md +++ b/docs/ja/providers.md @@ -99,6 +99,24 @@ } ``` +#### `model_list` エントリフィールド + +| フィールド | 型 | 必須 | 説明 | +|-----------|------|------|------| +| `model_name` | string | はい | agent 設定でこのモデルを参照するための一意の名前 | +| `model` | string | はい | ベンダー/モデル識別子(例:`openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | はい* | 認証キー。複数キーでリクエストごとのローテーションが可能。ローカル provider(Ollama、LM Studio、VLLM)には不要 | +| `api_base` | string | いいえ | デフォルトの API エンドポイント URL を上書き | +| `proxy` | string | いいえ | このモデルエントリの HTTP プロキシ URL | +| `user_agent` | string | いいえ | カスタム `User-Agent` リクエストヘッダー(OpenAI 互換、Anthropic、Azure provider で対応) | +| `request_timeout` | int | いいえ | リクエストタイムアウト(秒)。デフォルト値は provider により異なる | +| `max_tokens_field` | string | いいえ | リクエストボディの max tokens フィールド名を上書き(例:o1 モデルでは `max_completion_tokens`) | +| `thinking_level` | string | いいえ | 拡張思考レベル:`off`、`low`、`medium`、`high`、`xhigh`、`adaptive` | +| `extra_body` | object | いいえ | 各リクエストボディに注入する追加フィールド | +| `rpm` | int | いいえ | 1 分あたりのリクエストレート制限 | +| `fallbacks` | string[] | いいえ | 自動フェイルオーバーのフォールバックモデル名 | +| `enabled` | bool | いいえ | このモデルエントリを有効にするかどうか(デフォルト:`true`) | + #### ベンダー別設定例 **OpenAI** @@ -201,6 +219,7 @@ Anthropic API への直接アクセスや、Anthropic のネイティブメッ "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/providers.md b/docs/providers.md index b0dfa0bc8..9bb95446c 100644 --- a/docs/providers.md +++ b/docs/providers.md @@ -108,6 +108,24 @@ This design also enables **multi-agent support** with flexible provider selectio } ``` +#### `model_list` Entry Fields + +| Field | Type | Required | Description | +|-------|------|----------|-------------| +| `model_name` | string | Yes | Unique name used to reference this model in agent config | +| `model` | string | Yes | Vendor/model identifier (e.g., `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Yes* | API key(s) for authentication. Multiple keys enable per-request rotation. Not required for local providers (Ollama, LM Studio, VLLM) | +| `api_base` | string | No | Override the default API endpoint URL | +| `proxy` | string | No | HTTP proxy URL for this model entry | +| `user_agent` | string | No | Custom `User-Agent` header sent with API requests (supported by OpenAI-compatible, Anthropic, and Azure providers) | +| `request_timeout` | int | No | Request timeout in seconds (default varies by provider) | +| `max_tokens_field` | string | No | Override the max tokens field name in request body (e.g., `max_completion_tokens` for o1 models) | +| `thinking_level` | string | No | Extended thinking level: `off`, `low`, `medium`, `high`, `xhigh`, or `adaptive` | +| `extra_body` | object | No | Additional fields to inject into every request body | +| `rpm` | int | No | Per-minute request rate limit | +| `fallbacks` | string[] | No | Fallback model names for automatic failover | +| `enabled` | bool | No | Whether this model entry is active (default: `true`) | + #### Voice Transcription You can configure a dedicated model for audio transcription with `voice.model_name`. This lets you reuse existing multimodal providers that support audio input instead of relying only on Groq. @@ -249,6 +267,7 @@ PicoClaw sends OpenAI-compatible requests to LM Studio, and strips the `lmstudio "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/pt-br/providers.md b/docs/pt-br/providers.md index c7c6305e2..103490dc7 100644 --- a/docs/pt-br/providers.md +++ b/docs/pt-br/providers.md @@ -99,6 +99,24 @@ Este design também permite **suporte multi-agente** com seleção flexível de } ``` +#### Campos de entrada `model_list` + +| Campo | Tipo | Obrigatório | Descrição | +|-------|------|-------------|-----------| +| `model_name` | string | Sim | Nome único para referenciar este modelo na config do agent | +| `model` | string | Sim | Identificador fornecedor/modelo (ex: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Sim* | Chave(s) API para autenticação. Múltiplas chaves permitem rotação por requisição. Não necessário para providers locais (Ollama, LM Studio, VLLM) | +| `api_base` | string | Não | Substitui a URL base da API padrão | +| `proxy` | string | Não | URL do proxy HTTP para esta entrada de modelo | +| `user_agent` | string | Não | Cabeçalho `User-Agent` personalizado enviado com requisições API (suportado por providers OpenAI-compatible, Anthropic e Azure) | +| `request_timeout` | int | Não | Timeout de requisição em segundos (o padrão varia por provider) | +| `max_tokens_field` | string | Não | Substitui o nome do campo max tokens no corpo da requisição (ex: `max_completion_tokens` para modelos o1) | +| `thinking_level` | string | Não | Nível de pensamento estendido: `off`, `low`, `medium`, `high`, `xhigh` ou `adaptive` | +| `extra_body` | object | Não | Campos adicionais para injetar em cada corpo de requisição | +| `rpm` | int | Não | Limite de requisições por minuto | +| `fallbacks` | string[] | Não | Nomes dos modelos de fallback para failover automático | +| `enabled` | bool | Não | Ativar ou desativar esta entrada de modelo (padrão: `true`) | + #### Exemplos por Vendor **OpenAI** @@ -190,6 +208,7 @@ Para acesso direto à API Anthropic ou endpoints personalizados que suportam ape "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/vi/providers.md b/docs/vi/providers.md index ffd992645..46c9de663 100644 --- a/docs/vi/providers.md +++ b/docs/vi/providers.md @@ -99,6 +99,24 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr } ``` +#### Các trường entry `model_list` + +| Trường | Kiểu | Bắt buộc | Mô tả | +|--------|------|----------|------| +| `model_name` | string | Có | Tên duy nhất để tham chiếu model này trong cấu hình agent | +| `model` | string | Có | Định danh nhà cung cấp/model (ví dụ: `openai/gpt-5.4`, `azure/gpt-5.4`, `anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | Có* | Khóa API xác thực. Nhiều khóa cho phép xoay vòng theo yêu cầu. Không cần thiết cho provider nội bộ (Ollama, LM Studio, VLLM) | +| `api_base` | string | Không | Ghi đè URL endpoint API mặc định | +| `proxy` | string | Không | URL proxy HTTP cho entry model này | +| `user_agent` | string | Không | Header `User-Agent` tùy chỉnh gửi với yêu cầu API (được hỗ trợ bởi provider OpenAI-compatible, Anthropic và Azure) | +| `request_timeout` | int | Không | Timeout yêu cầu tính bằng giây (mặc định khác nhau tùy provider) | +| `max_tokens_field` | string | Không | Ghi đè tên trường max tokens trong request body (ví dụ: `max_completion_tokens` cho model o1) | +| `thinking_level` | string | Không | Mức độ tư duy mở rộng: `off`, `low`, `medium`, `high`, `xhigh` hoặc `adaptive` | +| `extra_body` | object | Không | Các trường bổ sung để chèn vào mỗi request body | +| `rpm` | int | Không | Giới hạn tốc độ yêu cầu mỗi phút | +| `fallbacks` | string[] | Không | Tên model dự phòng cho failover tự động | +| `enabled` | bool | Không | Kích hoạt hay vô hiệu hóa entry model này (mặc định: `true`) | + #### Ví Dụ Theo Vendor **OpenAI** @@ -190,6 +208,7 @@ Thiết kế này cũng cho phép **hỗ trợ đa agent** với lựa chọn pr "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/docs/zh/providers.md b/docs/zh/providers.md index 43c4f26db..6048b929f 100644 --- a/docs/zh/providers.md +++ b/docs/zh/providers.md @@ -104,6 +104,24 @@ } ``` +#### `model_list` 条目字段 + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `model_name` | string | 是 | 在 agent 配置中引用此模型的唯一名称 | +| `model` | string | 是 | 厂商/模型标识符(如 `openai/gpt-5.4`、`azure/gpt-5.4`、`anthropic/claude-sonnet-4.6`) | +| `api_keys` | string[] | 是* | 认证密钥。多个密钥可按请求轮换。本地 provider(Ollama、LM Studio、VLLM)不需要 | +| `api_base` | string | 否 | 覆盖默认的 API 端点 URL | +| `proxy` | string | 否 | 此模型条目的 HTTP 代理 URL | +| `user_agent` | string | 否 | 自定义 `User-Agent` 请求头(支持 OpenAI 兼容、Anthropic 和 Azure provider) | +| `request_timeout` | int | 否 | 请求超时时间(秒),默认值因 provider 而异 | +| `max_tokens_field` | string | 否 | 覆盖请求体中 max tokens 的字段名(如 o1 模型使用 `max_completion_tokens`) | +| `thinking_level` | string | 否 | 扩展思考级别:`off`、`low`、`medium`、`high`、`xhigh` 或 `adaptive` | +| `extra_body` | object | 否 | 注入到每个请求体中的额外字段 | +| `rpm` | int | 否 | 每分钟请求速率限制 | +| `fallbacks` | string[] | 否 | 自动故障转移的备用模型名称 | +| `enabled` | bool | 否 | 是否启用此模型条目(默认:`true`) | + #### 语音转录 你可以通过 `voice.model_name` 为语音转录指定一个专用模型。这样可以直接复用已经配置好的、支持音频输入的多模态 provider,而不必只依赖 Groq。 @@ -234,6 +252,7 @@ PicoClaw 向 LM Studio 的 OpenAI 兼容终结点发送请求,且将移除首 "model": "openai/custom-model", "api_base": "https://my-proxy.com/v1", "api_keys": ["sk-..."], + "user_agent": "MyApp/1.0", "request_timeout": 300 } ``` diff --git a/pkg/config/config.go b/pkg/config/config.go index a35689bc1..fcedf45b9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -600,6 +600,8 @@ type ModelConfig struct { // existing configs, the field is inferred during load: models with API keys // or the reserved "local-model" name are auto-enabled. Enabled bool `json:"enabled,omitempty" yaml:"enabled,omitempty"` + // UserAgent is the user agent string to use for HTTP requests. + UserAgent string `json:"user_agent,omitempty" yaml:"-"` // isVirtual marks this model as a virtual model generated from multi-key expansion. // Virtual models should not be persisted to config files. diff --git a/pkg/providers/anthropic_messages/provider.go b/pkg/providers/anthropic_messages/provider.go index 6a1c473dd..1e865b709 100644 --- a/pkg/providers/anthropic_messages/provider.go +++ b/pkg/providers/anthropic_messages/provider.go @@ -41,15 +41,16 @@ type Provider struct { apiKey string apiBase string httpClient *http.Client + userAgent string } // NewProvider creates a new Anthropic Messages API provider. -func NewProvider(apiKey, apiBase string) *Provider { - return NewProviderWithTimeout(apiKey, apiBase, 0) +func NewProvider(apiKey, apiBase, userAgent string) *Provider { + return NewProviderWithTimeout(apiKey, apiBase, userAgent, 0) } // NewProviderWithTimeout creates a provider with custom request timeout. -func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provider { +func NewProviderWithTimeout(apiKey, apiBase, userAgent string, timeoutSeconds int) *Provider { baseURL := normalizeBaseURL(apiBase) timeout := defaultRequestTimeout if timeoutSeconds > 0 { @@ -57,8 +58,9 @@ func NewProviderWithTimeout(apiKey, apiBase string, timeoutSeconds int) *Provide } return &Provider{ - apiKey: apiKey, - apiBase: baseURL, + apiKey: apiKey, + apiBase: baseURL, + userAgent: userAgent, httpClient: &http.Client{ Timeout: timeout, }, @@ -105,6 +107,9 @@ func (p *Provider) Chat( req.Header.Set("Content-Type", "application/json") req.Header.Set("X-API-Key", p.apiKey) //nolint:canonicalheader // Anthropic API requires exact header name req.Header.Set("Anthropic-Version", defaultAPIVersion) + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } // Execute request resp, err := p.httpClient.Do(req) diff --git a/pkg/providers/anthropic_messages/provider_test.go b/pkg/providers/anthropic_messages/provider_test.go index 39bc48117..ba9d24b66 100644 --- a/pkg/providers/anthropic_messages/provider_test.go +++ b/pkg/providers/anthropic_messages/provider_test.go @@ -411,7 +411,7 @@ func TestNormalizeBaseURL(t *testing.T) { } func TestNewProvider(t *testing.T) { - provider := NewProvider("test-key", "https://api.example.com") + provider := NewProvider("test-key", "https://api.example.com", "") if provider == nil { t.Fatal("NewProvider() returned nil") } @@ -424,7 +424,7 @@ func TestNewProvider(t *testing.T) { } func TestGetDefaultModel(t *testing.T) { - provider := NewProvider("test-key", "") + provider := NewProvider("test-key", "", "") got := provider.GetDefaultModel() expected := "claude-sonnet-4.6" if got != expected { @@ -743,7 +743,7 @@ func TestProviderChatErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // Create provider using constructor to ensure proper initialization - provider := NewProvider(tt.apiKey, "https://api.example.com") + provider := NewProvider(tt.apiKey, "https://api.example.com", "") _, err := provider.Chat(context.Background(), tt.messages, nil, "test-model", nil) if err == nil { diff --git a/pkg/providers/azure/provider.go b/pkg/providers/azure/provider.go index 429b26798..7de703248 100644 --- a/pkg/providers/azure/provider.go +++ b/pkg/providers/azure/provider.go @@ -36,6 +36,7 @@ type Provider struct { apiKey string apiBase string httpClient *http.Client + userAgent string } // Option configures the Azure Provider. @@ -50,11 +51,19 @@ func WithRequestTimeout(timeout time.Duration) Option { } } +// WithUserAgent sets the User-Agent header for requests. +func WithUserAgent(userAgent string) Option { + return func(p *Provider) { + p.userAgent = userAgent + } +} + // NewProvider creates a new Azure OpenAI provider. -func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { +func NewProvider(apiKey, apiBase, proxy, userAgent string, opts ...Option) *Provider { p := &Provider{ apiKey: apiKey, apiBase: strings.TrimRight(apiBase, "/"), + userAgent: userAgent, httpClient: common.NewHTTPClient(proxy), } @@ -68,9 +77,9 @@ func NewProvider(apiKey, apiBase, proxy string, opts ...Option) *Provider { } // NewProviderWithTimeout creates a new Azure OpenAI provider with a custom request timeout in seconds. -func NewProviderWithTimeout(apiKey, apiBase, proxy string, requestTimeoutSeconds int) *Provider { +func NewProviderWithTimeout(apiKey, apiBase, proxy, userAgent string, requestTimeoutSeconds int) *Provider { return NewProvider( - apiKey, apiBase, proxy, + apiKey, apiBase, proxy, userAgent, WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), ) } @@ -141,6 +150,9 @@ func (p *Provider) Chat( if p.apiKey != "" { req.Header.Set("Authorization", "Bearer "+p.apiKey) } + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } resp, err := p.httpClient.Do(req) if err != nil { diff --git a/pkg/providers/azure/provider_test.go b/pkg/providers/azure/provider_test.go index b3752ea50..816ae97dc 100644 --- a/pkg/providers/azure/provider_test.go +++ b/pkg/providers/azure/provider_test.go @@ -46,7 +46,7 @@ func TestProviderChat_AzureURLConstruction(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-gpt5-deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -69,7 +69,7 @@ func TestProviderChat_AzureAuthHeader(t *testing.T) { })) defer server.Close() - p := NewProvider("test-azure-key", server.URL, "") + p := NewProvider("test-azure-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -92,7 +92,7 @@ func TestProviderChat_AzureRequestBodyContainsModel(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "my-deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -112,7 +112,7 @@ func TestProviderChat_AzureUsesMaxOutputTokens(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat( t.Context(), []Message{{Role: "user", Content: "hi"}}, @@ -144,7 +144,7 @@ func TestProviderChat_AzureStoreIsFalse(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -161,7 +161,7 @@ func TestProviderChat_AzureHTTPError(t *testing.T) { })) defer server.Close() - p := NewProvider("bad-key", server.URL, "") + p := NewProvider("bad-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error, got nil") @@ -176,7 +176,7 @@ func TestProviderChat_AzureRateLimitError(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error for 429, got nil") @@ -194,7 +194,7 @@ func TestProviderChat_AzureServerError(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error for 500, got nil") @@ -229,7 +229,7 @@ func TestProviderChat_AzureParseTextOutput(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -270,7 +270,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) { })) defer server.Close() - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") out, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "weather?"}}, nil, "deployment", nil) if err != nil { t.Fatalf("Chat() error = %v", err) @@ -287,7 +287,7 @@ func TestProviderChat_AzureParseToolCalls(t *testing.T) { } func TestProvider_AzureEmptyAPIBase(t *testing.T) { - p := NewProvider("test-key", "", "") + p := NewProvider("test-key", "", "", "") _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, nil, "deployment", nil) if err == nil { t.Fatal("expected error for empty API base") @@ -295,21 +295,21 @@ func TestProvider_AzureEmptyAPIBase(t *testing.T) { } func TestProvider_AzureRequestTimeoutDefault(t *testing.T) { - p := NewProvider("test-key", "https://example.com", "") + p := NewProvider("test-key", "https://example.com", "", "") if p.httpClient.Timeout != defaultRequestTimeout { t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, defaultRequestTimeout) } } func TestProvider_AzureRequestTimeoutOverride(t *testing.T) { - p := NewProvider("test-key", "https://example.com", "", WithRequestTimeout(300*time.Second)) + p := NewProvider("test-key", "https://example.com", "", "", WithRequestTimeout(300*time.Second)) if p.httpClient.Timeout != 300*time.Second { t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 300*time.Second) } } func TestProvider_AzureNewProviderWithTimeout(t *testing.T) { - p := NewProviderWithTimeout("test-key", "https://example.com", "", 180) + p := NewProviderWithTimeout("test-key", "https://example.com", "", "", 180) if p.httpClient.Timeout != 180*time.Second { t.Errorf("timeout = %v, want %v", p.httpClient.Timeout, 180*time.Second) } @@ -343,7 +343,7 @@ func TestProviderChat_AzureNativeWebSearchInjection(t *testing.T) { }, } - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") // With native_search=true: user-defined web_search should be replaced by built-in _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", @@ -393,7 +393,7 @@ func TestProviderChat_AzureNoNativeWebSearch(t *testing.T) { }, } - p := NewProvider("test-key", server.URL, "") + p := NewProvider("test-key", server.URL, "", "") // Without native_search: user-defined web_search should be kept as-is _, err := p.Chat(t.Context(), []Message{{Role: "user", Content: "hi"}}, tools, "deployment", nil) diff --git a/pkg/providers/factory_provider.go b/pkg/providers/factory_provider.go index fb5191bf8..ab7277fae 100644 --- a/pkg/providers/factory_provider.go +++ b/pkg/providers/factory_provider.go @@ -129,6 +129,11 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err protocol, modelID := ExtractProtocol(cfg.Model) + userAgent := cfg.UserAgent + if userAgent == "" { + userAgent = fmt.Sprintf("PicoClaw/%s", config.Version) + } + switch protocol { case "openai": // OpenAI with OAuth/token auth (Codex-style) @@ -152,6 +157,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, cfg.ExtraBody, ), modelID, nil @@ -171,6 +177,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err cfg.APIKey(), cfg.APIBase, cfg.Proxy, + userAgent, cfg.RequestTimeout, ), modelID, nil @@ -228,6 +235,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, cfg.ExtraBody, ), modelID, nil @@ -253,6 +261,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, extraBody, ), modelID, nil @@ -279,6 +288,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err apiBase, cfg.Proxy, cfg.MaxTokensField, + userAgent, cfg.RequestTimeout, cfg.ExtraBody, ), modelID, nil @@ -295,6 +305,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, + userAgent, cfg.RequestTimeout, ), modelID, nil @@ -310,6 +321,7 @@ func CreateProviderFromConfig(cfg *config.ModelConfig) (LLMProvider, string, err return anthropicmessages.NewProviderWithTimeout( cfg.APIKey(), apiBase, + userAgent, cfg.RequestTimeout, ), modelID, nil diff --git a/pkg/providers/factory_provider_test.go b/pkg/providers/factory_provider_test.go index e2eafb934..b4f672f7a 100644 --- a/pkg/providers/factory_provider_test.go +++ b/pkg/providers/factory_provider_test.go @@ -846,6 +846,107 @@ func TestCreateProviderFromConfig_MinimaxPreservesUserExtraBody(t *testing.T) { } } +// openaiCompatResponse is the JSON response used by OpenAI-compatible providers. +const openaiCompatResponse = `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}]}` + +// anthropicResponse is the JSON response used by Anthropic providers. +const anthropicResponse = `{"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn","model":"claude-sonnet-4-20250514","usage":{"input_tokens":10,"output_tokens":5}}` + +func TestCreateProviderFromConfig_UserAgent(t *testing.T) { + defaultUA := "PicoClaw/" + config.Version + + tests := []struct { + name string + model string + userAgent string + apiKey string + response string + wantUA string + chatOpts map[string]any + }{ + { + name: "openai default user agent", + model: "openai/gpt-4o", + apiKey: "test-key", + response: openaiCompatResponse, + wantUA: defaultUA, + }, + { + name: "openai custom user agent", + model: "openai/gpt-4o", + apiKey: "test-key", + userAgent: "MyAgent/1.2.3", + response: openaiCompatResponse, + wantUA: "MyAgent/1.2.3", + }, + { + name: "anthropic default user agent", + model: "anthropic/claude-sonnet-4-20250514", + apiKey: "test-key", + response: anthropicResponse, + wantUA: defaultUA, + }, + { + name: "anthropic-messages default user agent", + model: "anthropic-messages/claude-sonnet-4-20250514", + apiKey: "test-key", + response: anthropicResponse, + wantUA: defaultUA, + chatOpts: map[string]any{"max_tokens": 1024}, + }, + { + name: "azure default user agent", + model: "azure/my-deployment", + apiKey: "test-azure-key", + response: openaiCompatResponse, + wantUA: defaultUA, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var receivedUA string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + receivedUA = r.Header.Get("User-Agent") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tt.response)) + })) + defer server.Close() + + cfg := &config.ModelConfig{ + ModelName: "test-ua-" + tt.name, + Model: tt.model, + APIBase: server.URL, + UserAgent: tt.userAgent, + } + cfg.SetAPIKey(tt.apiKey) + + provider, modelID, err := CreateProviderFromConfig(cfg) + if err != nil { + t.Fatalf("CreateProviderFromConfig() error = %v", err) + } + if provider == nil { + t.Fatal("CreateProviderFromConfig() returned nil provider") + } + + _, err = provider.Chat( + t.Context(), + []Message{{Role: "user", Content: "hi"}}, + nil, + modelID, + tt.chatOpts, + ) + if err != nil { + t.Fatalf("Chat() error = %v", err) + } + + if receivedUA != tt.wantUA { + t.Errorf("User-Agent = %q, want %q", receivedUA, tt.wantUA) + } + }) + } +} + func TestCreateProviderFromConfig_Bedrock(t *testing.T) { // Set dummy AWS env vars to make test deterministic t.Setenv("AWS_ACCESS_KEY_ID", "test-key") diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index f2ff52f1d..dae730536 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -24,11 +24,11 @@ func NewHTTPProvider(apiKey, apiBase, proxy string) *HTTPProvider { } func NewHTTPProviderWithMaxTokensField(apiKey, apiBase, proxy, maxTokensField string) *HTTPProvider { - return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, 0, nil) + return NewHTTPProviderWithMaxTokensFieldAndRequestTimeout(apiKey, apiBase, proxy, maxTokensField, "", 0, nil) } func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( - apiKey, apiBase, proxy, maxTokensField string, + apiKey, apiBase, proxy, maxTokensField, userAgent string, requestTimeoutSeconds int, extraBody map[string]any, ) *HTTPProvider { @@ -40,6 +40,7 @@ func NewHTTPProviderWithMaxTokensFieldAndRequestTimeout( openai_compat.WithMaxTokensField(maxTokensField), openai_compat.WithRequestTimeout(time.Duration(requestTimeoutSeconds)*time.Second), openai_compat.WithExtraBody(extraBody), + openai_compat.WithUserAgent(userAgent), ), } } diff --git a/pkg/providers/openai_compat/provider.go b/pkg/providers/openai_compat/provider.go index 4ff42506f..7cda033ad 100644 --- a/pkg/providers/openai_compat/provider.go +++ b/pkg/providers/openai_compat/provider.go @@ -36,6 +36,7 @@ type Provider struct { maxTokensField string // Field name for max tokens (e.g., "max_completion_tokens" for o1/glm models) httpClient *http.Client extraBody map[string]any // Additional fields to inject into request body + userAgent string } type Option func(*Provider) @@ -66,6 +67,12 @@ func WithMaxTokensField(maxTokensField string) Option { } } +func WithUserAgent(userAgent string) Option { + return func(p *Provider) { + p.userAgent = userAgent + } +} + func WithRequestTimeout(timeout time.Duration) Option { return func(p *Provider) { if timeout > 0 { @@ -198,6 +205,9 @@ func (p *Provider) Chat( } req.Header.Set("Content-Type", "application/json") + if p.userAgent != "" { + req.Header.Set("User-Agent", p.userAgent) + } if p.apiKey != "" { req.Header.Set("Authorization", "Bearer "+p.apiKey) } From adf78092daeb23bc873b0114a2d81e173de7c79f Mon Sep 17 00:00:00 2001 From: Cytown Date: Thu, 2 Apr 2026 12:02:24 +0800 Subject: [PATCH 10/16] fix typo in create_dmg.yml (#2255) --- .github/workflows/create_dmg.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/create_dmg.yml b/.github/workflows/create_dmg.yml index b47247fc2..d0a820944 100644 --- a/.github/workflows/create_dmg.yml +++ b/.github/workflows/create_dmg.yml @@ -54,9 +54,9 @@ jobs: "dist/picoclaw-${{ matrix.arch }}.dmg" \ "build/PicoClaw Launcher.app" - # 6. 上传文件到 GitHub Artifacts (供你下载) + # 7. 上传文件到 GitHub Artifacts (供你下载) - name: Upload DMG uses: actions/upload-artifact@v4 with: name: macos-dmg-${{ matrix.arch }} - path: dist/*.dmg \ No newline at end of file + path: dist/*.dmg From 257aa0ff573dbcfbf3e88770b5569e4c5f43c836 Mon Sep 17 00:00:00 2001 From: SakoroYou <165740095+Sakurapainting@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:14:47 +0800 Subject: [PATCH 11/16] fix(channels): fail fast when all channel startups fail (#2262) * fix(channels): fail fast when all channel startups fail * fix(channels): preserve startup errors and cover fail-fast semantics --- pkg/channels/manager.go | 41 ++++++++++++- pkg/channels/manager_test.go | 112 ++++++++++++++++++++++++++++++++++- 2 files changed, 150 insertions(+), 3 deletions(-) diff --git a/pkg/channels/manager.go b/pkg/channels/manager.go index 5fbf35ebf..239448a1c 100644 --- a/pkg/channels/manager.go +++ b/pkg/channels/manager.go @@ -12,6 +12,7 @@ import ( "fmt" "math" "net/http" + "sort" "sync" "time" @@ -513,6 +514,8 @@ func (m *Manager) StartAll(ctx context.Context) error { dispatchCtx, cancel := context.WithCancel(ctx) m.dispatchTask = &asyncTask{cancel: cancel} + failedStarts := make([]error, 0, len(m.channels)) + failedNames := make([]string, 0, len(m.channels)) for name, channel := range m.channels { logger.InfoCF("channels", "Starting channel", map[string]any{ @@ -523,6 +526,8 @@ func (m *Manager) StartAll(ctx context.Context) error { "channel": name, "error": err.Error(), }) + failedStarts = append(failedStarts, fmt.Errorf("channel %s: %w", name, err)) + failedNames = append(failedNames, name) continue } // Lazily create worker only after channel starts successfully @@ -532,6 +537,36 @@ func (m *Manager) StartAll(ctx context.Context) error { go m.runMediaWorker(dispatchCtx, name, w) } + if len(m.channels) > 0 && len(m.workers) == 0 { + if m.dispatchTask != nil { + m.dispatchTask.cancel() + m.dispatchTask = nil + } + + sort.Strings(failedNames) + if len(failedStarts) == 0 { + return fmt.Errorf("failed to start any enabled channels") + } + + logger.ErrorCF("channels", "All enabled channels failed to start", map[string]any{ + "failed": len(failedNames), + "total": len(m.channels), + "failed_channels": failedNames, + }) + + return fmt.Errorf("failed to start any enabled channels: %w", errors.Join(failedStarts...)) + } + + if len(failedNames) > 0 { + sort.Strings(failedNames) + logger.WarnCF("channels", "Some channels failed to start", map[string]any{ + "failed": len(failedNames), + "started": len(m.workers), + "total": len(m.channels), + "failed_channels": failedNames, + }) + } + // Start the dispatcher that reads from the bus and routes to workers go m.dispatchOutbound(dispatchCtx) go m.dispatchOutboundMedia(dispatchCtx) @@ -553,7 +588,11 @@ func (m *Manager) StartAll(ctx context.Context) error { }() } - logger.InfoC("channels", "All channels started") + logger.InfoCF("channels", "Channel startup completed", map[string]any{ + "started": len(m.workers), + "failed": len(failedNames), + "total": len(m.channels), + }) return nil } diff --git a/pkg/channels/manager_test.go b/pkg/channels/manager_test.go index e76212905..937b32d2c 100644 --- a/pkg/channels/manager_test.go +++ b/pkg/channels/manager_test.go @@ -19,6 +19,8 @@ import ( type mockChannel struct { BaseChannel sendFn func(ctx context.Context, msg bus.OutboundMessage) error + startFn func(ctx context.Context) error + stopFn func(ctx context.Context) error sentMessages []bus.OutboundMessage placeholdersSent int editedMessages int @@ -33,8 +35,19 @@ func (m *mockChannel) Send(ctx context.Context, msg bus.OutboundMessage) ([]stri return nil, m.sendFn(ctx, msg) } -func (m *mockChannel) Start(ctx context.Context) error { return nil } -func (m *mockChannel) Stop(ctx context.Context) error { return nil } +func (m *mockChannel) Start(ctx context.Context) error { + if m.startFn != nil { + return m.startFn(ctx) + } + return nil +} + +func (m *mockChannel) Stop(ctx context.Context) error { + if m.stopFn != nil { + return m.stopFn(ctx) + } + return nil +} func (m *mockChannel) SendPlaceholder(ctx context.Context, chatID string) (string, error) { m.placeholdersSent++ @@ -86,6 +99,101 @@ func newTestManager() *Manager { return &Manager{ channels: make(map[string]Channel), workers: make(map[string]*channelWorker), + bus: bus.NewMessageBus(), + } +} + +func TestStartAll_AllChannelsFail_ReturnsJoinedError(t *testing.T) { + m := newTestManager() + errA := errors.New("channel-a start failed") + errB := errors.New("channel-b start failed") + + m.channels["a"] = &mockChannel{ + startFn: func(_ context.Context) error { return errA }, + } + m.channels["b"] = &mockChannel{ + startFn: func(_ context.Context) error { return errB }, + } + + err := m.StartAll(t.Context()) + if err == nil { + t.Fatal("expected StartAll to fail when all channels fail") + } + if !strings.Contains(err.Error(), "failed to start any enabled channels") { + t.Fatalf("unexpected error: %v", err) + } + if !errors.Is(err, errA) { + t.Fatalf("expected error to wrap errA, got: %v", err) + } + if !errors.Is(err, errB) { + t.Fatalf("expected error to wrap errB, got: %v", err) + } + if len(m.workers) != 0 { + t.Fatalf("expected no workers on full startup failure, got %d", len(m.workers)) + } + if m.dispatchTask != nil { + t.Fatal("expected dispatch task to be cleared on full startup failure") + } +} + +func TestStartAll_PartialFailure_StartsSuccessfulWorkers(t *testing.T) { + m := newTestManager() + errBad := errors.New("bad channel start failed") + processed := make(chan struct{}, 1) + + m.channels["good"] = &mockChannel{ + sendFn: func(_ context.Context, msg bus.OutboundMessage) error { + if msg.Channel == "good" { + select { + case processed <- struct{}{}: + default: + } + } + return nil + }, + } + m.channels["bad"] = &mockChannel{ + startFn: func(_ context.Context) error { return errBad }, + } + + err := m.StartAll(t.Context()) + if err != nil { + t.Fatalf("expected StartAll to succeed with partial channel failures, got: %v", err) + } + if len(m.workers) != 1 { + t.Fatalf("expected exactly 1 active worker, got %d", len(m.workers)) + } + if _, ok := m.workers["good"]; !ok { + t.Fatal("expected worker for successful channel 'good'") + } + if _, ok := m.workers["bad"]; ok { + t.Fatal("did not expect worker for failed channel 'bad'") + } + if m.dispatchTask == nil { + t.Fatal("expected dispatch task to run when at least one channel starts") + } + + pubCtx, pubCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer pubCancel() + if err := m.bus.PublishOutbound(pubCtx, bus.OutboundMessage{ + Channel: "good", + ChatID: "chat-1", + Content: "hello", + }); err != nil { + t.Fatalf("PublishOutbound() error = %v", err) + } + + select { + case <-processed: + // worker processed outbound message as expected + case <-time.After(2 * time.Second): + t.Fatal("expected successful channel worker to process outbound message") + } + + stopCtx, stopCancel := context.WithTimeout(context.Background(), 2*time.Second) + defer stopCancel() + if err := m.StopAll(stopCtx); err != nil { + t.Fatalf("StopAll() error = %v", err) } } From 03b97e412e4c7ce13472718963d0ed227f9452fa Mon Sep 17 00:00:00 2001 From: lxowalle <83055338+lxowalle@users.noreply.github.com> Date: Thu, 2 Apr 2026 14:23:01 +0800 Subject: [PATCH 12/16] docs: optimize readme for android (#2272) * * update readme.md * update * * update --- README.fr.md | 27 +++++++++++++++++++-------- README.id.md | 27 +++++++++++++++++++-------- README.it.md | 27 +++++++++++++++++++-------- README.ja.md | 27 +++++++++++++++++++-------- README.md | 27 +++++++++++++++++++-------- README.my.md | 27 +++++++++++++++++++-------- README.pt-br.md | 27 +++++++++++++++++++-------- README.vi.md | 27 +++++++++++++++++++-------- README.zh.md | 27 +++++++++++++++++++-------- assets/fui_log_page.jpg | Bin 0 -> 11049 bytes assets/fui_main_page.jpg | Bin 0 -> 34641 bytes assets/fui_setting_page.jpg | Bin 0 -> 46703 bytes assets/fui_web_page.jpg | Bin 0 -> 19639 bytes 13 files changed, 171 insertions(+), 72 deletions(-) create mode 100644 assets/fui_log_page.jpg create mode 100644 assets/fui_main_page.jpg create mode 100644 assets/fui_setting_page.jpg create mode 100644 assets/fui_web_page.jpg diff --git a/README.fr.md b/README.fr.md index a0cb84ce3..a26c89f14 100644 --- a/README.fr.md +++ b/README.fr.md @@ -306,7 +306,25 @@ Pour la documentation détaillée du TUI, voir [docs.picoclaw.io](https://docs.p Donnez une seconde vie à votre téléphone vieux de dix ans ! Transformez-le en assistant IA intelligent avec PicoClaw. -**Option 1 : Termux (disponible maintenant)** +**Option 1 : Installation APK** + +Aperçu : + + + + + + + + +
+ +Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux ! + +**Option 2 : Termux** + +
+Terminal Launcher (pour les environnements à ressources limitées) 1. Installez [Termux](https://github.com/termux/termux-app) (téléchargez depuis [GitHub Releases](https://github.com/termux/termux-app/releases), ou cherchez dans F-Droid / Google Play) 2. Exécutez les commandes suivantes : @@ -323,13 +341,6 @@ Suivez ensuite la section Terminal Launcher ci-dessous pour terminer la configur PicoClaw on Termux -**Option 2 : Installation APK** - -Téléchargez l'APK depuis [picoclaw.io](https://picoclaw.io/download/) et installez-le directement. Pas besoin de Termux ! - -
-Terminal Launcher (pour les environnements à ressources limitées) - Pour les environnements minimaux où seul le binaire principal `picoclaw` est disponible (sans Launcher UI), vous pouvez tout configurer via la ligne de commande et un fichier de configuration JSON. **1. Initialiser** diff --git a/README.id.md b/README.id.md index bba010dec..d3c556dde 100644 --- a/README.id.md +++ b/README.id.md @@ -303,7 +303,25 @@ Untuk dokumentasi TUI lengkap, lihat [docs.picoclaw.io](https://docs.picoclaw.io Berikan kehidupan kedua untuk ponsel lama Anda! Ubah menjadi Asisten AI pintar dengan PicoClaw. -**Opsi 1: Termux (tersedia sekarang)** +**Opsi 1: Instal APK** + +Pratinjau: + + + + + + + + +
+ +Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux! + +**Opsi 2: Termux** + +
+Terminal Launcher (untuk lingkungan dengan sumber daya terbatas) 1. Instal [Termux](https://github.com/termux/termux-app) (unduh dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play) 2. Jalankan perintah berikut: @@ -320,13 +338,6 @@ Kemudian ikuti bagian Terminal Launcher di bawah untuk menyelesaikan konfigurasi PicoClaw on Termux -**Opsi 2: Instal APK** - -Unduh APK dari [picoclaw.io](https://picoclaw.io/download/) dan instal langsung. Tanpa Termux! - -
-Terminal Launcher (untuk lingkungan dengan sumber daya terbatas) - Untuk lingkungan minimal di mana hanya binary inti `picoclaw` yang tersedia (tanpa Launcher UI), Anda dapat mengonfigurasi semuanya melalui command line dan file konfigurasi JSON. **1. Inisialisasi** diff --git a/README.it.md b/README.it.md index 50f08ad8b..6fe6c5e17 100644 --- a/README.it.md +++ b/README.it.md @@ -303,7 +303,25 @@ Per la documentazione dettagliata del TUI, vedi [docs.picoclaw.io](https://docs. Dai una seconda vita al tuo telefono di dieci anni fa! Trasformalo in un assistente IA intelligente con PicoClaw. -**Opzione 1: Termux (disponibile ora)** +**Opzione 1: Installazione APK** + +Anteprima: + + + + + + + + +
+ +Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux! + +**Opzione 2: Termux** + +
+Terminal Launcher (per ambienti con risorse limitate) 1. Installa [Termux](https://github.com/termux/termux-app) (scarica da [GitHub Releases](https://github.com/termux/termux-app/releases), o cerca su F-Droid / Google Play) 2. Esegui i seguenti comandi: @@ -320,13 +338,6 @@ Poi segui la sezione Terminal Launcher qui sotto per completare la configurazion PicoClaw on Termux -**Opzione 2: Installazione APK** - -Scarica l'APK da [picoclaw.io](https://picoclaw.io/download/) e installa direttamente. Senza Termux! - -
-Terminal Launcher (per ambienti con risorse limitate) - Per ambienti minimali dove è disponibile solo il binario core `picoclaw` (senza Launcher UI), puoi configurare tutto tramite riga di comando e un file di configurazione JSON. **1. Inizializza** diff --git a/README.ja.md b/README.ja.md index 7171a87b9..793c41fcb 100644 --- a/README.ja.md +++ b/README.ja.md @@ -303,7 +303,25 @@ TUI の詳細なドキュメントは [docs.picoclaw.io](https://docs.picoclaw.i 10 年前のスマホに第二の人生を!PicoClaw でスマート AI アシスタントに変身させましょう。 -**オプション 1: Termux(現在利用可能)** +**オプション 1: APK インストール** + +プレビュー: + + + + + + + + +
+ +[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要! + +**オプション 2: Termux** + +
+Terminal Launcher(リソース制約環境向け) 1. [Termux](https://github.com/termux/termux-app) をインストール([GitHub Releases](https://github.com/termux/termux-app/releases) からダウンロード、または F-Droid / Google Play で検索) 2. 以下のコマンドを実行: @@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot で標準的な Linux ファイル PicoClaw on Termux -**オプション 2: APK インストール** - -[picoclaw.io](https://picoclaw.io/download/) から APK をダウンロードして直接インストール。Termux 不要! - -
-Terminal Launcher(リソース制約環境向け) - `picoclaw` コアバイナリのみが利用可能な最小環境(Launcher UI なし)では、コマンドラインと JSON 設定ファイルですべてを設定できます。 **1. 初期化** diff --git a/README.md b/README.md index db38e644f..d73348554 100644 --- a/README.md +++ b/README.md @@ -303,7 +303,25 @@ For detailed TUI documentation, see [docs.picoclaw.io](https://docs.picoclaw.io) Give your decade-old phone a second life! Turn it into a smart AI Assistant with PicoClaw. -**Option 1: Termux (available now)** +**Option 1: APK Install** + +Preview: + + + + + + + + +
+ +Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required! + +**Option 2: Termux** + +
+Terminal Launcher (for resource-constrained environments) 1. Install [Termux](https://github.com/termux/termux-app) (download from [GitHub Releases](https://github.com/termux/termux-app/releases), or search in F-Droid / Google Play) 2. Run the following commands: @@ -320,13 +338,6 @@ Then follow the Terminal Launcher section below to complete configuration. PicoClaw on Termux -**Option 2: APK Install** - -Download the APK from [picoclaw.io](https://picoclaw.io/download/) and install directly. No Termux required! - -
-Terminal Launcher (for resource-constrained environments) - For minimal environments where only the `picoclaw` core binary is available (no Launcher UI), you can configure everything via the command line and a JSON config file. **1. Initialize** diff --git a/README.my.md b/README.my.md index 095d4b66a..f00fb438c 100644 --- a/README.my.md +++ b/README.my.md @@ -300,7 +300,25 @@ Untuk dokumentasi TUI terperinci, lihat [docs.picoclaw.io](https://docs.picoclaw Berikan telefon lama anda kehidupan baru! Jadikannya Pembantu AI pintar dengan PicoClaw. -**Pilihan 1: Termux (tersedia sekarang)** +**Pilihan 1: Pasang APK** + +Pratonton: + + + + + + + + +
+ +Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan! + +**Pilihan 2: Termux** + +
+Pelancar Terminal (untuk persekitaran terhad sumber) 1. Pasang [Termux](https://github.com/termux/termux-app) (muat turun dari [GitHub Releases](https://github.com/termux/termux-app/releases), atau cari di F-Droid / Google Play) 2. Jalankan arahan berikut: @@ -317,13 +335,6 @@ Kemudian ikuti bahagian Pelancar Terminal di bawah untuk melengkapkan konfiguras PicoClaw pada Termux -**Pilihan 2: Pasang APK** - -Muat turun APK dari [picoclaw.io](https://picoclaw.io/download/) dan pasang secara langsung. Tiada Termux diperlukan! - -
-Pelancar Terminal (untuk persekitaran terhad sumber) - Untuk persekitaran minimal di mana hanya binari teras `picoclaw` tersedia (tiada UI Pelancar), anda boleh mengkonfigurasi semua melalui baris arahan dan fail konfigurasi JSON. **1. Mulakan** diff --git a/README.pt-br.md b/README.pt-br.md index bbc5b4957..db11d4d82 100644 --- a/README.pt-br.md +++ b/README.pt-br.md @@ -303,7 +303,25 @@ Para documentação detalhada do TUI, veja [docs.picoclaw.io](https://docs.picoc Dê uma segunda vida ao seu celular de uma década! Transforme-o em um Assistente de IA inteligente com o PicoClaw. -**Opção 1: Termux (disponível agora)** +**Opção 1: Instalação via APK** + +Pré-visualização: + + + + + + + + +
+ +Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux! + +**Opção 2: Termux** + +
+Terminal Launcher (para ambientes com recursos limitados) 1. Instale o [Termux](https://github.com/termux/termux-app) (baixe nas [GitHub Releases](https://github.com/termux/termux-app/releases), ou pesquise no F-Droid / Google Play) 2. Execute os seguintes comandos: @@ -320,13 +338,6 @@ Em seguida, siga a seção Terminal Launcher abaixo para concluir a configuraç PicoClaw on Termux -**Opção 2: Instalação via APK** - -Baixe o APK de [picoclaw.io](https://picoclaw.io/download/) e instale diretamente. Sem necessidade de Termux! - -
-Terminal Launcher (para ambientes com recursos limitados) - Para ambientes mínimos onde apenas o binário principal `picoclaw` está disponível (sem Launcher UI), você pode configurar tudo via linha de comando e um arquivo de configuração JSON. **1. Inicializar** diff --git a/README.vi.md b/README.vi.md index 7ae414723..78b8a9a59 100644 --- a/README.vi.md +++ b/README.vi.md @@ -303,7 +303,25 @@ Sử dụng menu TUI để: **1)** Cấu hình Provider -> **2)** Cấu hình Ch Hãy cho chiếc điện thoại cũ của bạn một cuộc sống mới! Biến nó thành Trợ lý AI thông minh với PicoClaw. -**Tùy chọn 1: Termux (có sẵn ngay)** +**Tùy chọn 1: Cài đặt APK** + +Xem trước: + + + + + + + + +
+ +Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux! + +**Tùy chọn 2: Termux** + +
+Terminal Launcher (cho môi trường hạn chế tài nguyên) 1. Cài đặt [Termux](https://github.com/termux/termux-app) (tải từ [GitHub Releases](https://github.com/termux/termux-app/releases), hoặc tìm kiếm trong F-Droid / Google Play) 2. Chạy các lệnh sau: @@ -320,13 +338,6 @@ Sau đó làm theo phần Terminal Launcher bên dưới để hoàn tất cấu PicoClaw on Termux -**Tùy chọn 2: Cài đặt APK** - -Tải APK từ [picoclaw.io](https://picoclaw.io/download/) và cài đặt trực tiếp. Không cần Termux! - -
-Terminal Launcher (cho môi trường hạn chế tài nguyên) - Đối với các môi trường tối giản chỉ có binary lõi `picoclaw` (không có Launcher UI), bạn có thể cấu hình mọi thứ qua dòng lệnh và tệp cấu hình JSON. **1. Khởi tạo** diff --git a/README.zh.md b/README.zh.md index 569ca1656..16d01b59b 100644 --- a/README.zh.md +++ b/README.zh.md @@ -303,7 +303,25 @@ picoclaw-launcher-tui 让你十年前的旧手机焕发新生!将它变成你的 AI 助手。 -**方式一:Termux(现已可用)** +**方式一:APK 安装** + +预览: + + + + + + + + +
+ +从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux! + +**方式二:Termux** + +
+Terminal Launcher(适用于资源受限环境) 1. 安装 [Termux](https://github.com/termux/termux-app)(可从 [GitHub Releases](https://github.com/termux/termux-app/releases) 下载,或在 F-Droid / Google Play 中搜索) 2. 执行以下命令: @@ -320,13 +338,6 @@ termux-chroot ./picoclaw onboard # chroot 提供标准 Linux 文件系统布 PicoClaw on Termux -**方式二:APK 安装** - -从 [picoclaw.io](https://picoclaw.io/download/) 下载 APK 并直接安装,无需 Termux! - -
-Terminal Launcher(适用于资源受限环境) - 对于只有 `picoclaw` 核心二进制文件的极简环境(无 Launcher UI),可通过命令行和 JSON 配置文件完成所有配置。 **1. 初始化** diff --git a/assets/fui_log_page.jpg b/assets/fui_log_page.jpg new file mode 100644 index 0000000000000000000000000000000000000000..188c4698278599adf53412d2e0a0da054c4fe7b7 GIT binary patch literal 11049 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 literal 0 HcmV?d00001 diff --git a/assets/fui_main_page.jpg b/assets/fui_main_page.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f9c5b5c348ef7367ab30fbfb6def8a4b72327ad4 GIT binary patch literal 34641 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 OdEbFYeEVd!7>(^ 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

#EAkwEBzd`c z)?e~CUA0B-8?ZG-LlHx!v!7!XCSnI|^KYJ3l2=azcEVMp)$^Or?78uo2xs+^p+(t! z<$+dHrsYvR*uxi4P02gz57wZY-wE!kSXGVqym)5-%FistWmP6-IxNJA4_IhbV|8sG z+{?6v&pP=aXBePK(DSS8uvkW(Jo0(c`z4zH=wx?~EV8i3Nj=ehGtLq_o;$p8@iJ99 zVYXFjnG$kp+Yd9oL^J$sIIn7JeKoBt!K0-I0rhL_uUft)tV)Z*_hiST2$Q2yD<#-N}dNCE@6heeII zPv-Ff1Va&Qumt$<+tr68KCqAzwXuZ)T}GZsggKm6#e9=Sw#*s{D8XRsijeiOPq$mm zl2qVjUN&?gH`uSlWjUe?*3Gx*mcqllGQZkZt;@0uX$jHuJW&h=+cOul!zNkhq3@zh zR?L--U^VVe%@ED36O|^mY=mOqiJ+b?R$FGNe{SE8i4r2Dy$-UIc&u%zkkl!kTe~i%v0M3e? z1ptn;xxRk#E1$D(3w-sr70UrQ3{4(JRV0<8t*Uxo-uV8utYzhkbzO>#MmS_WqB$nu zVcI3>+gS|8HF1 z89|6BQq*Qs6UFW3es?3vFGYkxv`UzPe>^E>OPy36;HXGV{PIEem!vR2cbA{l)G%d= zLN`h`EbBsk{g#-j^4XFow#bMT$b)=T{Cnr`pL^U*CMSG#bN!LCV|Ht(P8~XZ4RZY+ zwMvkuY~u|Ktlsnv2xYrFDmFAQGal#o!@8{mf|zrsUlo+4r*Dj-oEMSkTR2fry7U@& zdtogp&B6r*Ud-Mc`=iMH|1Gi|t2UvJG{Dl6ad?w+0)DJ#L1 zKaevlrmjcwMUke6>d*+4#b^6e!9f8InX_ACa~7VT6MnY-_7~k}fA6aPp{)0q)}_`5 fx39k%f3eRZs}vCNTjt(-@-f}sWB-x4-SPhcWY>n- literal 0 HcmV?d00001 From bae4342af1d0a27aa37c52c6e1689d340aaa7048 Mon Sep 17 00:00:00 2001 From: Mauro Date: Thu, 2 Apr 2026 12:49:08 +0200 Subject: [PATCH 13/16] Feat/tool read_file by lines (#1981) * feat(tool): read_file tool by lines * fix test * restore old bytes read_file tool * unified read_file tool * revert * fix doc * fix test * fix doc * fix offset * fix default start_line * fix line format * fix bug * removed legacy test * enhanced infos * improvements * feat(tool): read_file tool by lines --- config/config.example.json | 3 +- docs/configuration.md | 60 +++++ docs/it/configuration.md | 219 ----------------- pkg/agent/instance.go | 7 +- pkg/agent/instance_test.go | 41 ++++ pkg/config/config.go | 21 +- pkg/config/config_test.go | 7 + pkg/config/defaults.go | 1 + pkg/tools/filesystem.go | 382 ++++++++++++++++++++++++++++- pkg/tools/filesystem_test.go | 449 ++++++++++++++++++++++++++++++++--- 10 files changed, 936 insertions(+), 254 deletions(-) delete mode 100644 docs/it/configuration.md diff --git a/config/config.example.json b/config/config.example.json index bedd543d7..f0cce6d72 100644 --- a/config/config.example.json +++ b/config/config.example.json @@ -421,7 +421,8 @@ "enabled": true }, "read_file": { - "enabled": true + "enabled": true, + "mode": "bytes" }, "send_tts": { "enabled": false diff --git a/docs/configuration.md b/docs/configuration.md index 58930cbfa..7a5902f58 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -301,6 +301,66 @@ Even with `restrict_to_workspace: false`, the `exec` tool blocks these dangerous | `tools.allow_read_paths` | string[] | `[]` | Additional paths allowed for reading outside workspace | | `tools.allow_write_paths` | string[] | `[]` | Additional paths allowed for writing outside workspace | +### Read File Mode + +`read_file` has two mutually exclusive implementations selected by config. PicoClaw registers exactly one of them at startup: + +| Config Key | Type | Default | Description | +|------------|------|---------|-------------| +| `tools.read_file.enabled` | bool | `true` | Enables the `read_file` tool | +| `tools.read_file.mode` | string | `bytes` | Selects the `read_file` implementation: `bytes` or `lines` | +| `tools.read_file.max_read_file_size` | int | `65536` | Maximum bytes returned by `read_file` | + +#### Mode: `bytes` + +Optimized for arbitrary files and binary-safe pagination. + +Parameters: + +* `path` (required): File path +* `offset` (optional): Starting byte offset, default `0` +* `length` (optional): Maximum number of bytes to read, default `max_read_file_size` + +Use `bytes` when: + +* You may read binary files +* You want deterministic byte-range pagination + +#### Mode: `lines` + +Text-oriented behavior, optimized for source files, markdown, logs, and configs. The tool reads sequentially by line and stops when the configured byte budget is reached. + +Parameters: + +* `path` (required): File path +* `start_line` (optional): Starting line number, 1-indexed and inclusive, default `1` +* `max_lines` (optional): Maximum number of lines to read, default = all remaining lines until EOF or byte budget + +Behavior notes: + +* Binary-looking files are rejected with guidance to switch `read_file` to `mode = bytes` +* Extremely long single lines are truncated rather than skipped + +Use `mode = lines` when: + +* The agent mostly reads text files +* You want line-based pagination in prompts and tool calls +* You want cleaner chunks for code review, logs, and documentation + +#### Example + +```json +{ + "tools": { + "read_file": { + "enabled": true, + "mode": "lines", + "max_read_file_size": 65536 + } + } +} +``` + ### Exec Security | Config Key | Type | Default | Description | diff --git a/docs/it/configuration.md b/docs/it/configuration.md deleted file mode 100644 index 6a79a9543..000000000 --- a/docs/it/configuration.md +++ /dev/null @@ -1,219 +0,0 @@ -# ⚙️ Guida alla Configurazione - -> Torna al [README](../../README.md) - -## ⚙️ Configurazione - -File di configurazione: `~/.picoclaw/config.json` - -### Variabili d'Ambiente - -Puoi sovrascrivere i percorsi predefiniti usando variabili d'ambiente. Questo è utile per installazioni portatili, distribuzioni containerizzate, o per eseguire picoclaw come servizio di sistema. Queste variabili sono indipendenti e controllano percorsi diversi. - -| Variabile | Descrizione | Percorso Predefinito | -|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------|---------------------------| -| `PICOCLAW_CONFIG` | Sovrascrive il percorso al file di configurazione. Indica direttamente a picoclaw quale `config.json` caricare, ignorando tutte le altre posizioni. | `~/.picoclaw/config.json` | -| `PICOCLAW_HOME` | Sovrascrive la directory radice per i dati di picoclaw. Modifica la posizione predefinita del `workspace` e delle altre directory dati. | `~/.picoclaw` | - -**Esempi:** - -```bash -# Esegui picoclaw usando un file di configurazione specifico -# Il percorso del workspace verrà letto da quel file di configurazione -PICOCLAW_CONFIG=/etc/picoclaw/production.json picoclaw gateway - -# Esegui picoclaw con tutti i dati salvati in /opt/picoclaw -# La configurazione verrà caricata dal percorso predefinito ~/.picoclaw/config.json -# Il workspace verrà creato in /opt/picoclaw/workspace -PICOCLAW_HOME=/opt/picoclaw picoclaw agent - -# Usa entrambi per un setup completamente personalizzato -PICOCLAW_HOME=/srv/picoclaw PICOCLAW_CONFIG=/srv/picoclaw/main.json picoclaw gateway -``` - -### Struttura del Workspace - -PicoClaw salva i dati nel workspace configurato (predefinito: `~/.picoclaw/workspace`): - -``` -~/.picoclaw/workspace/ -├── sessions/ # Sessioni di conversazione e cronologia -├── memory/ # Memoria a lungo termine (MEMORY.md) -├── state/ # Stato persistente (ultimo canale, ecc.) -├── cron/ # Database dei job pianificati -├── skills/ # Skill personalizzate -├── AGENTS.md # Guida al comportamento dell'agent -├── HEARTBEAT.md # Prompt per task periodici (controllato ogni 30 min) -├── IDENTITY.md # Identità dell'agent -├── SOUL.md # Anima dell'agent -└── USER.md # Preferenze dell'utente -``` - -> **Nota:** Le modifiche a `AGENTS.md`, `SOUL.md`, `USER.md`, `IDENTITY.md` e `memory/MEMORY.md` vengono rilevate automaticamente a runtime tramite il tracciamento della data di modifica (mtime). **Non è necessario riavviare il gateway** dopo aver modificato questi file — l'agent caricherà il nuovo contenuto alla prossima richiesta. - -### Sorgenti delle Skill - -Per impostazione predefinita, le skill vengono caricate da: - -1. `~/.picoclaw/workspace/skills` (workspace) -2. `~/.picoclaw/skills` (globale) -3. `/skills` (builtin) - -Per configurazioni avanzate/di test, puoi sovrascrivere la directory radice delle skill builtin con: - -```bash -export PICOCLAW_BUILTIN_SKILLS=/path/to/skills -``` - -### Politica Unificata di Esecuzione dei Comandi - -- I comandi slash generici vengono eseguiti tramite un unico percorso in `pkg/agent/loop.go` via `commands.Executor`. -- Gli adattatori dei canali non consumano più localmente i comandi generici; inoltrano il testo in entrata al percorso bus/agent. Telegram registra ancora automaticamente i comandi supportati all'avvio. -- Un comando slash sconosciuto (ad esempio `/foo`) viene passato all'elaborazione LLM come se fosse un messaggio dell'utente. -- Un comando registrato ma non supportato sul canale corrente (ad esempio `/show` su WhatsApp) restituisce un errore esplicito all'utente e interrompe l'elaborazione. - -### 🔒 Sandbox di Sicurezza - -PicoClaw esegue in un ambiente sandboxed per impostazione predefinita. L'agent può accedere solo ai file ed eseguire comandi all'interno del workspace configurato. - -#### Configurazione Predefinita - -```json -{ - "agents": { - "defaults": { - "workspace": "~/.picoclaw/workspace", - "restrict_to_workspace": true - } - } -} -``` - -| Opzione | Predefinito | Descrizione | -| ----------------------- | ----------------------- | ---------------------------------------------------- | -| `workspace` | `~/.picoclaw/workspace` | Directory di lavoro dell'agent | -| `restrict_to_workspace` | `true` | Limita l'accesso a file/comandi al workspace | - -#### Strumenti Protetti - -Quando `restrict_to_workspace: true`, i seguenti strumenti sono in sandbox: - -| Strumento | Funzione | Restrizione | -| ------------- | ------------------------- | ---------------------------------------------------- | -| `read_file` | Legge file | Solo file all'interno del workspace | -| `write_file` | Scrive file | Solo file all'interno del workspace | -| `list_dir` | Elenca directory | Solo directory all'interno del workspace | -| `edit_file` | Modifica file | Solo file all'interno del workspace | -| `append_file` | Aggiunge ai file | Solo file all'interno del workspace | -| `exec` | Esegue comandi | I percorsi dei comandi devono essere nel workspace | - -#### Protezione Exec Aggiuntiva - -Anche con `restrict_to_workspace: false`, lo strumento `exec` blocca questi comandi pericolosi: - -* `rm -rf`, `del /f`, `rmdir /s` — Cancellazione di massa -* `format`, `mkfs`, `diskpart` — Formattazione del disco -* `dd if=` — Imaging del disco -* Scrittura su `/dev/sd[a-z]` — Scritture dirette su disco -* `shutdown`, `reboot`, `poweroff` — Spegnimento del sistema -* Fork bomb `:(){ :|:& };:` - -### Controllo Accesso ai File - -| Chiave di configurazione | Tipo | Predefinito | Descrizione | -|--------------------------|------|-------------|-------------| -| `tools.allow_read_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la lettura al di fuori del workspace | -| `tools.allow_write_paths` | string[] | `[]` | Percorsi aggiuntivi consentiti per la scrittura al di fuori del workspace | - -### Sicurezza Exec - -| Chiave di configurazione | Tipo | Predefinito | Descrizione | -|--------------------------|------|-------------|-------------| -| `tools.exec.allow_remote` | bool | `false` | Consente lo strumento exec da canali remoti (Telegram/Discord ecc.) | -| `tools.exec.enable_deny_patterns` | bool | `true` | Abilita l'intercettazione dei comandi pericolosi | -| `tools.exec.custom_deny_patterns` | string[] | `[]` | Pattern regex personalizzati da bloccare | -| `tools.exec.custom_allow_patterns` | string[] | `[]` | Pattern regex personalizzati da consentire | - -> **Nota di sicurezza:** La protezione dei symlink è abilitata per impostazione predefinita — tutti i percorsi file vengono risolti tramite `filepath.EvalSymlinks` prima del confronto con la whitelist, prevenendo attacchi di escape tramite symlink. - -#### Limitazione Nota: Processi Figlio degli Strumenti di Build - -Il controllo di sicurezza exec ispeziona solo la riga di comando avviata direttamente da PicoClaw. Non ispeziona ricorsivamente i processi figlio generati da strumenti di sviluppo consentiti come `make`, `go run`, `cargo`, `npm run` o script di build personalizzati. - -Ciò significa che un comando di primo livello può comunque compilare o avviare altri binari dopo aver superato il controllo iniziale. In pratica, tratta gli script di build, i Makefile, gli script di pacchetti e i binari generati come codice eseguibile che richiede lo stesso livello di revisione di un comando shell diretto. - -Per ambienti ad alto rischio: - -* Esamina gli script di build prima dell'esecuzione. -* Preferisci l'approvazione/revisione manuale per i workflow di compilazione ed esecuzione. -* Esegui PicoClaw in un container o VM se hai bisogno di un isolamento più forte di quello fornito dal controllo integrato. - -#### Esempi di Errore - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (path outside working dir)} -``` - -``` -[ERROR] tool: Tool execution failed -{tool=exec, error=Command blocked by safety guard (dangerous pattern detected)} -``` - -#### Disabilitare le Restrizioni (Rischio di Sicurezza) - -Se hai bisogno che l'agent acceda a percorsi al di fuori del workspace: - -**Metodo 1: File di configurazione** - -```json -{ - "agents": { - "defaults": { - "restrict_to_workspace": false - } - } -} -``` - -**Metodo 2: Variabile d'ambiente** - -```bash -export PICOCLAW_AGENTS_DEFAULTS_RESTRICT_TO_WORKSPACE=false -``` - -> ⚠️ **Attenzione**: Disabilitare questa restrizione consente all'agent di accedere a qualsiasi percorso sul tuo sistema. Usare con cautela solo in ambienti controllati. - -#### Coerenza dei Confini di Sicurezza - -L'impostazione `restrict_to_workspace` si applica in modo coerente a tutti i percorsi di esecuzione: - -| Percorso di esecuzione | Confine di sicurezza | -| ---------------------- | --------------------------------- | -| Main Agent | `restrict_to_workspace` ✅ | -| Subagent / Spawn | Eredita la stessa restrizione ✅ | -| Heartbeat tasks | Eredita la stessa restrizione ✅ | - -Tutti i percorsi condividono la stessa restrizione del workspace — non è possibile aggirare il confine di sicurezza tramite subagent o task pianificati. - -### Heartbeat (Task Periodici) - -PicoClaw può eseguire task periodici automaticamente. Crea un file `HEARTBEAT.md` nel tuo workspace: - -```markdown -# Periodic Tasks - -- Check my email for important messages -- Review my calendar for upcoming events -- Check the weather forecast -``` - -L'agent leggerà questo file ogni 30 minuti (configurabile) ed eseguirà tutti i task usando gli strumenti disponibili. - -#### Task Asincroni con Spawn - -Per task di lunga durata (ricerca web, chiamate API), usa lo strumento `spawn` per creare un **subagent**: - -```markdown -# Periodic Tasks -``` diff --git a/pkg/agent/instance.go b/pkg/agent/instance.go index 880725660..bacfa49c5 100644 --- a/pkg/agent/instance.go +++ b/pkg/agent/instance.go @@ -77,7 +77,12 @@ func NewAgentInstance( if cfg.Tools.IsToolEnabled("read_file") { maxReadFileSize := cfg.Tools.ReadFile.MaxReadFileSize - toolsRegistry.Register(tools.NewReadFileTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + switch cfg.Tools.ReadFile.EffectiveMode() { + case config.ReadFileModeLines: + toolsRegistry.Register(tools.NewReadFileLinesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + default: + toolsRegistry.Register(tools.NewReadFileBytesTool(workspace, readRestrict, maxReadFileSize, allowReadPaths)) + } } if cfg.Tools.IsToolEnabled("write_file") { toolsRegistry.Register(tools.NewWriteFileTool(workspace, restrict, allowWritePaths)) diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index e296a18cb..7c043d88f 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -248,6 +248,47 @@ func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { } } +func TestNewAgentInstance_ReadFileModeSelectsSchema(t *testing.T) { + workspace := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: workspace, + ModelName: "test-model", + }, + }, + Tools: config.ToolsConfig{ + ReadFile: config.ReadFileToolConfig{ + Enabled: true, + Mode: config.ReadFileModeLines, + MaxReadFileSize: 4096, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + readTool, ok := agent.Tools.Get("read_file") + if !ok { + t.Fatal("read_file tool not registered") + } + + params := readTool.Parameters() + props, _ := params["properties"].(map[string]any) + if _, ok := props["start_line"]; !ok { + t.Fatalf("expected line-mode schema to expose start_line, got %#v", props) + } + if _, ok := props["max_lines"]; !ok { + t.Fatalf("expected line-mode schema to expose max_lines, got %#v", props) + } + if _, ok := props["offset"]; ok { + t.Fatalf("did not expect line-mode schema to expose offset, got %#v", props) + } + if _, ok := props["length"]; ok { + t.Fatalf("did not expect line-mode schema to expose length, got %#v", props) + } +} + func TestNewAgentInstance_InvalidExecConfigDoesNotExit(t *testing.T) { workspace := t.TempDir() diff --git a/pkg/config/config.go b/pkg/config/config.go index fcedf45b9..30e5e1dd9 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -803,8 +803,25 @@ type MediaCleanupConfig struct { } type ReadFileToolConfig struct { - Enabled bool `json:"enabled"` - MaxReadFileSize int `json:"max_read_file_size"` + Enabled bool `json:"enabled"` + Mode string `json:"mode"` + MaxReadFileSize int `json:"max_read_file_size"` +} + +const ( + ReadFileModeBytes = "bytes" + ReadFileModeLines = "lines" +) + +func (c ReadFileToolConfig) EffectiveMode() string { + switch strings.ToLower(strings.TrimSpace(c.Mode)) { + case ReadFileModeLines: + return ReadFileModeLines + case "", ReadFileModeBytes: + return ReadFileModeBytes + default: + return ReadFileModeBytes + } } type ToolsConfig struct { diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 278dfa43a..a1410f940 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -317,6 +317,13 @@ func TestDefaultConfig_WebTools(t *testing.T) { } } +func TestDefaultConfig_ReadFileMode(t *testing.T) { + cfg := DefaultConfig() + if cfg.Tools.ReadFile.EffectiveMode() != ReadFileModeBytes { + t.Fatalf("expected default read_file mode %q, got %q", ReadFileModeBytes, cfg.Tools.ReadFile.EffectiveMode()) + } +} + func TestSaveConfig_FilePermissions(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("file permission bits are not enforced on Windows") diff --git a/pkg/config/defaults.go b/pkg/config/defaults.go index a9a107975..39cdb89e6 100644 --- a/pkg/config/defaults.go +++ b/pkg/config/defaults.go @@ -487,6 +487,7 @@ func DefaultConfig() *Config { }, ReadFile: ReadFileToolConfig{ Enabled: true, + Mode: ReadFileModeBytes, MaxReadFileSize: 64 * 1024, // 64KB }, Spawn: ToolConfig{ diff --git a/pkg/tools/filesystem.go b/pkg/tools/filesystem.go index 39d45013d..0b9a16950 100644 --- a/pkg/tools/filesystem.go +++ b/pkg/tools/filesystem.go @@ -1,18 +1,22 @@ package tools import ( + "bufio" + "bytes" "context" "errors" "fmt" "io" "io/fs" "math" + "net/http" "os" "path/filepath" "regexp" "strconv" "strings" "time" + "unicode/utf8" "github.com/sipeed/picoclaw/pkg/fileutil" "github.com/sipeed/picoclaw/pkg/logger" @@ -20,7 +24,11 @@ import ( const MaxReadFileSize = 64 * 1024 // 64KB limit to avoid context overflow -func validatePathWithAllowPaths(path, workspace string, restrict bool, patterns []*regexp.Regexp) (string, error) { +func validatePathWithAllowPaths( + path, workspace string, + restrict bool, + patterns []*regexp.Regexp, +) (string, error) { if workspace == "" { return path, fmt.Errorf("workspace is not defined") } @@ -253,6 +261,11 @@ type ReadFileTool struct { maxSize int64 } +type ReadFileLinesTool struct { + fs fileSystem + maxSize int64 +} + func NewReadFileTool( workspace string, restrict bool, @@ -275,14 +288,53 @@ func NewReadFileTool( } } +func NewReadFileBytesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileTool { + return NewReadFileTool(workspace, restrict, maxReadFileSize, allowPaths...) +} + +func NewReadFileLinesTool( + workspace string, + restrict bool, + maxReadFileSize int, + allowPaths ...[]*regexp.Regexp, +) *ReadFileLinesTool { + var patterns []*regexp.Regexp + if len(allowPaths) > 0 { + patterns = allowPaths[0] + } + + maxSize := int64(maxReadFileSize) + if maxSize <= 0 { + maxSize = MaxReadFileSize + } + + return &ReadFileLinesTool{ + fs: buildFs(workspace, restrict, patterns), + maxSize: maxSize, + } +} + func (t *ReadFileTool) Name() string { return "read_file" } +func (t *ReadFileLinesTool) Name() string { + return "read_file" +} + func (t *ReadFileTool) Description() string { return "Read the contents of a file. Supports pagination via `offset` and `length`." } +func (t *ReadFileLinesTool) Description() string { + return "Read a UTF-8 text file from the filesystem. Output always includes line numbers in the format `LINE_NUMBER|LINE_CONTENT` (1-indexed). Supports partial reads via `start_line` and `max_lines` for large text files." +} + func (t *ReadFileTool) Parameters() map[string]any { return map[string]any{ "type": "object", @@ -306,6 +358,28 @@ func (t *ReadFileTool) Parameters() map[string]any { } } +func (t *ReadFileLinesTool) Parameters() map[string]any { + return map[string]any{ + "type": "object", + "properties": map[string]any{ + "path": map[string]any{ + "type": "string", + "description": "Path to the file to read.", + }, + "start_line": map[string]any{ + "type": "integer", + "description": "Line number to start reading from (1-indexed, inclusive).", + "default": 1, + }, + "max_lines": map[string]any{ + "type": "integer", + "description": "Maximum number of lines to read.", + }, + }, + "required": []string{"path"}, + } +} + func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolResult { path, ok := args["path"].(string) if !ok { @@ -447,6 +521,302 @@ func (t *ReadFileTool) Execute(ctx context.Context, args map[string]any) *ToolRe return NewToolResult(header + "\n\n" + string(data)) } +func (t *ReadFileLinesTool) Execute(ctx context.Context, args map[string]any) *ToolResult { + path, ok := args["path"].(string) + if !ok { + return ErrorResult("path is required") + } + + startLine, err := getInt64Arg(args, "start_line", 1) + if err != nil { + return ErrorResult(err.Error()) + } + if startLine < 1 { + return ErrorResult("start_line must be >= 1") + } + if _, exists := args["offset"]; exists { + return ErrorResult("offset is not supported in line mode; use start_line") + } + if _, exists := args["length"]; exists { + return ErrorResult("length is not supported in line mode; use max_lines") + } + if _, exists := args["limit"]; exists { + return ErrorResult("limit is not supported in line mode; use max_lines") + } + + limit := int64(-1) + if raw, exists := args["max_lines"]; exists && raw != nil { + limit, err = getInt64Arg(args, "max_lines", -1) + if err != nil { + return ErrorResult(err.Error()) + } + if limit <= 0 { + return ErrorResult("max_lines, if provided, must be > 0") + } + } + + file, err := t.fs.Open(path) + if err != nil { + return ErrorResult(err.Error()) + } + defer file.Close() + + if info, statErr := file.Stat(); statErr == nil && info.IsDir() { + return ErrorResult(fmt.Sprintf("failed to open file: path is a directory: %s", path)) + } + + sample := make([]byte, 512) + sampleN, readErr := file.Read(sample) + if readErr != nil && readErr != io.EOF { + return ErrorResult(fmt.Sprintf("failed to read file: %v", readErr)) + } + sample = sample[:sampleN] + if isBinaryReadFileData(sample) { + return ErrorResult("file appears to be binary; switch read_file mode to 'bytes' for byte-based inspection") + } + + reader := bufio.NewReaderSize(io.MultiReader(bytes.NewReader(sample), file), 32*1024) + + var content strings.Builder + lineIndex := int64(1) + var linesRead int64 + var fileBytesRead int64 + var outputBytesRead int64 + var reachedEOF bool + var byteBudgetTruncated bool + var lineTruncated bool + + for lineIndex < startLine { + hasLine, consumeErr := consumeNextLine(reader) + if consumeErr != nil { + return ErrorResult(fmt.Sprintf("failed to read file content: %v", consumeErr)) + } + if !hasLine { + reachedEOF = true + break + } + lineIndex++ + } + + for !reachedEOF && (limit < 0 || linesRead < limit) { + prefix := formatReadFileLinePrefix(lineIndex) + remaining := t.maxSize - outputBytesRead - int64(len(prefix)) + if remaining <= 0 { + byteBudgetTruncated = true + break + } + + line, complete, hasLine, readLineErr := readNextLinePrefix(reader, remaining) + if readLineErr != nil { + return ErrorResult(fmt.Sprintf("failed to read file content: %v", readLineErr)) + } + if !hasLine { + reachedEOF = true + break + } + + content.WriteString(prefix) + content.Write(line) + fileBytesRead += int64(len(line)) + outputBytesRead += int64(len(prefix) + len(line)) + linesRead++ + lineIndex++ + + if !complete { + byteBudgetTruncated = true + lineTruncated = true + break + } + } + + if !reachedEOF && !lineTruncated { + hasMoreContent, peekErr := readerHasMoreContent(reader) + if peekErr != nil { + return ErrorResult(fmt.Sprintf("failed to inspect remaining file content: %v", peekErr)) + } + if !hasMoreContent { + reachedEOF = true + byteBudgetTruncated = false + } + } + + if linesRead == 0 && content.Len() == 0 { + return NewToolResult(fmt.Sprintf("[END OF FILE - no content at or after start_line=%d]", startLine)) + } + + start := startLine + endLine := startLine + linesRead - 1 + displayPath := filepath.Base(path) + header := fmt.Sprintf( + "[file: %s | read: lines %d-%d (1-indexed) | file_bytes: %d | output_bytes: %d]", + displayPath, start, endLine, fileBytesRead, outputBytesRead, + ) + + switch { + case lineTruncated: + header += fmt.Sprintf( + "\n[TRUNCATED - line %d exceeded the %d byte read budget and was cut mid-line.]", + endLine, + t.maxSize, + ) + case byteBudgetTruncated: + if limit > 0 { + header += fmt.Sprintf( + "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d and max_lines=%d to continue at the next line.]", + startLine+linesRead, + limit, + ) + } else { + header += fmt.Sprintf( + "\n[TRUNCATED - byte budget reached. Call read_file again with start_line=%d to continue at the next line.]", + startLine+linesRead, + ) + } + case !reachedEOF && limit > 0 && linesRead >= limit: + header += fmt.Sprintf( + "\n[PARTIAL - more content remains. Call read_file again with start_line=%d and max_lines=%d to continue.]", + startLine+linesRead, + limit, + ) + default: + header += "\n[END OF FILE - no further content.]" + } + + logger.DebugCF("tool", "ReadFileTool execution completed successfully", + map[string]any{ + "path": path, + "lines_read": linesRead, + "file_bytes_read": fileBytesRead, + "output_bytes_read": outputBytesRead, + "truncated": byteBudgetTruncated, + "tool": t.Name(), + }) + + return NewToolResult(header + "\n\n" + content.String()) +} + +func formatReadFileLinePrefix(lineNumber int64) string { + return strconv.FormatInt(lineNumber, 10) + "|" +} + +func isBinaryReadFileData(data []byte) bool { + if len(data) == 0 { + return false + } + + sample := data + if len(sample) > 512 { + sample = sample[:512] + } + + if bytes.IndexByte(sample, 0) >= 0 { + return true + } + + contentType := http.DetectContentType(sample) + if strings.HasPrefix(contentType, "text/") { + return false + } + if strings.HasSuffix(contentType, "/json") || + strings.HasSuffix(contentType, "+json") || + strings.HasSuffix(contentType, "/xml") || + strings.HasSuffix(contentType, "+xml") || + strings.Contains(contentType, "javascript") { + return false + } + + if !utf8.Valid(sample) { + return true + } + + controlChars := 0 + for _, b := range sample { + if b < 0x20 && b != '\n' && b != '\r' && b != '\t' && b != '\f' && b != '\b' { + controlChars++ + } + } + + return float64(controlChars)/float64(len(sample)) > 0.1 +} + +func consumeNextLine(reader *bufio.Reader) (bool, error) { + sawData := false + + for { + fragment, err := reader.ReadSlice('\n') + if len(fragment) > 0 { + sawData = true + } + + switch { + case err == nil: + return true, nil + case errors.Is(err, bufio.ErrBufferFull): + continue + case errors.Is(err, io.EOF): + return sawData, nil + default: + return false, err + } + } +} + +func readNextLinePrefix(reader *bufio.Reader, maxBytes int64) ([]byte, bool, bool, error) { + if maxBytes <= 0 { + return nil, false, false, nil + } + + var out bytes.Buffer + sawData := false + complete := true + + for { + fragment, err := reader.ReadSlice('\n') + if len(fragment) > 0 { + sawData = true + if remaining := maxBytes - int64(out.Len()); remaining > 0 { + take := len(fragment) + if int64(take) > remaining { + take = int(remaining) + complete = false + } + out.Write(fragment[:take]) + } else { + complete = false + } + } + + switch { + case err == nil: + return out.Bytes(), complete, sawData, nil + case errors.Is(err, bufio.ErrBufferFull): + if !complete { + return out.Bytes(), false, true, nil + } + continue + case errors.Is(err, io.EOF): + if !sawData { + return nil, true, false, nil + } + return out.Bytes(), complete, true, nil + default: + return nil, false, false, err + } + } +} + +func readerHasMoreContent(reader *bufio.Reader) (bool, error) { + _, err := reader.Peek(1) + switch { + case err == nil: + return true, nil + case errors.Is(err, io.EOF): + return false, nil + default: + return false, err + } +} + // getInt64Arg extracts an integer argument from the args map, returning the // provided default if the key is absent. func getInt64Arg(args map[string]any, key string, defaultVal int64) (int64, error) { @@ -483,7 +853,11 @@ type WriteFileTool struct { fs fileSystem } -func NewWriteFileTool(workspace string, restrict bool, allowPaths ...[]*regexp.Regexp) *WriteFileTool { +func NewWriteFileTool( + workspace string, + restrict bool, + allowPaths ...[]*regexp.Regexp, +) *WriteFileTool { var patterns []*regexp.Regexp if len(allowPaths) > 0 { patterns = allowPaths[0] @@ -536,7 +910,9 @@ func (t *WriteFileTool) Execute(ctx context.Context, args map[string]any) *ToolR if !overwrite { if _, err := t.fs.Open(path); err == nil { - return ErrorResult(fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path)) + return ErrorResult( + fmt.Sprintf("file: %s already exists. Set overwrite=true to replace.", path), + ) } } diff --git a/pkg/tools/filesystem_test.go b/pkg/tools/filesystem_test.go index 0b4dd310b..bfbc1f46e 100644 --- a/pkg/tools/filesystem_test.go +++ b/pkg/tools/filesystem_test.go @@ -18,7 +18,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { testFile := filepath.Join(tmpDir, "test.txt") os.WriteFile(testFile, []byte("test content"), 0o644) - tool := NewReadFileTool("", false, MaxReadFileSize) + tool := NewReadFileBytesTool("", false, MaxReadFileSize) ctx := context.Background() args := map[string]any{ "path": testFile, @@ -45,7 +45,7 @@ func TestFilesystemTool_ReadFile_Success(t *testing.T) { // TestFilesystemTool_ReadFile_NotFound verifies error handling for missing file func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { - tool := NewReadFileTool("", false, MaxReadFileSize) + tool := NewReadFileBytesTool("", false, MaxReadFileSize) ctx := context.Background() args := map[string]any{ "path": "/nonexistent_file_12345.txt", @@ -59,8 +59,13 @@ func TestFilesystemTool_ReadFile_NotFound(t *testing.T) { } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to open file") && !strings.Contains(result.ForUser, "failed to read") { - t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + if !strings.Contains(result.ForLLM, "failed to open file") && + !strings.Contains(result.ForUser, "failed to open") { + t.Errorf( + "Expected error message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } @@ -78,7 +83,8 @@ func TestFilesystemTool_ReadFile_MissingPath(t *testing.T) { } // Should mention required parameter - if !strings.Contains(result.ForLLM, "path is required") && !strings.Contains(result.ForUser, "path is required") { + if !strings.Contains(result.ForLLM, "path is required") && + !strings.Contains(result.ForUser, "path is required") { t.Errorf("Expected 'path is required' message, got ForLLM: %s", result.ForLLM) } } @@ -297,7 +303,12 @@ func TestFilesystemTool_WriteFile_OverwriteSandboxed(t *testing.T) { "content": "replaced in sandbox", "overwrite": true, }) - assert.False(t, result.IsError, "expected success in sandbox mode with overwrite=true, got: %s", result.ForLLM) + assert.False( + t, + result.IsError, + "expected success in sandbox mode with overwrite=true, got: %s", + result.ForLLM, + ) data, err := os.ReadFile(filepath.Join(workspace, testFile)) assert.NoError(t, err) @@ -325,7 +336,8 @@ func TestFilesystemTool_ListDir_Success(t *testing.T) { } // Should list files and directories - if !strings.Contains(result.ForLLM, "file1.txt") || !strings.Contains(result.ForLLM, "file2.txt") { + if !strings.Contains(result.ForLLM, "file1.txt") || + !strings.Contains(result.ForLLM, "file2.txt") { t.Errorf("Expected files in listing, got: %s", result.ForLLM) } if !strings.Contains(result.ForLLM, "subdir") { @@ -349,8 +361,13 @@ func TestFilesystemTool_ListDir_NotFound(t *testing.T) { } // Should contain error message - if !strings.Contains(result.ForLLM, "failed to read") && !strings.Contains(result.ForUser, "failed to read") { - t.Errorf("Expected error message, got ForLLM: %s, ForUser: %s", result.ForLLM, result.ForUser) + if !strings.Contains(result.ForLLM, "failed to read") && + !strings.Contains(result.ForUser, "failed to read") { + t.Errorf( + "Expected error message, got ForLLM: %s, ForUser: %s", + result.ForLLM, + result.ForUser, + ) } } @@ -397,7 +414,8 @@ func TestFilesystemTool_ReadFile_RejectsSymlinkEscape(t *testing.T) { // os.Root might return different errors depending on platform/implementation // but it definitely should error. // Our wrapper returns "access denied or file not found" - if !strings.Contains(result.ForLLM, "access denied") && !strings.Contains(result.ForLLM, "file not found") && + if !strings.Contains(result.ForLLM, "access denied") && + !strings.Contains(result.ForLLM, "file not found") && !strings.Contains(result.ForLLM, "no such file") { t.Fatalf("expected symlink escape error, got: %s", result.ForLLM) } @@ -416,10 +434,20 @@ func TestFilesystemTool_EmptyWorkspace_AccessDenied(t *testing.T) { }) // We EXPECT IsError=true (access blocked due to empty workspace) - assert.True(t, result.IsError, "Security Regression: Empty workspace allowed access! content: %s", result.ForLLM) + assert.True( + t, + result.IsError, + "Security Regression: Empty workspace allowed access! content: %s", + result.ForLLM, + ) // Verify it failed for the right reason - assert.Contains(t, result.ForLLM, "workspace is not defined", "Expected 'workspace is not defined' error") + assert.Contains( + t, + result.ForLLM, + "workspace is not defined", + "Expected 'workspace is not defined' error", + ) } // TestRootMkdirAll verifies that root.MkdirAll (used by atomicWriteFileInRoot) handles all cases: @@ -653,7 +681,10 @@ func TestWhitelistFs_BlocksSymlinkEscapeInAllowedDir(t *testing.T) { patterns := []*regexp.Regexp{regexp.MustCompile(`^` + regexp.QuoteMeta(allowedDir))} tool := NewReadFileTool(workspace, true, MaxReadFileSize, patterns) - result := tool.Execute(context.Background(), map[string]any{"path": filepath.Join(linkPath, "secret.txt")}) + result := tool.Execute( + context.Background(), + map[string]any{"path": filepath.Join(linkPath, "secret.txt")}, + ) if !result.IsError { t.Fatalf("expected symlink escape from allowed dir to be blocked, got: %s", result.ForLLM) } @@ -726,7 +757,6 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "pagination_test.txt") - // Create a test file with exactly 26 bytes of content fullContent := "abcdefghijklmnopqrstuvwxyz" err := os.WriteFile(testFile, []byte(fullContent), 0o644) if err != nil { @@ -748,15 +778,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { t.Fatalf("Chunk 1 failed: %s", result1.ForLLM) } - // Expect the first 10 characters if !strings.Contains(result1.ForLLM, "abcdefghij") { t.Errorf("Chunk 1 should contain 'abcdefghij', got: %s", result1.ForLLM) } - // Expect the header to indicate the file is truncated if !strings.Contains(result1.ForLLM, "[TRUNCATED") { t.Errorf("Chunk 1 header should indicate truncation, got: %s", result1.ForLLM) } - // Expect the header to suggest the next offset (10) if !strings.Contains(result1.ForLLM, "offset=10") { t.Errorf("Chunk 1 header should suggest next offset=10, got: %s", result1.ForLLM) } @@ -773,17 +800,14 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { t.Fatalf("Chunk 2 failed: %s", result2.ForLLM) } - // Expect the next 10 characters if !strings.Contains(result2.ForLLM, "klmnopqrst") { t.Errorf("Chunk 2 should contain 'klmnopqrst', got: %s", result2.ForLLM) } - // Expect the header to suggest the next offset (20) if !strings.Contains(result2.ForLLM, "offset=20") { t.Errorf("Chunk 2 header should suggest next offset=20, got: %s", result2.ForLLM) } // Step 3: Read the final chunk (remaining 6 bytes) --- - // We ask for 10 bytes, but only 6 are left in the file args3 := map[string]any{ "path": testFile, "offset": 20, @@ -795,16 +819,12 @@ func TestReadFileTool_ChunkedReading(t *testing.T) { t.Fatalf("Chunk 3 failed: %s", result3.ForLLM) } - // Expect the last 6 characters if !strings.Contains(result3.ForLLM, "uvwxyz") { t.Errorf("Chunk 3 should contain 'uvwxyz', got: %s", result3.ForLLM) } - // Expect the header to indicate the end of the file if !strings.Contains(result3.ForLLM, "[END OF FILE") { t.Errorf("Chunk 3 header should indicate end of file, got: %s", result3.ForLLM) } - - // Ensure no TRUNCATED message is present in the final chunk if strings.Contains(result3.ForLLM, "[TRUNCATED") { t.Errorf("Chunk 3 header should NOT indicate truncation, got: %s", result3.ForLLM) } @@ -816,7 +836,6 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) { tmpDir := t.TempDir() testFile := filepath.Join(tmpDir, "short.txt") - // create a file of only 5 bytes err := os.WriteFile(testFile, []byte("12345"), 0o644) if err != nil { t.Fatalf("Failed to write test file: %v", err) @@ -827,19 +846,393 @@ func TestReadFileTool_OffsetBeyondEOF(t *testing.T) { args := map[string]any{ "path": testFile, - "offset": int64(100), // Offset beyond the end of the file + "offset": int64(100), } result := tool.Execute(ctx, args) - // It should not be classified as a tool execution error if result.IsError { t.Errorf("A mistake was not expected, obtained IsError=true: %s", result.ForLLM) } - // Must return EXACTLY the string provided in the code expectedMsg := "[END OF FILE - no content at this offset]" if result.ForLLM != expectedMsg { t.Errorf("The message %q was expected, obtained: %q", expectedMsg, result.ForLLM) } } + +func TestReadFileLinesTool_ChunkedReading(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "pagination_lines.txt") + + fullContent := strings.Join([]string{ + "line 1", + "line 2", + "line 3", + "line 4", + "line 5", + "line 6", + }, "\n") + "\n" + err := os.WriteFile(testFile, []byte(fullContent), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + + result1 := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "max_lines": 2, + }) + if result1.IsError { + t.Fatalf("Chunk 1 failed: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "1|line 1\n2|line 2\n") { + t.Fatalf("expected first two lines, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "lines 1-2") { + t.Fatalf("expected line range 1-2, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "start_line=3") { + t.Fatalf("expected continuation start_line=3, got: %s", result1.ForLLM) + } + if !strings.Contains(result1.ForLLM, "max_lines=2") { + t.Fatalf("expected continuation max_lines=2, got: %s", result1.ForLLM) + } + + result2 := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 3, + "max_lines": 2, + }) + if result2.IsError { + t.Fatalf("Chunk 2 failed: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "3|line 3\n4|line 4\n") { + t.Fatalf("expected middle chunk, got: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "start_line=5") { + t.Fatalf("expected continuation start_line=5, got: %s", result2.ForLLM) + } + if !strings.Contains(result2.ForLLM, "max_lines=2") { + t.Fatalf("expected continuation max_lines=2, got: %s", result2.ForLLM) + } + + result3 := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 5, + "max_lines": 2, + }) + if result3.IsError { + t.Fatalf("Chunk 3 failed: %s", result3.ForLLM) + } + if !strings.Contains(result3.ForLLM, "5|line 5\n6|line 6\n") { + t.Fatalf("expected final chunk, got: %s", result3.ForLLM) + } + if !strings.Contains(result3.ForLLM, "[END OF FILE") { + t.Fatalf("expected EOF marker, got: %s", result3.ForLLM) + } +} + +func TestReadFileLinesTool_DefaultOffsetAndRemainingLines(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "default_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2\n3|line 3\n") { + t.Fatalf("expected remaining lines by default, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "lines 1-3") { + t.Fatalf("expected line range 1-3, got: %s", result.ForLLM) + } +} + +func TestReadFileTool_LegacyLengthUsesByteModeForText(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_bytes.txt") + + err := os.WriteFile(testFile, []byte("abcdefghijklmnopqrstuvwxyz"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileBytesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "offset": 10, + "length": 5, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "read: bytes 10-14") { + t.Fatalf("expected byte-based header, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "klmno") { + t.Fatalf("expected byte chunk content, got: %s", result.ForLLM) + } + if strings.Contains(result.ForLLM, "lines ") { + t.Fatalf("expected legacy byte mode, got line-based header: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_OffsetBeyondEOF(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "short_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": int64(100), + }) + if result.IsError { + t.Fatalf("unexpected error: %s", result.ForLLM) + } + if result.ForLLM != "[END OF FILE - no content at or after start_line=100]" { + t.Fatalf("unexpected EOF message: %q", result.ForLLM) + } +} + +func TestReadFileLinesTool_RegistryValidationSupportsMaxLinesAndRejectsLimit(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "registry_lines.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\nline 3\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + reg := NewToolRegistry() + reg.Register(NewReadFileLinesTool(tmpDir, false, MaxReadFileSize)) + + result := reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 1, + "max_lines": 1, + }) + if result.IsError { + t.Fatalf("expected max_lines to pass registry validation, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n") { + t.Fatalf("expected first line via max_lines, got: %s", result.ForLLM) + } + + result = reg.Execute(context.Background(), "read_file", map[string]any{ + "path": testFile, + "start_line": 2, + "limit": 1, + }) + if !result.IsError { + t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "unexpected property \"limit\"") { + t.Fatalf("expected registry validation error for limit, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_RejectsOffset(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_offset.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "offset": 1, + }) + if !result.IsError { + t.Fatalf("expected offset to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "offset is not supported in line mode; use start_line") { + t.Fatalf("unexpected error for offset in line mode: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_RejectsLength(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_length.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "length": 1, + }) + if !result.IsError { + t.Fatalf("expected length to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "length is not supported in line mode; use max_lines") { + t.Fatalf("unexpected error for length in line mode: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_RejectsLimit(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "legacy_limit.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + "limit": 1, + }) + if !result.IsError { + t.Fatalf("expected limit to be rejected, got success: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "limit is not supported in line mode; use max_lines") { + t.Fatalf("unexpected error for limit in line mode: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_BinaryFileRejected(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "binary.dat") + + data := []byte{0x00, 0x01, 'A', 'B', 'C', 'D', 'E', 'F'} + err := os.WriteFile(testFile, data, 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if !result.IsError { + t.Fatalf("expected binary file rejection in line mode, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "switch read_file mode to 'bytes'") { + t.Fatalf("expected binary file rejection message, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "mode to 'bytes'") { + t.Fatalf("expected suggestion to switch read_file mode, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_TruncatesSingleLongLineAtByteBudget(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "long_line.txt") + + content := "first line\n" + strings.Repeat("x", 70*1024) + "\n" + err := os.WriteFile(testFile, []byte(content), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "was cut mid-line") { + t.Fatalf("expected explicit mid-line truncation warning, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|first line\n") { + t.Fatalf("expected the first line with line prefix, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "2|") { + t.Fatalf("expected line prefix for the truncated line, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_NoTrailingNewline(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "no_trailing_newline.txt") + + err := os.WriteFile(testFile, []byte("line 1\nline 2"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, MaxReadFileSize) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|line 1\n2|line 2") { + t.Fatalf( + "expected final line without trailing newline to be preserved, got: %s", + result.ForLLM, + ) + } + if !strings.Contains(result.ForLLM, "[END OF FILE - no further content.]") { + t.Fatalf("expected EOF marker, got: %s", result.ForLLM) + } +} + +func TestReadFileLinesTool_ExactByteBudgetBoundaryIncludesPrefix(t *testing.T) { + tmpDir := t.TempDir() + testFile := filepath.Join(tmpDir, "exact_boundary.txt") + + err := os.WriteFile(testFile, []byte("1234567\nsecond line\n"), 0o644) + if err != nil { + t.Fatalf("Failed to write test file: %v", err) + } + + tool := NewReadFileLinesTool(tmpDir, false, 10) + result := tool.Execute(context.Background(), map[string]any{ + "path": testFile, + "start_line": 1, + }) + if result.IsError { + t.Fatalf("Execute() error = %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "1|1234567\n") { + t.Fatalf( + "expected first line to fit exactly in the byte budget with its prefix, got: %s", + result.ForLLM, + ) + } + if strings.Contains(result.ForLLM, "2|") { + t.Fatalf( + "expected second line to be excluded once the exact output byte budget was reached, got: %s", + result.ForLLM, + ) + } + if !strings.Contains(result.ForLLM, "file_bytes: 8 | output_bytes: 10") { + t.Fatalf("expected separate file/output byte counters, got: %s", result.ForLLM) + } + if !strings.Contains(result.ForLLM, "start_line=2") { + t.Fatalf("expected continuation at line 2, got: %s", result.ForLLM) + } +} From e075be6b10a4392c1cd83f88d70421aecc7053c9 Mon Sep 17 00:00:00 2001 From: wenjie Date: Thu, 2 Apr 2026 19:09:27 +0800 Subject: [PATCH 14/16] feat(web): move version display to the config page header (#2273) - remove version details from the sidebar footer - show the current app version as a badge in the config page header - add a reusable Badge UI component for the new version label --- web/frontend/src/components/app-sidebar.tsx | 29 ----------- .../src/components/config/config-page.tsx | 21 +++++++- web/frontend/src/components/ui/badge.tsx | 49 +++++++++++++++++++ 3 files changed, 69 insertions(+), 30 deletions(-) create mode 100644 web/frontend/src/components/ui/badge.tsx diff --git a/web/frontend/src/components/app-sidebar.tsx b/web/frontend/src/components/app-sidebar.tsx index dea43197c..1980e458c 100644 --- a/web/frontend/src/components/app-sidebar.tsx +++ b/web/frontend/src/components/app-sidebar.tsx @@ -11,12 +11,10 @@ import { IconSparkles, IconTools, } from "@tabler/icons-react" -import { useQuery } from "@tanstack/react-query" import { Link, useRouterState } from "@tanstack/react-router" import * as React from "react" import { useTranslation } from "react-i18next" -import { getSystemVersionInfo } from "@/api/system" import { Collapsible, CollapsibleContent, @@ -25,7 +23,6 @@ import { import { Sidebar, SidebarContent, - SidebarFooter, SidebarGroup, SidebarGroupContent, SidebarGroupLabel, @@ -84,13 +81,7 @@ export function AppSidebar({ ...props }: React.ComponentProps) { language: (i18n.resolvedLanguage ?? i18n.language ?? "").toLowerCase(), t, }) - const { data: versionInfo } = useQuery({ - queryKey: ["system", "version"], - queryFn: getSystemVersionInfo, - staleTime: 5 * 60 * 1000, - }) - const versionText = versionInfo?.version ?? t("footer.version_unknown") const handleNavItemClick = React.useCallback(() => { if (isMobile) { setOpenMobile(false) @@ -263,26 +254,6 @@ export function AppSidebar({ ...props }: React.ComponentProps) { ))} - -
-
- {t("footer.version")}:{" "} - {versionText} -
- {versionInfo?.git_commit && ( -
- {t("footer.commit")}:{" "} - {versionInfo.git_commit} -
- )} - {versionInfo?.build_time && ( -
- {t("footer.build")}:{" "} - {versionInfo.build_time} -
- )} -
-
) diff --git a/web/frontend/src/components/config/config-page.tsx b/web/frontend/src/components/config/config-page.tsx index cbe4d8e91..7c2cb263e 100644 --- a/web/frontend/src/components/config/config-page.tsx +++ b/web/frontend/src/components/config/config-page.tsx @@ -1,4 +1,4 @@ -import { IconCode, IconDeviceFloppy } from "@tabler/icons-react" +import { IconCode, IconDeviceFloppy, IconTag } from "@tabler/icons-react" import { useQuery, useQueryClient } from "@tanstack/react-query" import { Link } from "@tanstack/react-router" import { useEffect, useState } from "react" @@ -10,6 +10,7 @@ import { launcherFetch } from "@/api/http" import { getAutoStartStatus, getLauncherConfig, + getSystemVersionInfo, setAutoStartEnabled as updateAutoStartEnabled, setLauncherConfig as updateLauncherConfig, } from "@/api/system" @@ -32,6 +33,7 @@ import { parseMultilineList, } from "@/components/config/form-model" import { PageHeader } from "@/components/page-header" +import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" import { refreshGatewayState } from "@/store/gateway" @@ -64,6 +66,12 @@ export function ConfigPage() { queryFn: getLauncherConfig, }) + const { data: versionInfo } = useQuery({ + queryKey: ["system", "version"], + queryFn: getSystemVersionInfo, + staleTime: 5 * 60 * 1000, + }) + const { data: autoStartStatus, isLoading: isAutoStartLoading, @@ -297,6 +305,17 @@ export function ConfigPage() {
+ + {versionInfo.version} + + ) + } children={
- ) : undefined + channel && + docsUrl && ( + + {t("channels.page.docLink")} + + ) } /> @@ -562,46 +547,9 @@ export function ChannelConfigPage({ channelName }: ChannelConfigPageProps) { {fetchError} ) : ( -
-
-

- {t("channels.edit", { - name: channelDisplayName, - })} -

- {channel && docsUrl && ( - - {t("channels.page.docLink")} - - )} -
- - {channel?.name === "weixin" && ( -
-
- -
-

- {t("channels.weixin.warningTitle")} -

-

- {t("channels.weixin.warningDesc")} -

-
-
-
- )} - +
{!hidesPageLevelEnableToggle && ( -
+

{t("channels.page.enableLabel")}

diff --git a/web/frontend/src/components/channels/channel-forms/discord-form.tsx b/web/frontend/src/components/channels/channel-forms/discord-form.tsx index 300175e20..f72e1c5c7 100644 --- a/web/frontend/src/components/channels/channel-forms/discord-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/discord-form.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface DiscordFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record } @@ -35,75 +36,83 @@ function asRecord(value: unknown): Record { export function DiscordForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, }: DiscordFormProps) { const { t } = useTranslation() const groupTriggerConfig = asRecord(config.group_trigger) - const tokenExtraHint = - isEdit && asString(config.token) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("_token", v)} - placeholder={maskedSecretPlaceholder( - config.token, - t("channels.field.tokenPlaceholder"), - )} - /> - +
+ + + + onChange("_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "token", + t("channels.field.secretHintSet"), + t("channels.field.tokenPlaceholder"), + )} + /> + + + - - onChange("proxy", e.target.value)} - placeholder="http://127.0.0.1:7890" - /> - - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + onChange("proxy", e.target.value)} + placeholder="http://127.0.0.1:7890" + /> + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + - { - onChange("group_trigger", { - ...groupTriggerConfig, - mention_only: checked, - }) - }} - ariaLabel={t("channels.field.mentionOnly")} - /> +
+ { + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + }} + ariaLabel={t("channels.field.mentionOnly")} + /> +
+
+
) } diff --git a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx index 386adf9a5..5c77fe3f9 100644 --- a/web/frontend/src/components/channels/channel-forms/feishu-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/feishu-form.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface FeishuFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record } @@ -28,104 +29,111 @@ function asStringArray(value: unknown): string[] { export function FeishuForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, }: FeishuFormProps) { const { t } = useTranslation() - const appSecretExtraHint = - isEdit && asString(config.app_secret) - ? ` ${t("channels.field.secretHintSet")}` - : "" - const verificationExtraHint = - isEdit && asString(config.verification_token) - ? ` ${t("channels.field.secretHintSet")}` - : "" - const encryptExtraHint = - isEdit && asString(config.encrypt_key) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("app_id", e.target.value)} - placeholder="cli_xxxx" - /> - +
+ + + + onChange("app_id", e.target.value)} + placeholder="cli_xxxx" + /> + - - onChange("_app_secret", v)} - placeholder={maskedSecretPlaceholder( - config.app_secret, - t("channels.field.secretPlaceholder"), - )} - /> - + + onChange("_app_secret", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "app_secret", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + - - onChange("_verification_token", v)} - placeholder={maskedSecretPlaceholder( - config.verification_token, - t("channels.field.secretPlaceholder"), - )} - /> - - - onChange("_encrypt_key", v)} - placeholder={maskedSecretPlaceholder( - config.encrypt_key, - t("channels.field.secretPlaceholder"), - )} - /> - - onChange("is_lark", checked)} - /> - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + onChange("_verification_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "verification_token", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + onChange("_encrypt_key", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "encrypt_key", + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
+ onChange("is_lark", checked)} + ariaLabel={t("channels.field.isLark")} + /> +
+
+
) } diff --git a/web/frontend/src/components/channels/channel-forms/generic-form.tsx b/web/frontend/src/components/channels/channel-forms/generic-form.tsx index 936802944..526a3c808 100644 --- a/web/frontend/src/components/channels/channel-forms/generic-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/generic-form.tsx @@ -1,39 +1,23 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { + getSecretInputPlaceholder, + isSecretField, +} from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface GenericFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets?: string[] hiddenKeys?: string[] requiredKeys?: string[] fieldErrors?: Record } -// Secret field names that should use masked input. -const SECRET_FIELDS = new Set([ - "token", - "app_secret", - "client_secret", - "corp_secret", - "channel_secret", - "channel_access_token", - "access_token", - "bot_token", - "app_token", - "encoding_aes_key", - "encrypt_key", - "verification_token", - "secret", - "password", - "nickserv_password", - "sasl_password", -]) - // Fields to skip in the generic form (handled by enabled toggle or internal). const SKIP_FIELDS = new Set(["enabled", "reasoning_channel_id"]) @@ -83,7 +67,7 @@ function asBool(value: unknown): boolean { export function GenericForm({ config, onChange, - isEdit, + configuredSecrets = [], hiddenKeys = [], requiredKeys = [], fieldErrors = {}, @@ -96,7 +80,7 @@ export function GenericForm({ const placeholderConfig = asRecord(config.placeholder) const placeholderEnabled = asBool(placeholderConfig.enabled) - const fields = Object.keys(config).filter( + const rawFields = Object.keys(config).filter( (k) => !k.startsWith("_") && !SKIP_FIELDS.has(k) && @@ -160,231 +144,291 @@ export function GenericForm({ ) } - return ( -
- {fields.map((key) => { - const isRequired = requiredFieldSet.has(key) - if (SECRET_FIELDS.has(key)) { - const editKey = `_${key}` - const extraHint = - isEdit && config[key] ? ` ${t("channels.field.secretHintSet")}` : "" - return ( - - onChange(editKey, v)} - placeholder={maskedSecretPlaceholder(config[key])} - /> - - ) - } - - const value = config[key] - if (typeof value === "boolean") { - return ( - onChange(key, checked)} - ariaLabel={formatLabel(key)} - /> - ) - } - - if (Array.isArray(value)) { - return ( - - - onChange( - key, - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - /> - - ) - } - - return ( - - { - // Attempt to preserve number types - const v = e.target.value - if (typeof config[key] === "number") { - onChange(key, v === "" ? 0 : Number(v)) - } else { - onChange(key, v) - } - }} - /> - - ) - })} - - {/* Allow From field */} - {config.allow_from !== undefined && !hiddenFieldSet.has("allow_from") && ( + const renderField = (key: string) => { + const isRequired = requiredFieldSet.has(key) + if (isSecretField(key)) { + const editKey = `_${key}` + return ( + onChange(editKey, v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + key, + t("channels.field.secretHintSet"), + t("channels.field.secretPlaceholder"), + )} + /> + + ) + } + + const value = config[key] + if (typeof value === "boolean") { + return ( + onChange(key, checked)} + ariaLabel={formatLabel(key)} + /> + ) + } + + if (Array.isArray(value)) { + return ( + onChange( - "allow_from", + key, e.target.value .split(",") .map((s: string) => s.trim()) .filter(Boolean), ) } - placeholder={t("channels.field.allowFromPlaceholder")} /> - )} + ) + } - {config.allow_origins !== undefined && - !hiddenFieldSet.has("allow_origins") && ( - - - onChange( - "allow_origins", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowOriginsPlaceholder")} - /> - - )} - - {config.allow_token_query !== undefined && - !hiddenFieldSet.has("allow_token_query") && ( - - onChange("allow_token_query", checked) + return ( + + { + const v = e.target.value + if (typeof config[key] === "number") { + onChange(key, v === "" ? 0 : Number(v)) + } else { + onChange(key, v) } - ariaLabel={formatLabel("allow_token_query")} - /> - )} - - {config.group_trigger !== undefined && - !hiddenFieldSet.has("group_trigger") && ( - <> - - onChange("group_trigger", { - ...groupTriggerConfig, - mention_only: checked, - }) - } - ariaLabel={t("channels.field.groupTriggerMentionOnly")} - /> - - - onChange("group_trigger", { - ...groupTriggerConfig, - prefixes: e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - }) - } - placeholder={t("channels.field.groupTriggerPrefixes")} - /> - - - )} - - {config.typing !== undefined && !hiddenFieldSet.has("typing") && ( - - onChange("typing", { ...typingConfig, enabled: checked }) - } - ariaLabel={t("channels.field.typingEnabled")} + }} /> + + ) + } + + const isBasicField = (key: string) => { + if (requiredFieldSet.has(key)) return true + if ( + key.endsWith("id") || + key.endsWith("token") || + key.endsWith("secret") || + key.endsWith("url") || + key === "server" || + key === "host" || + key === "port" + ) { + return true + } + return false + } + + const basicFields = rawFields.filter(isBasicField) + const advancedFields = rawFields.filter((key) => !isBasicField(key)) + + const hasAdvancedContent = + advancedFields.length > 0 || + (config.allow_from !== undefined && !hiddenFieldSet.has("allow_from")) || + (config.allow_origins !== undefined && + !hiddenFieldSet.has("allow_origins")) || + (config.allow_token_query !== undefined && + !hiddenFieldSet.has("allow_token_query")) || + (config.group_trigger !== undefined && + !hiddenFieldSet.has("group_trigger")) || + (config.typing !== undefined && !hiddenFieldSet.has("typing")) || + (config.placeholder !== undefined && !hiddenFieldSet.has("placeholder")) + + return ( +
+ {basicFields.length > 0 && ( + + + {basicFields.map(renderField)} + + )} - {config.placeholder !== undefined && - !hiddenFieldSet.has("placeholder") && ( - - onChange("placeholder", { - ...placeholderConfig, - enabled: checked, - }) - } - ariaLabel={t("channels.field.placeholderEnabled")} - > - {placeholderEnabled && ( -
- - onChange("placeholder", { - ...placeholderConfig, - text: e.target.value, - }) + {hasAdvancedContent && ( + + + {advancedFields.map(renderField)} + + {config.allow_from !== undefined && + !hiddenFieldSet.has("allow_from") && ( + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + )} + + {config.allow_origins !== undefined && + !hiddenFieldSet.has("allow_origins") && ( + + + onChange( + "allow_origins", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowOriginsPlaceholder")} + /> + + )} + + {config.allow_token_query !== undefined && + !hiddenFieldSet.has("allow_token_query") && ( +
+ + onChange("allow_token_query", checked) + } + ariaLabel={formatLabel("allow_token_query")} + /> +
+ )} + + {config.group_trigger !== undefined && + !hiddenFieldSet.has("group_trigger") && ( + <> +
+ + onChange("group_trigger", { + ...groupTriggerConfig, + mention_only: checked, + }) + } + ariaLabel={t("channels.field.groupTriggerMentionOnly")} + /> +
+ + + + onChange("group_trigger", { + ...groupTriggerConfig, + prefixes: e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + }) + } + placeholder={t("channels.field.groupTriggerPrefixes")} + /> + + + )} + + {config.typing !== undefined && !hiddenFieldSet.has("typing") && ( +
+ + onChange("typing", { ...typingConfig, enabled: checked }) } - placeholder={t("channels.field.placeholderText")} - aria-label={t("channels.field.placeholderText")} + ariaLabel={t("channels.field.typingEnabled")} />
)} - - )} + + {config.placeholder !== undefined && + !hiddenFieldSet.has("placeholder") && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + enabled: checked, + }) + } + ariaLabel={t("channels.field.placeholderEnabled")} + > + {placeholderEnabled && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + text: e.target.value, + }) + } + placeholder={t("channels.field.placeholderText")} + aria-label={t("channels.field.placeholderText")} + /> +
+ )} +
+
+ )} +
+
+ )}
) } diff --git a/web/frontend/src/components/channels/channel-forms/slack-form.tsx b/web/frontend/src/components/channels/channel-forms/slack-form.tsx index 54650e842..14ffa0913 100644 --- a/web/frontend/src/components/channels/channel-forms/slack-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/slack-form.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface SlackFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record } @@ -24,63 +25,73 @@ function asStringArray(value: unknown): string[] { export function SlackForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, }: SlackFormProps) { const { t } = useTranslation() - const botTokenExtraHint = - isEdit && asString(config.bot_token) - ? ` ${t("channels.field.secretHintSet")}` - : "" - const appTokenExtraHint = - isEdit && asString(config.app_token) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("_bot_token", v)} - placeholder={maskedSecretPlaceholder(config.bot_token, "xoxb-xxxx")} - /> - +
+ + + + onChange("_bot_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "bot_token", + t("channels.field.secretHintSet"), + "xoxb-xxxx", + )} + /> + - - onChange("_app_token", v)} - placeholder={maskedSecretPlaceholder(config.app_token, "xapp-xxxx")} - /> - + + onChange("_app_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "app_token", + t("channels.field.secretHintSet"), + "xapp-xxxx", + )} + /> + + + - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
) } diff --git a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx index 169ddec63..696da245d 100644 --- a/web/frontend/src/components/channels/channel-forms/telegram-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/telegram-form.tsx @@ -1,14 +1,15 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" -import { maskedSecretPlaceholder } from "@/components/secret-placeholder" +import { getSecretInputPlaceholder } from "@/components/channels/channel-config-fields" import { Field, KeyInput, SwitchCardField } from "@/components/shared-form" +import { Card, CardContent } from "@/components/ui/card" import { Input } from "@/components/ui/input" interface TelegramFormProps { config: ChannelConfig onChange: (key: string, value: unknown) => void - isEdit: boolean + configuredSecrets: string[] fieldErrors?: Record } @@ -35,113 +36,124 @@ function asBool(value: unknown): boolean { export function TelegramForm({ config, onChange, - isEdit, + configuredSecrets, fieldErrors = {}, }: TelegramFormProps) { const { t } = useTranslation() const typingConfig = asRecord(config.typing) const placeholderConfig = asRecord(config.placeholder) const placeholderEnabled = asBool(placeholderConfig.enabled) - const tokenExtraHint = - isEdit && asString(config.token) - ? ` ${t("channels.field.secretHintSet")}` - : "" return ( -
- - onChange("_token", v)} - placeholder={maskedSecretPlaceholder( - config.token, - t("channels.field.tokenPlaceholder"), - )} - /> - +
+ + + + onChange("_token", v)} + placeholder={getSecretInputPlaceholder( + configuredSecrets, + "token", + t("channels.field.secretHintSet"), + t("channels.field.tokenPlaceholder"), + )} + /> + - - onChange("base_url", e.target.value)} - placeholder="https://api.telegram.org" - /> - - - onChange("proxy", e.target.value)} - placeholder="http://127.0.0.1:7890" - /> - - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - - - - onChange("typing", { ...typingConfig, enabled: checked }) - } - ariaLabel={t("channels.field.typingEnabled")} - /> - - - onChange("placeholder", { - ...placeholderConfig, - enabled: checked, - }) - } - ariaLabel={t("channels.field.placeholderEnabled")} - > - {placeholderEnabled && ( -
+ onChange("base_url", e.target.value)} + placeholder="https://api.telegram.org" + /> + + + + + + + + onChange("proxy", e.target.value)} + placeholder="http://127.0.0.1:7890" + /> + + + - onChange("placeholder", { - ...placeholderConfig, - text: e.target.value, - }) + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) } - placeholder={t("channels.field.placeholderText")} - aria-label={t("channels.field.placeholderText")} + placeholder={t("channels.field.allowFromPlaceholder")} + /> + + +
+ + onChange("typing", { ...typingConfig, enabled: checked }) + } + ariaLabel={t("channels.field.typingEnabled")} />
- )} - + +
+ + onChange("placeholder", { + ...placeholderConfig, + enabled: checked, + }) + } + ariaLabel={t("channels.field.placeholderEnabled")} + > + {placeholderEnabled && ( +
+ + onChange("placeholder", { + ...placeholderConfig, + text: e.target.value, + }) + } + placeholder={t("channels.field.placeholderText")} + aria-label={t("channels.field.placeholderText")} + /> +
+ )} +
+
+
+
) } diff --git a/web/frontend/src/components/channels/channel-forms/wecom-form.tsx b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx index 744c87ba2..b7e6ce849 100644 --- a/web/frontend/src/components/channels/channel-forms/wecom-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/wecom-form.tsx @@ -11,6 +11,13 @@ import { useTranslation } from "react-i18next" import type { ChannelConfig } from "@/api/channels" import { patchAppConfig, pollWecomFlow, startWecomFlow } from "@/api/channels" import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" import { Switch } from "@/components/ui/switch" type BindingState = @@ -329,39 +336,32 @@ export function WecomForm({ } return ( -
-
-
-
-

- {t("channels.page.enableLabel")} -

-

- {isBound - ? t("channels.wecom.enableDesc") - : t("channels.wecom.enableBindFirst")} -

-
+
+
+

{t("channels.page.enableLabel")}

+
void handleEnabledChange(checked)} /> + {toggleError && ( +

+ {toggleError} +

+ )}
- {toggleError && ( -

{toggleError}

- )}
-
-
-

{t("channels.wecom.bindTitle")}

-

- {t("channels.wecom.bindDesc")} -

-
- {renderBindSection()} -
+ + + + {t("channels.wecom.bindTitle")} + + {t("channels.wecom.bindDesc")} + + {renderBindSection()} +
) } diff --git a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx index 20e66ffc2..ec80520ea 100644 --- a/web/frontend/src/components/channels/channel-forms/weixin-form.tsx +++ b/web/frontend/src/components/channels/channel-forms/weixin-form.tsx @@ -12,6 +12,13 @@ import type { ChannelConfig } from "@/api/channels" import { pollWeixinFlow, startWeixinFlow } from "@/api/channels" import { Field } from "@/components/shared-form" import { Button } from "@/components/ui/button" +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card" import { Input } from "@/components/ui/input" type BindingState = @@ -301,51 +308,50 @@ export function WeixinForm({ } return ( -
- {/* QR Bind Section */} -
-
-

+

+ + + {t("channels.weixin.bindTitle")} -

-

- {t("channels.weixin.bindDesc")} -

-
- {renderBindSection()} -
+ + {t("channels.weixin.bindDesc")} + + {renderBindSection()} + - {/* allow_from */} - - - onChange( - "allow_from", - e.target.value - .split(",") - .map((s: string) => s.trim()) - .filter(Boolean), - ) - } - placeholder={t("channels.field.allowFromPlaceholder")} - /> - + + + + + onChange( + "allow_from", + e.target.value + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean), + ) + } + placeholder={t("channels.field.allowFromPlaceholder")} + /> + - {/* proxy */} - - onChange("proxy", e.target.value)} - placeholder="http://localhost:7890" - /> - + + onChange("proxy", e.target.value)} + placeholder="http://localhost:7890" + /> + + +
) } diff --git a/web/frontend/src/components/shared-form.tsx b/web/frontend/src/components/shared-form.tsx index 14da8e1f1..e6dd2cee9 100644 --- a/web/frontend/src/components/shared-form.tsx +++ b/web/frontend/src/components/shared-form.tsx @@ -34,23 +34,28 @@ export function Field({ }: FieldProps) { if (layout === "setting-row") { return ( -
-
- +
+
+ {label} {required && *} {hint && ( - + {hint} )}
-
+
{children}
{error && ( - + {error} )} @@ -125,6 +130,7 @@ interface SwitchCardFieldProps { disabled?: boolean children?: ReactNode layout?: FieldLayout + transparent?: boolean } export function SwitchCardField({ @@ -137,19 +143,22 @@ export function SwitchCardField({ disabled, children, layout = "default", + transparent, }: SwitchCardFieldProps) { if (layout === "setting-row") { return ( -
-
-

{label}

+
+
+

+ {label} +

{hint && ( -

+

{hint}

)}
-
+
- {children &&
{children}
} + {children && ( +
+
{children}
+
+ )} {error && ( -

+

{error}

)} @@ -168,7 +181,11 @@ export function SwitchCardField({ } return ( -
+

{label}

@@ -185,7 +202,7 @@ export function SwitchCardField({ aria-label={ariaLabel ?? label} />
- {children &&
{children}
} + {children &&
{children}
} {error && (

{error}

)} diff --git a/web/frontend/src/i18n/locales/en.json b/web/frontend/src/i18n/locales/en.json index b99ff9594..851b0c8c4 100644 --- a/web/frontend/src/i18n/locales/en.json +++ b/web/frontend/src/i18n/locales/en.json @@ -244,10 +244,6 @@ }, "channels": { "loadError": "Failed to load channels", - "edit": "Configure {{name}}", - "status": { - "configured": "Configured" - }, "name": { "telegram": "Telegram", "discord": "Discord", @@ -267,8 +263,6 @@ "weixin": "WeChat" }, "weixin": { - "warningTitle": "Testing phase, use with caution", - "warningDesc": "The WeChat channel is still experimental and may carry a risk of account suspension. Use it only if you understand and accept the risk.", "bindTitle": "WeChat Account Binding", "bindDesc": "Scan the QR code with WeChat to bind your personal account.", "bind": "Bind WeChat", @@ -286,8 +280,6 @@ "wecom": { "bindTitle": "WeCom Binding", "bindDesc": "Scan the QR code with WeCom to bind your AI Bot.", - "enableDesc": "Once bound, you can enable or disable the channel here.", - "enableBindFirst": "Bind the bot first, then enable the channel.", "bind": "Bind WeCom", "rebind": "Re-bind", "bound": "WeCom Bound", @@ -329,7 +321,6 @@ "notFound": "Channel \"{{name}}\" is not supported.", "saveSuccess": "Channel configuration saved.", "saveError": "Failed to save channel configuration", - "enabled": "enabled", "docLink": "Documentation", "enableLabel": "Enable channel", "restartRequiredTitle": "Gateway restart required", diff --git a/web/frontend/src/i18n/locales/zh.json b/web/frontend/src/i18n/locales/zh.json index 9fa45e981..07538ace9 100644 --- a/web/frontend/src/i18n/locales/zh.json +++ b/web/frontend/src/i18n/locales/zh.json @@ -244,10 +244,6 @@ }, "channels": { "loadError": "加载频道列表失败", - "edit": "配置 {{name}}", - "status": { - "configured": "已配置" - }, "name": { "telegram": "Telegram", "discord": "Discord", @@ -267,8 +263,6 @@ "weixin": "微信" }, "weixin": { - "warningTitle": "测试阶段,请谨慎使用", - "warningDesc": "微信 Channel 当前仍处于测试阶段,存在封号风险。请仅在充分了解风险的前提下使用。", "bindTitle": "微信账号绑定", "bindDesc": "使用微信扫描二维码以绑定您的个人微信账号。", "bind": "绑定微信", @@ -286,8 +280,6 @@ "wecom": { "bindTitle": "企业微信绑定", "bindDesc": "使用企业微信扫描二维码以绑定您的 AI Bot。", - "enableDesc": "绑定后可在这里直接启用或停用频道。", - "enableBindFirst": "请先完成绑定,然后再启用频道。", "bind": "绑定企业微信", "rebind": "重新绑定", "bound": "企业微信已绑定", @@ -323,13 +315,12 @@ "allowOrigins": "允许来源域名", "allowOriginsPlaceholder": "例如 https://example.com, http://localhost:5173", "secretPlaceholder": "输入密钥", - "secretHintSet": "已设置密钥,留空表示不修改。" + "secretHintSet": "配置已保存,留空表示不修改" }, "page": { "notFound": "不支持频道“{{name}}”。", "saveSuccess": "频道配置已保存。", "saveError": "保存频道配置失败", - "enabled": "已启用", "docLink": "配置文档", "enableLabel": "启用频道", "restartRequiredTitle": "需要重启服务", @@ -337,58 +328,58 @@ }, "form": { "desc": { - "token": "机器人访问令牌,用于连接平台 API。", - "botToken": "Bot Token,用于发送与接收消息。", + "token": "机器人访问令牌,用于连接平台 API", + "botToken": "Bot Token,用于发送与接收消息", "appToken": "App Token,用于 Socket 模式连接。", - "appId": "应用唯一标识,用于平台鉴权。", - "appSecret": "应用密钥,用于请求签名和鉴权。", - "verificationToken": "事件回调验证令牌。", - "encryptKey": "消息加密密钥,用于解密回调内容。", - "baseUrl": "平台 API 地址,默认使用官方地址。", - "proxy": "HTTP 代理地址,用于网络访问。", - "mentionOnly": "在群聊中仅当明确提及时才响应。", - "typingEnabled": "在生成回复时显示“正在输入”状态。", - "placeholderEnabled": "在最终回复发送前,先发送临时占位消息。", - "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应。", - "groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔。", - "isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)。", - "allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔。", - "allowOrigins": "允许访问的来源域名,多个值用逗号分隔。", - "wsUrl": "WebSocket 服务地址。", - "reconnectInterval": "断线后的重连间隔(秒)。", - "bridgeUrl": "桥接服务地址。", - "sessionStorePath": "本地会话存储目录路径。", - "useNative": "是否使用原生客户端模式连接。", - "host": "服务监听主机地址。", - "port": "服务监听端口。", - "homeserver": "Matrix homeserver 地址。", - "userId": "账号 ID。", - "deviceId": "设备 ID。", - "joinOnInvite": "收到邀请时是否自动加入房间。", - "clientId": "应用客户端 ID,用于平台鉴权。", - "corpId": "企业 ID。", - "agentId": "企业应用 Agent ID。", - "webhookUrl": "Webhook 完整地址。", - "webhookHost": "Webhook 监听主机。", - "webhookPort": "Webhook 监听端口。", - "webhookPath": "Webhook 路径。", - "replyTimeout": "回复超时时间(秒)。", - "maxSteps": "最大步骤数。", - "welcomeMessage": "新会话欢迎语内容。", - "allowTokenQuery": "是否允许 URL Query 方式传递 Token。", - "pingInterval": "连接心跳间隔(秒)。", - "readTimeout": "读取超时时间(秒)。", - "writeTimeout": "写入超时时间(秒)。", - "maxConnections": "最大并发连接数。", - "server": "IRC 服务器地址。", - "tls": "是否启用 TLS 连接。", - "nick": "机器人昵称。", - "user": "IRC 用户名。", - "realName": "显示名称。", - "channels": "要加入的 IRC 频道列表。", - "requestCaps": "连接时请求的 IRC 扩展能力列表。", - "maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传。", - "genericField": "用于配置{{field}}。" + "appId": "应用唯一标识,用于平台鉴权", + "appSecret": "应用密钥,用于请求签名和鉴权", + "verificationToken": "事件回调验证令牌", + "encryptKey": "消息加密密钥,用于解密回调内容", + "baseUrl": "平台 API 地址,默认使用官方地址", + "proxy": "HTTP 代理地址,用于网络访问", + "mentionOnly": "在群聊中仅当明确提及时才响应", + "typingEnabled": "在生成回复时显示“正在输入”状态", + "placeholderEnabled": "在最终回复发送前,先发送临时占位消息", + "groupTriggerMentionOnly": "在群聊中仅当提及机器人时才响应", + "groupTriggerPrefixes": "群聊触发前缀,多个值用逗号分隔", + "isLark": "使用 Lark 国际版域名(open.larksuite.com)替代飞书域名(open.feishu.cn)", + "allowFrom": "允许访问的用户或群组 ID,多个值用逗号分隔", + "allowOrigins": "允许访问的来源域名,多个值用逗号分隔", + "wsUrl": "WebSocket 服务地址", + "reconnectInterval": "断线后的重连间隔(秒)", + "bridgeUrl": "桥接服务地址", + "sessionStorePath": "本地会话存储目录路径", + "useNative": "是否使用原生客户端模式连接", + "host": "服务监听主机地址", + "port": "服务监听端口", + "homeserver": "Matrix homeserver 地址", + "userId": "账号 ID", + "deviceId": "设备 ID", + "joinOnInvite": "收到邀请时是否自动加入房间", + "clientId": "应用客户端 ID,用于平台鉴权", + "corpId": "企业 ID", + "agentId": "企业应用 Agent ID", + "webhookUrl": "Webhook 完整地址", + "webhookHost": "Webhook 监听主机", + "webhookPort": "Webhook 监听端口", + "webhookPath": "Webhook 路径", + "replyTimeout": "回复超时时间(秒)", + "maxSteps": "最大步骤数", + "welcomeMessage": "新会话欢迎语内容", + "allowTokenQuery": "是否允许 URL Query 方式传递 Token", + "pingInterval": "连接心跳间隔(秒)", + "readTimeout": "读取超时时间(秒)", + "writeTimeout": "写入超时时间(秒)", + "maxConnections": "最大并发连接数", + "server": "IRC 服务器地址", + "tls": "是否启用 TLS 连接", + "nick": "机器人昵称", + "user": "IRC 用户名", + "realName": "显示名称", + "channels": "要加入的 IRC 频道列表", + "requestCaps": "连接时请求的 IRC 扩展能力列表", + "maxBase64FileSizeMiB": "本地文件转为 base64 上传的最大体积,单位 MiB;0 表示不限制,仅影响本地文件,不影响 URL 直传", + "genericField": "用于配置{{field}}" } }, "validation": { From b114dcaeb1eb6032e817fb7e7ca9a25cff4eb529 Mon Sep 17 00:00:00 2001 From: Mauro Date: Thu, 2 Apr 2026 13:26:26 +0200 Subject: [PATCH 16/16] feat(model): llm rate limiting (#2198) * feat(model): rate limiting * fix(agent): preserve per-model identity in rate limiting and fallback * fix test --- docs/rate-limiting.md | 95 +++++++++++ pkg/agent/instance_test.go | 52 ++++++ pkg/agent/loop.go | 26 ++- pkg/agent/model_resolution.go | 159 +++++++++++++----- pkg/providers/fallback.go | 84 +++++++++- pkg/providers/fallback_multikey_test.go | 12 +- pkg/providers/fallback_test.go | 118 +++++++++++-- pkg/providers/ratelimiter.go | 144 ++++++++++++++++ pkg/providers/ratelimiter_test.go | 209 ++++++++++++++++++++++++ 9 files changed, 821 insertions(+), 78 deletions(-) create mode 100644 docs/rate-limiting.md create mode 100644 pkg/providers/ratelimiter.go create mode 100644 pkg/providers/ratelimiter_test.go diff --git a/docs/rate-limiting.md b/docs/rate-limiting.md new file mode 100644 index 000000000..b54c757f8 --- /dev/null +++ b/docs/rate-limiting.md @@ -0,0 +1,95 @@ +# Dynamic Rate Limiting + +PicoClaw prevents 429 errors from LLM provider APIs by enforcing configurable per-model request-rate limits **before** sending each request. Unlike the reactive cooldown/fallback system (which activates *after* a 429 is received), rate limiting is **proactive**: it keeps outbound QPS within the provider's free-tier or plan limits. + +## How it works + +### Token-bucket algorithm + +Each rate-limited model gets a token bucket: + +- **Capacity** = `rpm` (burst size equals the per-minute limit) +- **Refill rate** = `rpm / 60` tokens per second +- Tokens are consumed one per LLM call; if the bucket is empty, the call blocks until a token refills or the request context is cancelled + +### Call chain integration + +``` +AgentLoop.callLLM() + └─ FallbackChain.Execute() ← iterate candidates + ├─ CooldownTracker.IsAvailable() ← skip if post-429 cooldown active + ├─ RateLimiterRegistry.Wait() ← NEW: block until token available + └─ provider.Chat() ← actual LLM HTTP call +``` + +The rate limiter runs **after** the cooldown check and **before** the provider call, so: +- Candidates already in cooldown are skipped entirely (no token consumed) +- Candidates that are available get throttled to the configured RPM + +The same check applies in `ExecuteImage`. + +### Thread safety + +`RateLimiterRegistry` is safe for concurrent use. The per-limiter token bucket uses a fine-grained mutex so concurrent goroutines each acquire their own token independently. + +## Configuration + +Set `rpm` on any model in `model_list`: + +```yaml +model_list: + - model_name: gpt-4o-free + model: openai/gpt-4o + api_base: https://api.openai.com/v1 + rpm: 3 # max 3 requests per minute + api_keys: + - sk-... + + - model_name: claude-haiku + model: anthropic/claude-haiku-4-5 + rpm: 60 # 60 rpm (Anthropic free tier) + api_keys: + - sk-ant-... + + - model_name: local-llm + model: openai/llama3 + api_base: http://localhost:11434/v1 + # no rpm → unrestricted +``` + +| Field | Type | Default | Description | +|---|---|---|---| +| `rpm` | `int` | `0` | Requests per minute. `0` means no limit. | + +### Interaction with fallbacks + +When a model has fallbacks configured, each candidate is rate-limited **independently**: + +```yaml +model_list: + - model_name: gpt4-with-fallback + model: openai/gpt-4o + rpm: 5 + fallbacks: + - gpt-4o-mini # must also be in model_list; its own rpm applies +``` + +If the current candidate's bucket is empty and there are more candidates available, PicoClaw skips the locally saturated candidate and tries the next fallback immediately. Only the last remaining candidate waits for a token to refill. If the context deadline is hit while waiting on that last candidate, the wait error propagates. + +For `model_list` aliases that resolve to the same underlying provider/model, rate limiting is keyed by the stable config identity (for example `model_name`) rather than the resolved runtime model string. This preserves distinct RPM settings for multi-key and alias-based configurations. + +### Burst behaviour + +The bucket starts **full** (burst = RPM). For `rpm: 3`, the first 3 requests fire instantly; subsequent requests are spaced ~20 s apart. + +To reduce burstiness for strict APIs, set a lower `rpm` and rely on the steady-state refill. + +## Files changed + +| File | What | +|---|---| +| `pkg/providers/ratelimiter.go` | `RateLimiter` (token bucket) + `RateLimiterRegistry` | +| `pkg/providers/ratelimiter_test.go` | Unit tests for limiter and registry | +| `pkg/providers/fallback.go` | `FallbackCandidate.RPM` field; `FallbackChain.rl`; `Wait()` call in `Execute`/`ExecuteImage` | +| `pkg/agent/model_resolution.go` | Resolves candidates from `model_list`, preserving stable config identity and propagating `RPM` into `FallbackCandidate` | +| `pkg/agent/loop.go` | Build `RateLimiterRegistry`, register all agents' candidates, pass to `NewFallbackChain` | diff --git a/pkg/agent/instance_test.go b/pkg/agent/instance_test.go index 7c043d88f..ba907e88b 100644 --- a/pkg/agent/instance_test.go +++ b/pkg/agent/instance_test.go @@ -165,6 +165,58 @@ func TestNewAgentInstance_ResolveCandidatesFromModelListAlias(t *testing.T) { } } +func TestNewAgentInstance_PreservesDistinctLimiterIdentityForSharedResolvedModel(t *testing.T) { + tmpDir := t.TempDir() + + cfg := &config.Config{ + Agents: config.AgentsConfig{ + Defaults: config.AgentDefaults{ + Workspace: tmpDir, + ModelName: "glm-4.7", + ModelFallbacks: []string{"glm-4.7__key_1"}, + }, + }, + ModelList: []*config.ModelConfig{ + { + ModelName: "glm-4.7", + Model: "zhipu/glm-4.7", + RPM: 1, + }, + { + ModelName: "glm-4.7__key_1", + Model: "zhipu/glm-4.7", + RPM: 3, + }, + }, + } + + agent := NewAgentInstance(nil, &cfg.Agents.Defaults, cfg, &mockProvider{}) + if len(agent.Candidates) != 2 { + t.Fatalf("len(Candidates) = %d, want 2", len(agent.Candidates)) + } + + first := agent.Candidates[0] + second := agent.Candidates[1] + if first.Provider != "zhipu" || first.Model != "glm-4.7" { + t.Fatalf("first candidate = %s/%s, want zhipu/glm-4.7", first.Provider, first.Model) + } + if second.Provider != "zhipu" || second.Model != "glm-4.7" { + t.Fatalf("second candidate = %s/%s, want zhipu/glm-4.7", second.Provider, second.Model) + } + if first.IdentityKey != "model_name:glm-4.7" { + t.Fatalf("first identity key = %q, want %q", first.IdentityKey, "model_name:glm-4.7") + } + if second.IdentityKey != "model_name:glm-4.7__key_1" { + t.Fatalf("second identity key = %q, want %q", second.IdentityKey, "model_name:glm-4.7__key_1") + } + if first.RPM != 1 { + t.Fatalf("first RPM = %d, want 1", first.RPM) + } + if second.RPM != 3 { + t.Fatalf("second RPM = %d, want 3", second.RPM) + } +} + func TestNewAgentInstance_AllowsMediaTempDirForReadListAndExec(t *testing.T) { workspace := t.TempDir() mediaDir := media.TempDir() diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 624ff261b..808d12c07 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -119,9 +119,18 @@ func NewAgentLoop( ) *AgentLoop { registry := NewAgentRegistry(cfg, provider) - // Set up shared fallback chain + // Set up shared fallback chain with rate limiting. cooldown := providers.NewCooldownTracker() - fallbackChain := providers.NewFallbackChain(cooldown) + rl := providers.NewRateLimiterRegistry() + // Register rate limiters for all agents' candidates so that RPM limits + // configured in ModelConfig are enforced before each LLM call. + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + rl.RegisterCandidates(agent.Candidates) + rl.RegisterCandidates(agent.LightCandidates) + } + } + fallbackChain := providers.NewFallbackChain(cooldown, rl) // Create state manager using default agent's workspace for channel recording defaultAgent := registry.GetDefaultAgent() @@ -1032,8 +1041,15 @@ func (al *AgentLoop) ReloadProviderAndConfig( al.cfg = cfg al.registry = registry - // Also update fallback chain with new config - al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker()) + // Also update fallback chain with new config; rebuild rate limiter registry. + newRL := providers.NewRateLimiterRegistry() + for _, agentID := range registry.ListAgentIDs() { + if agent, ok := registry.GetAgent(agentID); ok { + newRL.RegisterCandidates(agent.Candidates) + newRL.RegisterCandidates(agent.LightCandidates) + } + } + al.fallback = providers.NewFallbackChain(providers.NewCooldownTracker(), newRL) al.mu.Unlock() @@ -3229,7 +3245,7 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, opts *processOpt return "", fmt.Errorf("failed to initialize model %q: %w", value, err) } - nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, modelCfg.Model, agent.Fallbacks) + nextCandidates := resolveModelCandidates(cfg, cfg.Agents.Defaults.Provider, value, agent.Fallbacks) if len(nextCandidates) == 0 { return "", fmt.Errorf("model %q did not resolve to any provider candidates", value) } diff --git a/pkg/agent/model_resolution.go b/pkg/agent/model_resolution.go index 140cff718..7cbf3a8d6 100644 --- a/pkg/agent/model_resolution.go +++ b/pkg/agent/model_resolution.go @@ -8,44 +8,102 @@ import ( "github.com/sipeed/picoclaw/pkg/providers" ) -func buildModelListResolver(cfg *config.Config) func(raw string) (string, bool) { - ensureProtocol := func(model string) string { - model = strings.TrimSpace(model) - if model == "" { - return "" - } - if strings.Contains(model, "/") { - return model - } - return "openai/" + model +func ensureProtocolModel(model string) string { + model = strings.TrimSpace(model) + if model == "" { + return "" + } + if strings.Contains(model, "/") { + return model + } + return "openai/" + model +} + +func modelConfigIdentityKey(mc *config.ModelConfig) string { + if mc == nil { + return "" + } + if name := strings.TrimSpace(mc.ModelName); name != "" { + return "model_name:" + name + } + return "" +} + +func candidateFromModelConfig( + defaultProvider string, + mc *config.ModelConfig, +) (providers.FallbackCandidate, bool) { + if mc == nil { + return providers.FallbackCandidate{}, false } - return func(raw string) (string, bool) { - raw = strings.TrimSpace(raw) - if raw == "" || cfg == nil { - return "", false - } - - if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { - return ensureProtocol(mc.Model), true - } - - for i := range cfg.ModelList { - fullModel := strings.TrimSpace(cfg.ModelList[i].Model) - if fullModel == "" { - continue - } - if fullModel == raw { - return ensureProtocol(fullModel), true - } - _, modelID := providers.ExtractProtocol(fullModel) - if modelID == raw { - return ensureProtocol(fullModel), true - } - } - - return "", false + ref := providers.ParseModelRef(ensureProtocolModel(mc.Model), defaultProvider) + if ref == nil { + return providers.FallbackCandidate{}, false } + + return providers.FallbackCandidate{ + Provider: ref.Provider, + Model: ref.Model, + RPM: mc.RPM, + IdentityKey: modelConfigIdentityKey(mc), + }, true +} + +func lookupModelConfigByRef(cfg *config.Config, raw string) *config.ModelConfig { + raw = strings.TrimSpace(raw) + if raw == "" || cfg == nil { + return nil + } + + if mc, err := cfg.GetModelConfig(raw); err == nil && mc != nil && strings.TrimSpace(mc.Model) != "" { + return mc + } + + for i := range cfg.ModelList { + mc := cfg.ModelList[i] + if mc == nil { + continue + } + fullModel := strings.TrimSpace(mc.Model) + if fullModel == "" { + continue + } + if fullModel == raw { + return mc + } + _, modelID := providers.ExtractProtocol(fullModel) + if modelID == raw { + return mc + } + } + + return nil +} + +func resolveModelCandidate( + cfg *config.Config, + defaultProvider string, + raw string, +) (providers.FallbackCandidate, bool) { + raw = strings.TrimSpace(raw) + if raw == "" { + return providers.FallbackCandidate{}, false + } + + if mc := lookupModelConfigByRef(cfg, raw); mc != nil { + return candidateFromModelConfig(defaultProvider, mc) + } + + ref := providers.ParseModelRef(raw, defaultProvider) + if ref == nil { + return providers.FallbackCandidate{}, false + } + + return providers.FallbackCandidate{ + Provider: ref.Provider, + Model: ref.Model, + }, true } func resolveModelCandidates( @@ -54,14 +112,29 @@ func resolveModelCandidates( primary string, fallbacks []string, ) []providers.FallbackCandidate { - return providers.ResolveCandidatesWithLookup( - providers.ModelConfig{ - Primary: primary, - Fallbacks: fallbacks, - }, - defaultProvider, - buildModelListResolver(cfg), - ) + seen := make(map[string]bool) + candidates := make([]providers.FallbackCandidate, 0, 1+len(fallbacks)) + + addCandidate := func(raw string) { + candidate, ok := resolveModelCandidate(cfg, defaultProvider, raw) + if !ok { + return + } + + key := candidate.StableKey() + if seen[key] { + return + } + seen[key] = true + candidates = append(candidates, candidate) + } + + addCandidate(primary) + for _, fallback := range fallbacks { + addCandidate(fallback) + } + + return candidates } func resolvedCandidateModel(candidates []providers.FallbackCandidate, fallback string) string { diff --git a/pkg/providers/fallback.go b/pkg/providers/fallback.go index 549ec7837..36092105b 100644 --- a/pkg/providers/fallback.go +++ b/pkg/providers/fallback.go @@ -10,12 +10,24 @@ import ( // FallbackChain orchestrates model fallback across multiple candidates. type FallbackChain struct { cooldown *CooldownTracker + rl *RateLimiterRegistry } // FallbackCandidate represents one model/provider to try. type FallbackCandidate struct { - Provider string - Model string + Provider string + Model string + RPM int // requests per minute; 0 means unrestricted + IdentityKey string // optional stable config identity for cooldown/rate limiting +} + +// StableKey returns the candidate's config-level identity when available, +// otherwise it falls back to the runtime provider/model key. +func (c FallbackCandidate) StableKey() string { + if key := strings.TrimSpace(c.IdentityKey); key != "" { + return key + } + return ModelKey(c.Provider, c.Model) } // FallbackResult contains the successful response and metadata about all attempts. @@ -36,9 +48,10 @@ type FallbackAttempt struct { Skipped bool // true if skipped due to cooldown } -// NewFallbackChain creates a new fallback chain with the given cooldown tracker. -func NewFallbackChain(cooldown *CooldownTracker) *FallbackChain { - return &FallbackChain{cooldown: cooldown} +// NewFallbackChain creates a new fallback chain with the given cooldown tracker +// and rate limiter registry. +func NewFallbackChain(cooldown *CooldownTracker, rl *RateLimiterRegistry) *FallbackChain { + return &FallbackChain{cooldown: cooldown, rl: rl} } // ResolveCandidates parses model config into a deduplicated candidate list. @@ -117,9 +130,9 @@ func (fc *FallbackChain) Execute( return nil, context.Canceled } - // Check cooldown (per provider/model, not just provider). - // This allows multi-key failover where different keys use different model names. - cooldownKey := ModelKey(candidate.Provider, candidate.Model) + // Check cooldown per stable candidate identity, not just provider/model. + // This allows aliases and multi-key configs to fail over independently. + cooldownKey := candidate.StableKey() if !fc.cooldown.IsAvailable(cooldownKey) { remaining := fc.cooldown.CooldownRemaining(cooldownKey) result.Attempts = append(result.Attempts, FallbackAttempt{ @@ -136,6 +149,33 @@ func (fc *FallbackChain) Execute( continue } + // Enforce per-candidate rate limit before calling the provider. + // If this candidate is locally saturated, try other candidates first. + if fc.rl != nil { + if !fc.rl.TryAcquire(cooldownKey) { + if i < len(candidates)-1 { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Skipped: true, + Reason: FailoverRateLimit, + Error: fmt.Errorf("%s waiting for local rate limit token", cooldownKey), + }) + continue + } + if waitErr := fc.rl.Wait(ctx, cooldownKey); waitErr != nil { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Skipped: true, + Reason: FailoverRateLimit, + Error: waitErr, + }) + return nil, waitErr + } + } + } + // Execute the run function. start := time.Now() resp, err := run(ctx, candidate.Provider, candidate.Model) @@ -229,6 +269,34 @@ func (fc *FallbackChain) ExecuteImage( return nil, context.Canceled } + // Enforce per-candidate rate limit before calling the provider. + // If this candidate is locally saturated, try other candidates first. + imageKey := candidate.StableKey() + if fc.rl != nil { + if !fc.rl.TryAcquire(imageKey) { + if i < len(candidates)-1 { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Skipped: true, + Reason: FailoverRateLimit, + Error: fmt.Errorf("%s waiting for local rate limit token", imageKey), + }) + continue + } + if waitErr := fc.rl.Wait(ctx, imageKey); waitErr != nil { + result.Attempts = append(result.Attempts, FallbackAttempt{ + Provider: candidate.Provider, + Model: candidate.Model, + Skipped: true, + Reason: FailoverRateLimit, + Error: waitErr, + }) + return nil, waitErr + } + } + } + start := time.Now() resp, err := run(ctx, candidate.Provider, candidate.Model) elapsed := time.Since(start) diff --git a/pkg/providers/fallback_multikey_test.go b/pkg/providers/fallback_multikey_test.go index 9ed8fa73c..10481ec61 100644 --- a/pkg/providers/fallback_multikey_test.go +++ b/pkg/providers/fallback_multikey_test.go @@ -25,7 +25,7 @@ func TestMultiKeyFailover(t *testing.T) { // Create fallback chain cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: first call fails with 429, second succeeds callCount := 0 @@ -82,7 +82,7 @@ func TestMultiKeyFailoverAllFail(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: all calls fail with rate limit callCount := 0 @@ -127,7 +127,7 @@ func TestMultiKeyFailoverCooldown(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Put the first model in cooldown (using ModelKey now, not just provider) cooldownKey := ModelKey(candidates[0].Provider, candidates[0].Model) @@ -183,7 +183,7 @@ func TestMultiKeyFailoverWithFormatError(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: first call fails with format error (bad request) callCount := 0 @@ -263,7 +263,7 @@ func TestMultiKeyWithModelFallback(t *testing.T) { } cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: first two fail, third succeeds (model fallback) callCount := 0 @@ -337,7 +337,7 @@ func TestMultiKeyFailoverMixedErrors(t *testing.T) { candidates := ResolveCandidates(cfg, "zhipu") cooldown := NewCooldownTracker() - chain := NewFallbackChain(cooldown) + chain := NewFallbackChain(cooldown, nil) // Mock run function: different errors for each key callCount := 0 diff --git a/pkg/providers/fallback_test.go b/pkg/providers/fallback_test.go index 1a1118e33..54fb9b6ea 100644 --- a/pkg/providers/fallback_test.go +++ b/pkg/providers/fallback_test.go @@ -19,7 +19,7 @@ func successRun(content string) func(ctx context.Context, provider, model string func TestFallback_SingleCandidate_Success(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} result, err := fc.Execute(context.Background(), candidates, successRun("hello")) @@ -36,7 +36,7 @@ func TestFallback_SingleCandidate_Success(t *testing.T) { func TestFallback_SecondCandidateSuccess(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -69,7 +69,7 @@ func TestFallback_SecondCandidateSuccess(t *testing.T) { func TestFallback_AllFail(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -96,7 +96,7 @@ func TestFallback_AllFail(t *testing.T) { func TestFallback_ContextCanceled(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) ctx, cancel := context.WithCancel(context.Background()) candidates := []FallbackCandidate{ @@ -123,7 +123,7 @@ func TestFallback_ContextCanceled(t *testing.T) { func TestFallback_NonRetriableError(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -155,7 +155,7 @@ func TestFallback_NonRetriableError(t *testing.T) { func TestFallback_CooldownSkip(t *testing.T) { now := time.Now() ct, _ := newTestTracker(now) - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) // Put openai/gpt-4 in cooldown (using ModelKey now) ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) @@ -193,7 +193,7 @@ func TestFallback_CooldownSkip(t *testing.T) { func TestFallback_AllInCooldown(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) // Put all models in cooldown (using ModelKey now) ct.MarkFailure(ModelKey("openai", "gpt-4"), FailoverRateLimit) @@ -221,7 +221,7 @@ func TestFallback_AllInCooldown(t *testing.T) { func TestFallback_NoCandidates(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) _, err := fc.Execute(context.Background(), nil, successRun("ok")) if err == nil { @@ -232,7 +232,7 @@ func TestFallback_NoCandidates(t *testing.T) { func TestFallback_EmptyFallbacks(t *testing.T) { // Single primary, no fallbacks: should work like direct call ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} result, err := fc.Execute(context.Background(), candidates, successRun("ok")) @@ -246,7 +246,7 @@ func TestFallback_EmptyFallbacks(t *testing.T) { func TestFallback_UnclassifiedError(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4"), @@ -270,7 +270,7 @@ func TestFallback_UnclassifiedError(t *testing.T) { func TestFallback_SuccessResetsCooldown(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4")} modelKey := ModelKey("openai", "gpt-4") @@ -293,11 +293,78 @@ func TestFallback_SuccessResetsCooldown(t *testing.T) { } } +func assertLocalRateLimitSkipsToHealthyFallback( + t *testing.T, + primaryKey string, + fallbackKey string, + fallbackProvider string, + fallbackModel string, + execute func(context.Context, *FallbackChain, []FallbackCandidate, + func(context.Context, string, string) (*LLMResponse, error), + ) (*FallbackResult, error), + responseContent string, +) { + t.Helper() + + ct := NewCooldownTracker() + rl := NewRateLimiterRegistry() + rl.Register(primaryKey, 1) + if err := rl.Wait(context.Background(), primaryKey); err != nil { + t.Fatalf("failed to pre-drain primary limiter: %v", err) + } + + fc := NewFallbackChain(ct, rl) + candidates := []FallbackCandidate{ + {Provider: "openai", Model: "gpt-4o", IdentityKey: primaryKey}, + {Provider: fallbackProvider, Model: fallbackModel, IdentityKey: fallbackKey}, + } + + run := func(ctx context.Context, provider, model string) (*LLMResponse, error) { + if provider != fallbackProvider || model != fallbackModel { + t.Fatalf("expected fallback candidate to run, got %s/%s", provider, model) + } + return &LLMResponse{Content: responseContent, FinishReason: "stop"}, nil + } + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + + result, err := execute(ctx, fc, candidates, run) + if err != nil { + t.Fatalf("expected fallback success, got error: %v", err) + } + if result.Provider != fallbackProvider || result.Model != fallbackModel { + t.Fatalf("result = %s/%s, want %s/%s", result.Provider, result.Model, fallbackProvider, fallbackModel) + } + if len(result.Attempts) != 1 || !result.Attempts[0].Skipped { + t.Fatalf("expected one skipped primary attempt, got %+v", result.Attempts) + } +} + +func TestFallback_LocalRateLimitSkipsToHealthyFallback(t *testing.T) { + assertLocalRateLimitSkipsToHealthyFallback( + t, + "model_name:primary", + "model_name:fallback", + "anthropic", + "claude", + func( + ctx context.Context, + fc *FallbackChain, + candidates []FallbackCandidate, + run func(context.Context, string, string) (*LLMResponse, error), + ) (*FallbackResult, error) { + return fc.Execute(ctx, candidates, run) + }, + "fallback ok", + ) +} + // --- Image Fallback Tests --- func TestImageFallback_Success(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{makeCandidate("openai", "gpt-4o")} result, err := fc.ExecuteImage(context.Background(), candidates, successRun("image result")) @@ -311,7 +378,7 @@ func TestImageFallback_Success(t *testing.T) { func TestImageFallback_DimensionError(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4o"), @@ -335,7 +402,7 @@ func TestImageFallback_DimensionError(t *testing.T) { func TestImageFallback_SizeError(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4o"), @@ -359,7 +426,7 @@ func TestImageFallback_SizeError(t *testing.T) { func TestImageFallback_RetryOnOtherErrors(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) candidates := []FallbackCandidate{ makeCandidate("openai", "gpt-4o"), @@ -384,9 +451,28 @@ func TestImageFallback_RetryOnOtherErrors(t *testing.T) { } } +func TestImageFallback_LocalRateLimitSkipsToHealthyFallback(t *testing.T) { + assertLocalRateLimitSkipsToHealthyFallback( + t, + "model_name:primary-image", + "model_name:fallback-image", + "anthropic", + "claude-sonnet", + func( + ctx context.Context, + fc *FallbackChain, + candidates []FallbackCandidate, + run func(context.Context, string, string) (*LLMResponse, error), + ) (*FallbackResult, error) { + return fc.ExecuteImage(ctx, candidates, run) + }, + "image fallback ok", + ) +} + func TestImageFallback_NoCandidates(t *testing.T) { ct := NewCooldownTracker() - fc := NewFallbackChain(ct) + fc := NewFallbackChain(ct, nil) _, err := fc.ExecuteImage(context.Background(), nil, successRun("ok")) if err == nil { diff --git a/pkg/providers/ratelimiter.go b/pkg/providers/ratelimiter.go new file mode 100644 index 000000000..f475b58fb --- /dev/null +++ b/pkg/providers/ratelimiter.go @@ -0,0 +1,144 @@ +package providers + +import ( + "context" + "sync" + "time" +) + +// RateLimiter implements a token-bucket rate limiter for a single key. +// Allows up to RPM requests per minute with a burst equal to RPM. +// Thread-safe. +type RateLimiter struct { + mu sync.Mutex + rpm int + tokens float64 + maxBurst float64 + lastTick time.Time + nowFunc func() time.Time // for testing +} + +func (rl *RateLimiter) refillLocked(now time.Time) { + elapsed := now.Sub(rl.lastTick).Seconds() + rl.lastTick = now + + // Refill tokens proportional to elapsed time. + refill := elapsed * float64(rl.rpm) / 60.0 + rl.tokens = min(rl.maxBurst, rl.tokens+refill) +} + +// newRateLimiter creates a RateLimiter that allows rpm requests/minute. +func newRateLimiter(rpm int) *RateLimiter { + return &RateLimiter{ + rpm: rpm, + tokens: float64(rpm), // start full + maxBurst: float64(rpm), + lastTick: time.Now(), + nowFunc: time.Now, + } +} + +// Wait blocks until a token is available or ctx is canceled. +// Returns ctx.Err() if canceled while waiting. +func (rl *RateLimiter) Wait(ctx context.Context) error { + for { + rl.mu.Lock() + now := rl.nowFunc() + rl.refillLocked(now) + + if rl.tokens >= 1.0 { + rl.tokens-- + rl.mu.Unlock() + return nil + } + + // Calculate how long until a token is available. + deficit := 1.0 - rl.tokens + waitSec := deficit / (float64(rl.rpm) / 60.0) + rl.mu.Unlock() + + timer := time.NewTimer(time.Duration(waitSec * float64(time.Second))) + select { + case <-ctx.Done(): + if !timer.Stop() { + <-timer.C + } + return ctx.Err() + case <-timer.C: + // Loop to re-check (another goroutine may have consumed the token). + } + } +} + +// TryAcquire attempts to consume a token without blocking. +func (rl *RateLimiter) TryAcquire() bool { + rl.mu.Lock() + defer rl.mu.Unlock() + + rl.refillLocked(rl.nowFunc()) + if rl.tokens < 1.0 { + return false + } + rl.tokens-- + return true +} + +// RateLimiterRegistry holds per-candidate rate limiters. +// Candidates with RPM=0 are unrestricted. +// Thread-safe for concurrent reads/writes. +type RateLimiterRegistry struct { + mu sync.RWMutex + limiters map[string]*RateLimiter +} + +// NewRateLimiterRegistry creates an empty registry. +func NewRateLimiterRegistry() *RateLimiterRegistry { + return &RateLimiterRegistry{ + limiters: make(map[string]*RateLimiter), + } +} + +// Register adds a rate limiter for the given key at the given RPM. +// If rpm <= 0, no limiter is registered (unrestricted). +func (r *RateLimiterRegistry) Register(key string, rpm int) { + if rpm <= 0 { + return + } + r.mu.Lock() + defer r.mu.Unlock() + r.limiters[key] = newRateLimiter(rpm) +} + +// Wait acquires a token for the given key, blocking if needed. +// If no limiter is registered for key, returns immediately. +func (r *RateLimiterRegistry) Wait(ctx context.Context, key string) error { + r.mu.RLock() + rl := r.limiters[key] + r.mu.RUnlock() + if rl == nil { + return nil + } + return rl.Wait(ctx) +} + +// TryAcquire attempts to consume a token for the given key without blocking. +// If no limiter is registered for key, it returns true. +func (r *RateLimiterRegistry) TryAcquire(key string) bool { + r.mu.RLock() + rl := r.limiters[key] + r.mu.RUnlock() + if rl == nil { + return true + } + return rl.TryAcquire() +} + +// RegisterCandidates registers rate limiters for all candidates that have RPM > 0. +// Candidates with RPM == 0 are ignored (no restriction). +func (r *RateLimiterRegistry) RegisterCandidates(candidates []FallbackCandidate) { + for _, c := range candidates { + if c.RPM > 0 { + r.Register(c.StableKey(), c.RPM) + } + } +} diff --git a/pkg/providers/ratelimiter_test.go b/pkg/providers/ratelimiter_test.go new file mode 100644 index 000000000..9972616e9 --- /dev/null +++ b/pkg/providers/ratelimiter_test.go @@ -0,0 +1,209 @@ +package providers + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestRateLimiter_AllowsUpToRPM verifies that up to RPM requests pass immediately +// (burst capacity) and the (RPM+1)-th request is delayed. +func TestRateLimiter_AllowsUpToRPM(t *testing.T) { + rpm := 5 + rl := newRateLimiter(rpm) + + // All rpm tokens should be available immediately (bucket starts full). + for i := 0; i < rpm; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + if err := rl.Wait(ctx); err != nil { + t.Fatalf("request %d should pass immediately, got: %v", i+1, err) + } + cancel() + } + + // The next request must wait; cancel it to confirm it blocks. + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + err := rl.Wait(ctx) + if err == nil { + t.Fatal("expected request beyond RPM to block, but it passed immediately") + } +} + +// TestRateLimiter_ContextCancellation verifies that a blocked Wait respects cancellation. +func TestRateLimiter_ContextCancellation(t *testing.T) { + rl := newRateLimiter(1) + + // Drain the one token. + ctx := context.Background() + if err := rl.Wait(ctx); err != nil { + t.Fatalf("first request failed: %v", err) + } + + // Second request should block; cancel it. + cancelCtx, cancel := context.WithTimeout(context.Background(), 30*time.Millisecond) + defer cancel() + err := rl.Wait(cancelCtx) + if err == nil { + t.Fatal("expected cancellation error, got nil") + } +} + +// TestRateLimiter_TokenRefill verifies that tokens refill over time. +func TestRateLimiter_TokenRefill(t *testing.T) { + rpm := 60 // 1 token per second + rl := newRateLimiter(rpm) + + // Drain all tokens. + for i := 0; i < rpm; i++ { + rl.Wait(context.Background()) //nolint:errcheck + } + + // Advance time via nowFunc: simulate 2 seconds passing (should give 2 tokens). + start := time.Now() + rl.nowFunc = func() time.Time { return start.Add(2 * time.Second) } + + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if err := rl.Wait(ctx); err != nil { + t.Fatalf("expected refilled token to be available: %v", err) + } +} + +// TestRateLimiterRegistry_NoLimiter verifies that keys without a registered limiter pass freely. +func TestRateLimiterRegistry_NoLimiter(t *testing.T) { + r := NewRateLimiterRegistry() + ctx := context.Background() + for i := 0; i < 100; i++ { + if err := r.Wait(ctx, "unregistered/key"); err != nil { + t.Fatalf("unregistered key should not block: %v", err) + } + } +} + +// TestRateLimiterRegistry_ZeroRPM verifies that RPM=0 means no limiter is registered. +func TestRateLimiterRegistry_ZeroRPM(t *testing.T) { + r := NewRateLimiterRegistry() + r.Register("some/key", 0) + ctx := context.Background() + for i := 0; i < 50; i++ { + if err := r.Wait(ctx, "some/key"); err != nil { + t.Fatalf("zero-RPM key should not block: %v", err) + } + } +} + +// TestRateLimiterRegistry_Enforcement verifies the registry enforces RPM per key. +func TestRateLimiterRegistry_Enforcement(t *testing.T) { + r := NewRateLimiterRegistry() + r.Register("openai/gpt-4o", 3) + + // First 3 calls should pass (burst = RPM). + for i := 0; i < 3; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + if err := r.Wait(ctx, "openai/gpt-4o"); err != nil { + t.Fatalf("call %d should pass: %v", i+1, err) + } + cancel() + } + + // 4th call should block. + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err := r.Wait(ctx, "openai/gpt-4o"); err == nil { + t.Fatal("4th call should have been rate-limited") + } +} + +// TestRateLimiterRegistry_RegisterCandidates verifies that RegisterCandidates +// correctly picks up RPM from FallbackCandidate. +func TestRateLimiterRegistry_RegisterCandidates(t *testing.T) { + r := NewRateLimiterRegistry() + candidates := []FallbackCandidate{ + {Provider: "openai", Model: "gpt-4o", RPM: 2}, + {Provider: "anthropic", Model: "claude-3", RPM: 0}, // no limit + } + r.RegisterCandidates(candidates) + + // openai/gpt-4o: 2 tokens burst, 3rd should block. + for i := 0; i < 2; i++ { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + if err := r.Wait(ctx, "openai/gpt-4o"); err != nil { + t.Fatalf("openai call %d should pass: %v", i+1, err) + } + cancel() + } + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancel() + if err := r.Wait(ctx, "openai/gpt-4o"); err == nil { + t.Fatal("openai 3rd call should have been limited") + } + + // anthropic/claude-3: no limit, should always pass. + for i := 0; i < 10; i++ { + if err := r.Wait(context.Background(), "anthropic/claude-3"); err != nil { + t.Fatalf("anthropic call should not be limited: %v", err) + } + } +} + +func TestRateLimiterRegistry_RegisterCandidatesUsesStableIdentity(t *testing.T) { + r := NewRateLimiterRegistry() + candidates := []FallbackCandidate{ + {Provider: "openai", Model: "gpt-4o", RPM: 1, IdentityKey: "model_name:primary"}, + {Provider: "openai", Model: "gpt-4o", RPM: 2, IdentityKey: "model_name:fallback"}, + } + r.RegisterCandidates(candidates) + + if err := r.Wait(context.Background(), "model_name:primary"); err != nil { + t.Fatalf("primary first call should pass: %v", err) + } + if err := r.Wait(context.Background(), "model_name:fallback"); err != nil { + t.Fatalf("fallback first call should pass: %v", err) + } + if err := r.Wait(context.Background(), "model_name:fallback"); err != nil { + t.Fatalf("fallback second call should pass: %v", err) + } + + ctxPrimary, cancelPrimary := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancelPrimary() + if err := r.Wait(ctxPrimary, "model_name:primary"); err == nil { + t.Fatal("primary second call should have been limited") + } + + ctxFallback, cancelFallback := context.WithTimeout(context.Background(), 20*time.Millisecond) + defer cancelFallback() + if err := r.Wait(ctxFallback, "model_name:fallback"); err == nil { + t.Fatal("fallback third call should have been limited") + } +} + +// TestRateLimiter_Concurrency verifies thread safety under concurrent access. +func TestRateLimiter_Concurrency(t *testing.T) { + rpm := 20 + rl := newRateLimiter(rpm) + var passed atomic.Int64 + var wg sync.WaitGroup + + // Launch 30 goroutines; only ~20 should pass immediately. + for i := 0; i < 30; i++ { + wg.Add(1) + go func() { + defer wg.Done() + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + if rl.Wait(ctx) == nil { + passed.Add(1) + } + }() + } + wg.Wait() + + got := passed.Load() + // Allow small timing slack: between rpm-2 and rpm+2. + if got < int64(rpm-2) || got > int64(rpm+2) { + t.Fatalf("expected ~%d immediate passes, got %d", rpm, got) + } +}