Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ require (
github.com/samber/mo v1.16.0
github.com/samber/oops v1.21.0
github.com/samber/ro v0.3.0
github.com/samber/ro/plugins/fsnotify v0.0.0-20260516194255-a8c153943435
github.com/samber/ro/plugins/signal v0.0.0-20260516194255-a8c153943435
github.com/samber/slog-zerolog/v2 v2.9.2
github.com/spf13/cobra v1.10.2
github.com/spf13/viper v1.21.0
Expand Down
6 changes: 4 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,6 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho=
github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
Expand Down Expand Up @@ -107,6 +105,10 @@ github.com/samber/oops v1.21.0 h1:18atcO4oEigNFuGXqr3NZWZ6P0XOSEXyBSAMXdQRxTc=
github.com/samber/oops v1.21.0/go.mod h1:Hsm/sKPxtCfPh0w/cE3xVoRfSiE1joDRiStPAsmG9bo=
github.com/samber/ro v0.3.0 h1:fxrdIL9yuA6JiGQuPE/xSXovowdG6+WcMBCCEL00wVk=
github.com/samber/ro v0.3.0/go.mod h1:eInj5R1BbXfGoT1ef0HIO5Qie0wlPkkyL0koOaEmfNM=
github.com/samber/ro/plugins/fsnotify v0.0.0-20260516194255-a8c153943435 h1:xy3P6prHbHBCBe/pn5Qjd61k0dBnZmCN64ob9g5W27c=
github.com/samber/ro/plugins/fsnotify v0.0.0-20260516194255-a8c153943435/go.mod h1:39S5OD7epUKdNVSeg4tpy916q1SjxgIlMUN0twGI6sM=
github.com/samber/ro/plugins/signal v0.0.0-20260516194255-a8c153943435 h1:tySZEg7gguzOtqdOxOoe7EU4G34aPOb77d3MU/wJsG8=
github.com/samber/ro/plugins/signal v0.0.0-20260516194255-a8c153943435/go.mod h1:/zJel4/bMNX08spdRGRZvUWz1BE+iqXSZij3hDps3/A=
github.com/samber/slog-common v0.21.0 h1:Wo2hTly1Br5RjYqX/BTWJJeDnTE85oWk/7vqlpZuAUc=
github.com/samber/slog-common v0.21.0/go.mod h1:d/6OaSlzdkl9PFpfRLgn8FwY1OW6EFmPtBpsHX4MrU0=
github.com/samber/slog-zerolog/v2 v2.9.2 h1:DIFzfzDTxHeRyGlfg/D7b2by7VVzcsBTybRPrzjWF4c=
Expand Down
2 changes: 2 additions & 0 deletions internal/assistant/anthropic_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ func testCompletionRequestAuth(args ...string) *CompletionRequest {

return &CompletionRequest{
OnEvent: nil,
OnToolCall: nil,
OnToolResult: nil,
SessionID: "",
SystemPrompt: "",
ThinkingLevel: "",
Expand Down
26 changes: 18 additions & 8 deletions internal/assistant/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,14 +58,16 @@ const (

// CompletionRequest describes one model completion request.
type CompletionRequest struct {
OnEvent func(StreamEvent) `json:"-"`
SessionID string `json:"session_id"`
SystemPrompt string `json:"system_prompt"`
ThinkingLevel string `json:"thinking_level"`
CWD string `json:"cwd"`
Auth model.RequestAuth `json:"auth"`
Messages []database.MessageEntity `json:"messages"`
Model model.Model `json:"model"`
OnEvent func(StreamEvent) `json:"-"`
OnToolCall func(context.Context, ToolCallEvent) `json:"-"`
OnToolResult func(context.Context, *ToolEvent) `json:"-"`
SessionID string `json:"session_id"`
SystemPrompt string `json:"system_prompt"`
ThinkingLevel string `json:"thinking_level"`
CWD string `json:"cwd"`
Auth model.RequestAuth `json:"auth"`
Messages []database.MessageEntity `json:"messages"`
Model model.Model `json:"model"`
}

// CompletionResult is a provider response plus model-visible side effects.
Expand All @@ -76,6 +78,14 @@ type CompletionResult struct {
Usage model.TokenUsage `json:"usage"`
}

// ToolCallEvent captures one requested tool call before execution.
type ToolCallEvent struct {
Arguments map[string]any `json:"arguments,omitempty"`
ID string `json:"id"`
Name string `json:"name"`
ArgumentsJSON string `json:"arguments_json"`
}

// ToolEvent captures one tool call for persistence and TUI rendering.
type ToolEvent struct {
Name string `json:"name"`
Expand Down
23 changes: 22 additions & 1 deletion internal/assistant/client_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
//nolint:testpackage // These tests cover unexported SSE accumulator behavior.
package assistant

import "testing"
import (
"strings"
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const answerDelta = "answer"

Expand Down Expand Up @@ -58,3 +64,18 @@ func TestSSEAccumulatorEmitsReasoningDeltaSeparately(t *testing.T) {
t.Fatalf("reasoning deltas should not be response text parts: %#v", accumulator.parts)
}
}
func TestParseSSEResultExtractsToolCallFromOutputItems(t *testing.T) {
t.Parallel()

payload := `{"response":{"output":[{"id":"call_1","type":"function_call",` +
`"call_id":"call_1","name":"read","arguments":"{\"path\":\"README.md\"}"}]}}`
stream := "data: " + payload + "\n" + "data: [DONE]\n"

result, err := parseSSEResult(strings.NewReader(stream), nil)
require.NoError(t, err)
require.Len(t, result.ToolCalls, 1)
assert.Equal(t, "call_1", result.ToolCalls[0].ID)
assert.Equal(t, "read", result.ToolCalls[0].Name)
assert.Equal(t, `{"path":"README.md"}`, result.ToolCalls[0].ArgumentsJSON)
assert.Equal(t, "README.md", result.ToolCalls[0].Arguments["path"])
}
10 changes: 10 additions & 0 deletions internal/assistant/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,13 @@ func stringValue(value any) string {
return ""
}
}

func firstNonEmptyString(values ...any) string {
for _, value := range values {
if text := strings.TrimSpace(stringValue(value)); text != "" {
return text
}
}

return ""
}
13 changes: 10 additions & 3 deletions internal/assistant/openai_responses.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,14 @@ func (client *HTTPCompletionClient) completeResponsesLoop(
return result, nil
}
input = append(input, statelessResponseOutputItems(providerResult.OutputItems)...)
outputs, events := executeToolCalls(ctx, request.CWD, providerResult.ToolCalls, request.OnEvent)
outputs, events := executeToolCalls(
ctx,
request.CWD,
providerResult.ToolCalls,
request.OnEvent,
request.OnToolCall,
request.OnToolResult,
)
result.ToolEvents = append(result.ToolEvents, events...)
input = append(input, outputs...)
}
Expand Down Expand Up @@ -230,8 +237,8 @@ func toolCallsFromOutput(output []any) []toolCall {
}
calls = append(calls, toolCall{
Arguments: arguments,
ID: stringValue(object[jsonCallIDKey]),
Name: stringValue(object[jsonToolNameKey]),
ID: firstNonEmptyString(object[jsonCallIDKey], object["id"]),
Name: firstNonEmptyString(object[jsonToolNameKey], object["function"]),
ArgumentsJSON: argumentsJSON,
})
}
Expand Down
64 changes: 48 additions & 16 deletions internal/assistant/runtime.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,16 +652,15 @@ func (runtime *Runtime) modelResponse(
estimatedUsage := estimateTokenUsage(systemPrompt, messages, &selectedModel)
runtime.dispatchContextBuild(ctx, sessionID, cwd, systemPrompt, messages, estimatedUsage)
runtime.emitUsage(ctx, onEvent, estimatedUsage)
request := &CompletionRequest{
Model: selectedModel,
Auth: auth,
Messages: messages,
SessionID: sessionID,
SystemPrompt: systemPrompt,
ThinkingLevel: runtime.cfg.Assistant.ThinkingLevel,
CWD: cwd,
OnEvent: onEvent,
}
request := runtime.modelCompletionRequest(
&selectedModel,
auth,
messages,
sessionID,
systemPrompt,
cwd,
onEvent,
)
result, err := runtime.completeWithRetry(ctx, request, onRetry)
if err != nil {
return nil, err
Expand All @@ -677,20 +676,52 @@ func (runtime *Runtime) modelResponse(
}, nil
}

func (runtime *Runtime) modelCompletionRequest(
selectedModel *model.Model,
auth model.RequestAuth,
messages []database.MessageEntity,
sessionID string,
systemPrompt string,
cwd string,
onEvent func(StreamEvent),
) *CompletionRequest {
return &CompletionRequest{
OnEvent: onEvent,
OnToolCall: runtime.emitToolCall,
OnToolResult: runtime.emitToolResult,
SessionID: sessionID,
SystemPrompt: systemPrompt,
ThinkingLevel: runtime.cfg.Assistant.ThinkingLevel,
CWD: cwd,
Auth: auth,
Messages: messages,
Model: *selectedModel,
}
}

func (runtime *Runtime) completeWithRetry(
ctx context.Context,
request *CompletionRequest,
onRetry RetryEventHandler,
) (*CompletionResult, error) {
retry := retryConfig(runtime.cfg)
if !retry.Enabled || retry.MaxAttempts <= 1 {
return runtime.client.Complete(ctx, request)
runtime.emitProviderRequest(ctx, request, 1)
result, err := runtime.client.Complete(ctx, request)
if err != nil {
runtime.emitProviderError(ctx, request, 1, err)
return nil, err
}
runtime.emitProviderResponse(ctx, request, 1, result)
return result, nil
}

var lastErr error
for attempt := 1; attempt <= retry.MaxAttempts; attempt++ {
runtime.emitProviderRequest(ctx, request, attempt)
result, err := runtime.client.Complete(ctx, request)
if err == nil {
runtime.emitProviderResponse(ctx, request, attempt, result)
if attempt > 1 {
runtime.emitRetryEvent(ctx, onRetry, RetryEvent{
Kind: RetryEventEnd,
Expand All @@ -703,6 +734,7 @@ func (runtime *Runtime) completeWithRetry(
return result, nil
}
lastErr = err
runtime.emitProviderError(ctx, request, attempt, err)
if attempt == retry.MaxAttempts || !ShouldRetryModelError(err) {
return nil, err
}
Expand Down Expand Up @@ -815,7 +847,7 @@ func activeSkillEventPayload(skills []core.ActivatedSkill) []map[string]any {
payload = append(payload, map[string]any{
"name": skill.Name,
"description": skill.Description,
"path": skill.FilePath,
jsonPathKey: skill.FilePath,
"truncated": skills[index].Truncated,
})
}
Expand All @@ -828,10 +860,10 @@ func activeSkillMatchPayload(matches []core.SkillActivationDiagnostic) []map[str
for index := range matches {
match := matches[index]
payload = append(payload, map[string]any{
"name": match.Skill.Name,
"path": match.Skill.FilePath,
"reason": match.Reason,
"score": match.Score,
"name": match.Skill.Name,
jsonPathKey: match.Skill.FilePath,
"reason": match.Reason,
"score": match.Score,
})
}

Expand Down
98 changes: 98 additions & 0 deletions internal/assistant/runtime_events.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package assistant

import (
"context"

"github.com/omarluq/librecode/internal/extension"
)

const (
lifecycleAPIKey = "api"
lifecycleAttemptKey = "attempt"
lifecycleProviderKey = "provider"
lifecycleToolErrorKey = "error"
)

func (runtime *Runtime) emitProviderRequest(ctx context.Context, request *CompletionRequest, attempt int) {
if request == nil {
return
}
runtime.dispatchLifecycle(ctx, extension.LifecycleBeforeProviderRequest, map[string]any{
lifecycleAPIKey: request.Model.API,
lifecycleAttemptKey: attempt,
jsonModelKey: request.Model.ID,
lifecycleProviderKey: request.Model.Provider,
jsonSessionIDKey: request.SessionID,
"thinking_level": request.ThinkingLevel,
})
}

func (runtime *Runtime) emitProviderResponse(
ctx context.Context,
request *CompletionRequest,
attempt int,
result *CompletionResult,
) {
if request == nil || result == nil {
return
}
runtime.dispatchLifecycle(ctx, extension.LifecycleAfterProviderResponse, map[string]any{
lifecycleAPIKey: request.Model.API,
lifecycleAttemptKey: attempt,
jsonModelKey: request.Model.ID,
lifecycleProviderKey: request.Model.Provider,
jsonSessionIDKey: request.SessionID,
jsonTextKey: result.Text,
"thinking_count": len(result.Thinking),
"tool_event_count": len(result.ToolEvents),
jsonUsageKey: tokenUsageLifecyclePayload(result.Usage),
})
}

func (runtime *Runtime) emitProviderError(ctx context.Context, request *CompletionRequest, attempt int, err error) {
if request == nil || err == nil {
return
}
runtime.dispatchLifecycle(ctx, extension.LifecycleProviderError, map[string]any{
lifecycleAPIKey: request.Model.API,
lifecycleAttemptKey: attempt,
jsonModelKey: request.Model.ID,
lifecycleProviderKey: request.Model.Provider,
jsonSessionIDKey: request.SessionID,
lifecycleErrorKey: err.Error(),
})
}

func (runtime *Runtime) emitToolCall(ctx context.Context, call ToolCallEvent) {
runtime.dispatchLifecycle(ctx, extension.LifecycleToolCall, toolCallPayload(call))
}

func (runtime *Runtime) emitToolResult(ctx context.Context, event *ToolEvent) {
if event == nil {
return
}
payload := toolEventPayload(event)
runtime.dispatchLifecycle(ctx, extension.LifecycleToolResult, payload)
if event.Error != "" {
runtime.dispatchLifecycle(ctx, extension.LifecycleToolError, payload)
}
}

func toolCallPayload(call ToolCallEvent) map[string]any {
return map[string]any{
"call_id": call.ID,
jsonToolNameKey: call.Name,
"arguments_json": call.ArgumentsJSON,
"arguments": call.Arguments,
}
}

func toolEventPayload(event *ToolEvent) map[string]any {
return map[string]any{
jsonToolNameKey: event.Name,
"arguments_json": event.ArgumentsJSON,
"details_json": event.DetailsJSON,
"result": event.Result,
lifecycleToolErrorKey: event.Error,
}
}
Loading
Loading