Skip to content
Merged
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
ee5b618
fix: migration ModelName, reasoning_content, shell regex, loop boundary
putueddy Mar 1, 2026
ec54031
feat: add Kimi/Moonshot and Opencode provider support
putueddy Mar 1, 2026
a6f4274
feat: add message chunking in Telegram Send method
putueddy Mar 1, 2026
81aeaf1
fix: address Copilot review feedback on PR #932
putueddy Mar 1, 2026
9c91d66
Address Copilot review feedback for Kimi/Opencode providers
putueddy Mar 1, 2026
2dccee5
Address Copilot review feedback for Telegram message chunking
putueddy Mar 1, 2026
3501962
test: add unit tests for Telegram Send() method
putueddy Mar 2, 2026
4a067cd
Merge branch 'main' into feat/kimi-opencode-providers
putueddy Mar 2, 2026
d9b4af7
feat: add .env file loading and provider env overrides
putueddy Mar 2, 2026
4b7e8d9
feat: add Exa AI search provider
putueddy Mar 2, 2026
33109a1
Address Copilot review: handle HTML expansion exceeding Telegram limit
putueddy Mar 2, 2026
8219b5a
Address Copilot review feedback for Exa search provider
putueddy Mar 2, 2026
84ded81
Address Copilot review feedback for .env loading
putueddy Mar 2, 2026
df53f44
fix: format long lines in telegram_test.go to satisfy golines linter
putueddy Mar 3, 2026
2fc8798
fix: add kimi-code migration alias and User-Agent test
putueddy Mar 3, 2026
0e810a2
fix: tighten HTML-expansion test to stay under chunk size
putueddy Mar 3, 2026
e54b1d3
refactor: parse Kimi API hostname once in constructor instead of per-…
putueddy Mar 3, 2026
5dcd42e
Merge upstream/main into fix/bugfixes
putueddy Mar 3, 2026
56ad77b
Merge upstream/main into feat/dotenv-loading
putueddy Mar 3, 2026
5b608ae
test: use guardCommand directly and improve assertions in DiskWiping …
putueddy Mar 3, 2026
e503c87
fix: add LiteLLM to env overrides and fix malformed .env test
putueddy Mar 3, 2026
d257f1a
merge: resolve conflict with upstream/main in provider_test.go
putueddy Mar 3, 2026
c5d2298
Merge remote-tracking branch 'origin/feat/kimi-opencode-providers' in…
putueddy Mar 3, 2026
8ed351c
Merge remote-tracking branch 'origin/feat/telegram-chunking' into dep…
putueddy Mar 3, 2026
b7aaa5b
Merge remote-tracking branch 'origin/feat/dotenv-loading' into deploy…
putueddy Mar 3, 2026
fe97387
Merge remote-tracking branch 'origin/feat/exa-search' into deploy/pi-…
putueddy Mar 3, 2026
8bd1935
telegram: lower MaxMessageLength to 4000 for HTML expansion margin
putueddy Mar 4, 2026
3de4cb8
fix: pass original markdown to sendHTMLChunk for plain-text fallback
putueddy Mar 4, 2026
bd0018a
fix: use queue-based re-splitting for HTML expansion validation
putueddy Mar 4, 2026
11017ac
merge: resolve conflicts with upstream/main
putueddy Mar 4, 2026
f07dbd1
fix: remove redundant SplitMessage in Send() per review feedback
putueddy Mar 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ require (
github.com/gdamore/tcell/v2 v2.13.8
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/joho/godotenv v1.5.1
github.com/larksuite/oapi-sdk-go/v3 v3.5.3
github.com/mdp/qrterminal/v3 v3.2.1
github.com/modelcontextprotocol/go-sdk v1.3.0
Expand All @@ -37,7 +38,6 @@ require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/elliotchance/orderedmap/v3 v3.1.0 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/gdamore/tcell/v2 v2.13.8 // indirect
github.com/h2non/filetype v1.1.3 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyf
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
Expand Down
12 changes: 11 additions & 1 deletion pkg/agent/loop.go
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,9 @@ func registerSharedTools(
PerplexityAPIKey: cfg.Tools.Web.Perplexity.APIKey,
PerplexityMaxResults: cfg.Tools.Web.Perplexity.MaxResults,
PerplexityEnabled: cfg.Tools.Web.Perplexity.Enabled,
ExaAPIKey: cfg.Tools.Web.Exa.APIKey,
ExaMaxResults: cfg.Tools.Web.Exa.MaxResults,
ExaEnabled: cfg.Tools.Web.Exa.Enabled,
Proxy: cfg.Tools.Web.Proxy,
})
if err != nil {
Expand Down Expand Up @@ -1111,8 +1114,15 @@ func (al *AgentLoop) forceCompression(agent *AgentInstance, sessionKey string) {
return
}

// Helper to find the mid-point of the conversation
// Find the mid-point of the conversation, avoiding splitting tool call/result pairs.
// A tool-call message (role=assistant with ToolCalls) must be followed by its
// tool-result message (role=tool). Splitting between them causes API errors.
mid := len(conversation) / 2
if mid < len(conversation) && mid > 0 {
if conversation[mid].Role == "tool" {
mid++ // move past the tool result to keep the pair together
}
}

// New history structure:
// 1. System Prompt (with compression note appended)
Expand Down
79 changes: 79 additions & 0 deletions pkg/agent/loop_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,85 @@ func TestAgentLoop_ContextExhaustionRetry(t *testing.T) {
}
}

// TestForceCompression_ToolMessageBoundary verifies that forceCompression does not
// split a tool call/result pair when the midpoint falls on a "tool" role message.
// Regression test for: API errors when orphaned tool result messages appear
// without their preceding assistant tool-call message.
func TestForceCompression_ToolMessageBoundary(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)

cfg := &config.Config{
Agents: config.AgentsConfig{
Defaults: config.AgentDefaults{
Workspace: tmpDir,
Model: "test-model",
MaxTokens: 4096,
MaxToolIterations: 10,
},
},
}

msgBus := bus.NewMessageBus()
provider := &mockProvider{}
al := NewAgentLoop(cfg, msgBus, provider)

sessionKey := "test-session-tool-boundary"
defaultAgent := al.registry.GetDefaultAgent()
if defaultAgent == nil {
t.Fatal("No default agent found")
}

// Construct a history where len(conversation)/2 falls exactly on a "tool" message.
// history = [system, user, assistant(tool_call), tool, user, assistant, user_trigger]
// conversation = history[1:6] = [user, assistant(tool_call), tool, user, assistant]
// len(conversation) = 5, mid = 5/2 = 2 => conversation[2].Role == "tool"
// Without the fix, this would split between assistant(tool_call) and tool result.
history := []providers.Message{
{Role: "system", Content: "You are a helpful assistant."},
{Role: "user", Content: "What files are in the current directory?"},
{Role: "assistant", Content: "", ToolCalls: []providers.ToolCall{
{ID: "call_1", Name: "exec", Arguments: map[string]any{"command": "ls"}},
}},
{Role: "tool", Content: "file1.txt\nfile2.txt", ToolCallID: "call_1"},
{Role: "user", Content: "Tell me about file1.txt"},
{Role: "assistant", Content: "file1.txt is a text file."},
{Role: "user", Content: "Thanks"}, // trigger message
}

// Create the session first (AddMessage creates the session entry),
// then overwrite with our full history via SetHistory.
defaultAgent.Sessions.AddMessage(sessionKey, "system", "init")
defaultAgent.Sessions.SetHistory(sessionKey, history)

// Call forceCompression
al.forceCompression(defaultAgent, sessionKey)

// Verify the result
compressed := defaultAgent.Sessions.GetHistory(sessionKey)

// Check that no message with role="tool" is the first conversation message
// (after the system prompt). If it is, it means the tool result was orphaned.
for i := 1; i < len(compressed); i++ {
if compressed[i].Role == "tool" {
// There must be an assistant message with tool calls before it
if i == 1 {
t.Errorf("Tool result message at position %d is orphaned (no preceding assistant with tool call)", i)
} else if compressed[i-1].Role != "assistant" || len(compressed[i-1].ToolCalls) == 0 {
t.Errorf("Tool result at position %d is not preceded by assistant with tool calls (preceded by role=%q)", i, compressed[i-1].Role)
}
}
}

// Verify the system prompt has the compression note
if !strings.Contains(compressed[0].Content, "Emergency compression") {
t.Errorf("Expected compression note in system prompt, got: %s", compressed[0].Content)
}
}

func TestTargetReasoningChannelID_AllChannels(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "agent-test-*")
if err != nil {
Expand Down
45 changes: 40 additions & 5 deletions pkg/channels/telegram/telegram.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ func NewTelegramChannel(cfg *config.Config, bus *bus.MessageBus) (*TelegramChann
telegramCfg,
bus,
telegramCfg.AllowFrom,
channels.WithMaxMessageLength(4096),
channels.WithMaxMessageLength(4000),
channels.WithGroupTrigger(telegramCfg.GroupTrigger),
channels.WithReasoningChannelID(telegramCfg.ReasoningChannelID),
)
Expand Down Expand Up @@ -233,13 +233,49 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("invalid chat ID %s: %w", msg.ChatID, channels.ErrSendFailed)
}

htmlContent := markdownToTelegramHTML(msg.Content)
if msg.Content == "" {
return nil
}

// Split the raw markdown before converting to HTML so that
// SplitMessage's code-fence-aware logic works correctly and
// we never break HTML tags/entities by splitting converted output.
mdChunks := channels.SplitMessage(msg.Content, 4000)

// Typing/placeholder handled by Manager.preSend — just send the message
for _, chunk := range mdChunks {
htmlContent := markdownToTelegramHTML(chunk)

// If HTML expansion pushes the chunk over Telegram's 4096-char limit,
// re-split the markdown chunk with a proportionally smaller maxLen.
if len([]rune(htmlContent)) > 4096 {
ratio := float64(len([]rune(chunk))) / float64(len([]rune(htmlContent)))
smallerLen := int(float64(4096) * ratio * 0.95) // 5% safety margin
if smallerLen < 100 {
smallerLen = 100
}
subChunks := channels.SplitMessage(chunk, smallerLen)
for _, sub := range subChunks {
if err := c.sendHTMLChunk(ctx, chatID, markdownToTelegramHTML(sub)); err != nil {
return err
}
}
continue
}

if err := c.sendHTMLChunk(ctx, chatID, htmlContent); err != nil {
return err
}
}

return nil
}

// sendHTMLChunk sends a single HTML message, falling back to plain text on parse failure.
func (c *TelegramChannel) sendHTMLChunk(ctx context.Context, chatID int64, htmlContent string) error {
tgMsg := tu.Message(tu.ID(chatID), htmlContent)
tgMsg.ParseMode = telego.ModeHTML

if _, err = c.bot.SendMessage(ctx, tgMsg); err != nil {
if _, err := c.bot.SendMessage(ctx, tgMsg); err != nil {
logger.ErrorCF("telegram", "HTML parse failed, falling back to plain text", map[string]any{
"error": err.Error(),
})
Expand All @@ -248,7 +284,6 @@ func (c *TelegramChannel) Send(ctx context.Context, msg bus.OutboundMessage) err
return fmt.Errorf("telegram send: %w", channels.ErrTemporary)
}
}

return nil
}

Expand Down
Loading
Loading