From a6ec3d2460a93cc5a7559c6250ff5ae3da8c6bf0 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Sat, 30 May 2026 13:35:25 -0500 Subject: [PATCH 1/3] refactor(assistant): split runtime concerns --- internal/assistant/runtime.go | 868 -------------------------- internal/assistant/runtime_context.go | 71 +++ internal/assistant/runtime_model.go | 257 ++++++++ internal/assistant/runtime_persist.go | 243 +++++++ internal/assistant/runtime_session.go | 145 +++++ internal/assistant/runtime_skills.go | 76 +++ internal/assistant/runtime_slash.go | 145 +++++ 7 files changed, 937 insertions(+), 868 deletions(-) create mode 100644 internal/assistant/runtime_context.go create mode 100644 internal/assistant/runtime_model.go create mode 100644 internal/assistant/runtime_persist.go create mode 100644 internal/assistant/runtime_session.go create mode 100644 internal/assistant/runtime_skills.go create mode 100644 internal/assistant/runtime_slash.go diff --git a/internal/assistant/runtime.go b/internal/assistant/runtime.go index 65ec9c90..1539203d 100644 --- a/internal/assistant/runtime.go +++ b/internal/assistant/runtime.go @@ -3,7 +3,6 @@ package assistant import ( "context" - "fmt" "log/slog" "path/filepath" "strings" @@ -12,16 +11,12 @@ import ( "github.com/samber/oops" "github.com/omarluq/librecode/internal/config" - "github.com/omarluq/librecode/internal/core" "github.com/omarluq/librecode/internal/database" "github.com/omarluq/librecode/internal/event" "github.com/omarluq/librecode/internal/extension" "github.com/omarluq/librecode/internal/model" - "github.com/omarluq/librecode/internal/tool" ) -const slashPrefix = "/" - // Runtime coordinates prompt handling and durable sessions. type Runtime struct { cfg *config.Config @@ -101,17 +96,6 @@ type responseBundle struct { Usage model.TokenUsage } -type partialPromptBlock struct { - Role database.Role - Content string -} - -type partialPromptProgress struct { - forward func(StreamEvent) - blocks []partialPromptBlock - fallbackBlocks []partialPromptBlock -} - // NewRuntime creates an assistant runtime. func NewRuntime( cfg *config.Config, @@ -235,858 +219,6 @@ func (runtime *Runtime) emit(ctx context.Context, channel string, data any) { runtime.events.Emit(ctx, channel, data) } -func (runtime *Runtime) appendAssistantSideEffects( - ctx context.Context, - sessionID string, - userEntryID string, - bundle *responseBundle, -) (*string, error) { - parentID := &userEntryID - for _, thinking := range bundle.Thinking { - trimmed := strings.TrimSpace(thinking) - if trimmed == "" { - continue - } - message := database.MessageEntity{ - Timestamp: time.Now().UTC(), - Role: database.RoleThinking, - Content: trimmed, - Provider: runtime.cfg.Assistant.Provider, - Model: runtime.cfg.Assistant.Model, - } - entry, err := runtime.sessions.AppendMessage(ctx, sessionID, parentID, &message) - if err != nil { - return nil, oops.In("assistant").Code("append_thinking").Wrapf(err, "append thinking message") - } - runtime.dispatchMessageAppend(ctx, entry) - parentID = &entry.ID - } - for _, event := range bundle.ToolEvents { - message := database.MessageEntity{ - Timestamp: time.Now().UTC(), - Role: database.RoleToolResult, - Content: formatToolEvent(&event), - Provider: runtime.cfg.Assistant.Provider, - Model: runtime.cfg.Assistant.Model, - } - entry, err := runtime.sessions.AppendMessage(ctx, sessionID, parentID, &message) - if err != nil { - return nil, oops.In("assistant").Code("append_tool_result").Wrapf(err, "append tool result") - } - runtime.dispatchMessageAppend(ctx, entry) - parentID = &entry.ID - } - - return parentID, nil -} - -func (runtime *Runtime) respondWithPartialProgress( - ctx context.Context, - sessionID string, - userEntryID string, - request *PromptRequest, -) (*responseBundle, bool, error) { - progress := newPartialPromptProgress(request.OnEvent) - bundle, cached, err := runtime.respond( - ctx, - sessionID, - request.CWD, - request.Text, - progress.handle, - progress.retryHandler(request.OnRetry), - ) - if err != nil { - persistErr := runtime.appendPartialPromptFailure(ctx, sessionID, userEntryID, progress, err) - if persistErr != nil { - return nil, false, oops. - In("assistant"). - Code("persist_failed_prompt"). - Wrapf(persistErr, "persist failed prompt progress") - } - - return nil, false, err - } - - return bundle, cached, nil -} - -func newPartialPromptProgress(forward func(StreamEvent)) *partialPromptProgress { - return &partialPromptProgress{forward: forward, blocks: []partialPromptBlock{}, fallbackBlocks: nil} -} - -func (progress *partialPromptProgress) handle(streamEvent StreamEvent) { - if progress != nil { - progress.record(streamEvent) - } - if progress != nil && progress.forward != nil { - progress.forward(streamEvent) - } -} - -func (progress *partialPromptProgress) record(streamEvent StreamEvent) { - switch streamEvent.Kind { - case StreamEventTextDelta: - progress.append(database.RoleAssistant, streamEvent.Text) - case StreamEventThinkingDelta: - progress.append(database.RoleThinking, streamEvent.Text) - case StreamEventToolResult: - if streamEvent.ToolEvent != nil { - progress.append(database.RoleToolResult, formatToolEvent(streamEvent.ToolEvent)) - } - case StreamEventToolStart, - StreamEventSkillLoaded, - StreamEventUsage: - return - } -} - -func (progress *partialPromptProgress) retryHandler(forward RetryEventHandler) RetryEventHandler { - return func(retryEvent RetryEvent) { - if retryEvent.Kind == RetryEventStart { - progress.reset() - } - if forward != nil { - forward(retryEvent) - } - } -} - -func (progress *partialPromptProgress) reset() { - if progress == nil { - return - } - if len(progress.blocks) > 0 { - progress.fallbackBlocks = progressBlocks(progress.blocks) - } - progress.blocks = progress.blocks[:0] -} - -func (progress *partialPromptProgress) append(role database.Role, content string) { - if progress == nil || content == "" { - return - } - lastIndex := len(progress.blocks) - 1 - if lastIndex >= 0 && progress.blocks[lastIndex].Role == role && canMergePartialPromptBlock(role) { - progress.blocks[lastIndex].Content += content - return - } - progress.blocks = append(progress.blocks, partialPromptBlock{Role: role, Content: content}) -} - -func canMergePartialPromptBlock(role database.Role) bool { - return role == database.RoleAssistant || role == database.RoleThinking -} - -func (runtime *Runtime) appendPartialPromptFailure( - ctx context.Context, - sessionID string, - userEntryID string, - progress *partialPromptProgress, - promptErr error, -) error { - parentID := &userEntryID - for _, block := range progress.persistableBlocks() { - message := database.MessageEntity{ - Timestamp: time.Now().UTC(), - Role: block.Role, - Content: block.Content, - Provider: runtime.cfg.Assistant.Provider, - Model: runtime.cfg.Assistant.Model, - } - entry, err := runtime.sessions.AppendMessage(ctx, sessionID, parentID, &message) - if err != nil { - return oops.In("assistant").Code("append_partial_prompt").Wrapf(err, "append partial prompt progress") - } - runtime.dispatchMessageAppend(ctx, entry) - parentID = &entry.ID - } - message := database.MessageEntity{ - Timestamp: time.Now().UTC(), - Role: database.RoleCustom, - Content: "[system] " + promptErr.Error(), - Provider: runtime.cfg.Assistant.Provider, - Model: runtime.cfg.Assistant.Model, - } - entry, err := runtime.sessions.AppendMessage(ctx, sessionID, parentID, &message) - if err != nil { - return oops.In("assistant").Code("append_prompt_error").Wrapf(err, "append prompt error") - } - runtime.dispatchMessageAppend(ctx, entry) - - return nil -} - -func (progress *partialPromptProgress) persistableBlocks() []partialPromptBlock { - if progress == nil { - return nil - } - if len(progress.blocks) > 0 { - return progressBlocks(progress.blocks) - } - return progressBlocks(progress.fallbackBlocks) -} - -func progressBlocks(blocks []partialPromptBlock) []partialPromptBlock { - if len(blocks) == 0 { - return nil - } - clone := make([]partialPromptBlock, len(blocks)) - copy(clone, blocks) - - return clone -} - -func formatToolEvent(toolEvent *ToolEvent) string { - parts := []string{fmt.Sprintf("tool: %s", toolEvent.Name)} - if strings.TrimSpace(toolEvent.ArgumentsJSON) != "" { - parts = append(parts, "arguments:", toolEvent.ArgumentsJSON) - } - if toolEvent.Error != "" { - parts = append(parts, "error:", toolEvent.Error) - } - if strings.TrimSpace(toolEvent.DetailsJSON) != "" { - parts = append(parts, "details:", toolEvent.DetailsJSON) - } - if strings.TrimSpace(toolEvent.Result) != "" { - parts = append(parts, "output:", toolEvent.Result) - } - - return strings.Join(parts, "\n") -} - -func (runtime *Runtime) resolveSession( - ctx context.Context, - request *PromptRequest, -) (*database.SessionEntity, extension.LifecycleEventName, error) { - if request.SessionID != "" { - return runtime.resolveRequestedSession(ctx, request) - } - if request.ResumeLatest { - return runtime.resolveLatestOrNewSession(ctx, request) - } - - return runtime.createPromptSession(ctx, request) -} - -func (runtime *Runtime) resolveRequestedSession( - ctx context.Context, - request *PromptRequest, -) (*database.SessionEntity, extension.LifecycleEventName, error) { - if request.ResumeLatest { - return nil, "", oops. - In("assistant"). - Code("session_selection_conflict"). - Errorf("resume latest cannot be used with an explicit session") - } - loadedSession, found, err := runtime.sessions.GetSession(ctx, request.SessionID) - if err != nil { - return nil, "", oops. - In("assistant"). - Code("load_session"). - With("session_id", request.SessionID). - Wrapf(err, "load requested session") - } - if !found { - return nil, "", oops. - In("assistant"). - Code("session_not_found"). - With("session_id", request.SessionID). - Errorf("session not found") - } - - return loadedSession, extension.LifecycleSessionLoad, nil -} - -func (runtime *Runtime) resolveLatestOrNewSession( - ctx context.Context, - request *PromptRequest, -) (*database.SessionEntity, extension.LifecycleEventName, error) { - if request.Name != "" { - return nil, "", oops. - In("assistant"). - Code("session_selection_conflict"). - Errorf("resume latest cannot be used with a new session name") - } - latestSession, found, err := runtime.sessions.LatestSession(ctx, request.CWD) - if err != nil { - return nil, "", oops. - In("assistant"). - Code("load_latest_session"). - With("cwd", request.CWD). - Wrapf(err, "load latest session") - } - if found { - return latestSession, extension.LifecycleSessionLoad, nil - } - - return runtime.createPromptSession(ctx, request) -} - -func (runtime *Runtime) createPromptSession( - ctx context.Context, - request *PromptRequest, -) (*database.SessionEntity, extension.LifecycleEventName, error) { - if request.Name != "" { - session, err := runtime.sessions.CreateSession(ctx, request.CWD, request.Name, "") - if err != nil { - return nil, "", oops. - In("assistant"). - Code("create_named_session"). - With("cwd", request.CWD). - With("name", request.Name). - Wrapf(err, "create named session") - } - - return session, extension.LifecycleSessionStart, nil - } - - session, err := runtime.sessions.CreateSession(ctx, request.CWD, "", "") - if err != nil { - return nil, "", oops. - In("assistant"). - Code("create_session"). - With("cwd", request.CWD). - Wrapf(err, "create session") - } - - return session, extension.LifecycleSessionStart, nil -} - -func (runtime *Runtime) notifyPromptUserEntry(request *PromptRequest, sessionID, entryID string) { - if request.OnUserEntry == nil { - return - } - request.OnUserEntry(PromptUserEntryEvent{SessionID: sessionID, EntryID: entryID}) -} - -func (runtime *Runtime) promptParentID(ctx context.Context, sessionID string, explicitParent *string) (*string, error) { - if explicitParent != nil { - return explicitPromptParentID(explicitParent), nil - } - - leaf, _, err := runtime.sessions.LeafEntry(ctx, sessionID) - if err != nil { - return nil, err - } - - return parentIDFromEntry(leaf), nil -} - -func explicitPromptParentID(explicitParent *string) *string { - if *explicitParent == "" { - return nil - } - - return explicitParent -} - -func (runtime *Runtime) respond( - ctx context.Context, - sessionID string, - cwd string, - prompt string, - onEvent func(StreamEvent), - onRetry RetryEventHandler, -) ( - bundle *responseBundle, - cached bool, - err error, -) { - if strings.HasPrefix(prompt, slashPrefix) { - slashResponse, slashToolEvents, slashErr := runtime.respondToSlashCommand(ctx, cwd, prompt, onEvent) - return &responseBundle{ - Text: slashResponse, - Thinking: nil, - ToolEvents: slashToolEvents, - Usage: model.EmptyTokenUsage(), - }, false, slashErr - } - - cacheKey := runtime.cacheKey(sessionID, prompt) - cachedResponse, found, err := runtime.cache.Get(cacheKey) - if err != nil { - return nil, false, oops.In("assistant").Code("cache_get").Wrapf(err, "read response cache") - } - if found { - return &responseBundle{ - Text: cachedResponse, - Thinking: nil, - ToolEvents: nil, - Usage: model.EmptyTokenUsage(), - }, true, nil - } - - bundle, err = runtime.modelResponse(ctx, sessionID, cwd, prompt, onEvent, onRetry) - if err != nil { - return nil, false, err - } - runtime.cache.Set(cacheKey, bundle.Text) - - return bundle, false, nil -} - -func (runtime *Runtime) respondToSlashCommand( - ctx context.Context, - cwd string, - prompt string, - onEvent func(StreamEvent), -) (string, []ToolEvent, error) { - commandName, commandArgs := splitSlashCommand(prompt) - if commandName == "" { - return "", nil, fmt.Errorf("assistant: empty slash command") - } - - if commandName == "skill" { - return runtime.respondToSkillCommand(ctx, cwd, commandArgs, onEvent) - } - if commandName == "tool" { - response, err := runtime.respondToToolCommand(ctx, cwd, commandArgs) - return response, nil, err - } - - response, err := runtime.extensions.ExecuteCommand(ctx, commandName, commandArgs) - if err != nil { - return "", nil, oops. - In("assistant"). - Code("extension_command"). - With("command", commandName). - Wrapf(err, "execute command") - } - - return response, nil, nil -} - -func (runtime *Runtime) respondToSkillCommand( - ctx context.Context, - cwd string, - args string, - onEvent func(StreamEvent), -) (string, []ToolEvent, error) { - skills := core.LoadSkills(cwd, nil, true).Skills - name := strings.TrimSpace(args) - if name == "" { - if len(skills) == 0 { - return "No skills found.", nil, nil - } - lines := []string{"Available skills:"} - for index := range skills { - lines = append(lines, fmt.Sprintf("- %s: %s", skills[index].Name, skills[index].Description)) - } - - return strings.Join(lines, "\n"), nil, nil - } - - for index := range skills { - skill := &skills[index] - if skill.Name != name { - continue - } - result, toolEvent, err := runtime.loadSkillWithReadTool(ctx, cwd, skill, nil) - if err != nil { - return "", nil, err - } - emitStreamEvent(onEvent, StreamEvent{ - ToolEvent: &toolEvent, - Usage: nil, - Kind: StreamEventSkillLoaded, - Text: skill.Name, - }) - - return result, []ToolEvent{toolEvent}, nil - } - - return "", nil, fmt.Errorf("assistant: skill %q not found", name) -} - -func (runtime *Runtime) loadSkillWithReadTool( - ctx context.Context, - cwd string, - skill *core.Skill, - limit *int, -) (string, ToolEvent, error) { - registry := tool.NewRegistry(cwd) - input := map[string]any{jsonPathKey: skill.FilePath} - if limit != nil { - input["limit"] = *limit - } - result, err := registry.Execute(ctx, string(tool.NameRead), input) - toolEvent := ToolEvent{ - Name: "load skill: " + skill.Name, - ArgumentsJSON: skillReadArgumentsJSON(skill.FilePath, limit), - DetailsJSON: "", - Result: result.Text(), - Error: "", - } - if err != nil { - toolEvent.Error = err.Error() - return "", toolEvent, oops.In("assistant").Code("skill_read").Wrapf(err, "load skill with read tool") - } - - return result.Text(), toolEvent, nil -} - -func skillReadArgumentsJSON(path string, limit *int) string { - if limit == nil { - return fmt.Sprintf(`{"path":%q}`, path) - } - - return fmt.Sprintf(`{"path":%q,"limit":%d}`, path, *limit) -} - -func (runtime *Runtime) respondToToolCommand(ctx context.Context, cwd, args string) (string, error) { - toolName, payload, found := strings.Cut(strings.TrimSpace(args), " ") - if toolName == "" { - return "", fmt.Errorf("assistant: tool command requires a tool name") - } - if !found || strings.TrimSpace(payload) == "" { - payload = "{}" - } - - registry := tool.NewRegistry(cwd) - result, err := registry.ExecuteJSON(ctx, toolName, []byte(payload)) - if err != nil { - return "", oops. - In("assistant"). - Code("builtin_tool"). - With("tool", toolName). - Wrapf(err, "execute built-in tool") - } - - return result.Text(), nil -} - -func (runtime *Runtime) modelResponse( - ctx context.Context, - sessionID string, - cwd string, - prompt string, - onEvent func(StreamEvent), - onRetry RetryEventHandler, -) (*responseBundle, error) { - if runtime.models == nil { - return nil, oops.In("assistant").Code("models_unavailable").Errorf("model registry is not configured") - } - selectedModel, err := runtime.selectedModel() - if err != nil { - return nil, err - } - auth := runtime.models.RequestAuthContext(ctx, selectedModel.Provider) - if !auth.OK { - return nil, oops.In("assistant"). - Code("auth_missing"). - With("provider", selectedModel.Provider). - Wrapf(fmt.Errorf("%s", auth.Error), "resolve model auth") - } - contextResult, err := runtime.buildModelContext(ctx, sessionID, cwd, prompt, &selectedModel, onEvent) - if err != nil { - return nil, err - } - runtime.emitUsage(ctx, onEvent, contextResult.Usage) - registry, err := newToolRegistry(cwd, runtime.extensions) - if err != nil { - return nil, err - } - request := runtime.modelCompletionRequest( - &selectedModel, - auth, - contextResult.Messages, - sessionID, - contextResult.SystemPrompt, - cwd, - contextResult.Usage, - registry, - onEvent, - ) - result, err := runtime.completeWithRetry(ctx, request, onRetry) - if err != nil { - return nil, err - } - usage := mergeUsage(contextResult.Usage, result.Usage) - runtime.emitUsage(ctx, onEvent, usage) - - return &responseBundle{ - Text: result.Text, - Thinking: result.Thinking, - ToolEvents: result.ToolEvents, - Usage: usage, - }, nil -} - -func (runtime *Runtime) modelCompletionRequest( - selectedModel *model.Model, - auth model.RequestAuth, - messages []database.MessageEntity, - sessionID string, - systemPrompt string, - cwd string, - usage model.TokenUsage, - registry *tool.Registry, - onEvent func(StreamEvent), -) *CompletionRequest { - return &CompletionRequest{ - OnEvent: onEvent, - OnProviderObserve: runtime.emitProviderRequest, - OnProviderRequest: runtime.dispatchProviderRequestHook, - OnToolCall: runtime.dispatchToolCallLifecycle, - OnToolResult: runtime.dispatchToolResultLifecycle, - ToolRegistry: registry, - SessionID: sessionID, - SystemPrompt: systemPrompt, - ThinkingLevel: runtime.cfg.Assistant.ThinkingLevel, - CWD: cwd, - Auth: auth, - Messages: messages, - Usage: usage, - Model: *selectedModel, - ProviderAttempt: 0, - } -} - -func (runtime *Runtime) completeWithRetry( - ctx context.Context, - request *CompletionRequest, - onRetry RetryEventHandler, -) (*CompletionResult, error) { - retry := retryConfig(runtime.cfg) - if !retry.Enabled || retry.MaxAttempts <= 1 { - request.ProviderAttempt = 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++ { - request.ProviderAttempt = 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, - Error: "", - Attempt: attempt, - MaxAttempts: retry.MaxAttempts, - Delay: 0, - }) - } - return result, nil - } - lastErr = err - runtime.emitProviderError(ctx, request, attempt, err) - if attempt == retry.MaxAttempts || !ShouldRetryModelError(err) { - return nil, err - } - delay := retryDelay(attempt, retry) - runtime.emitRetryEvent(ctx, onRetry, RetryEvent{ - Kind: RetryEventStart, - Attempt: attempt + 1, - MaxAttempts: retry.MaxAttempts, - Delay: delay, - Error: err.Error(), - }) - if waitErr := waitForRetry(ctx, delay); waitErr != nil { - return nil, oops.In("assistant").Code("retry_canceled").Wrapf(waitErr, "wait before retry") - } - } - - return nil, lastErr -} - -func (runtime *Runtime) emitRetryEvent(ctx context.Context, handler RetryEventHandler, retryEvent RetryEvent) { - if handler != nil { - handler(retryEvent) - } - runtime.emit(ctx, string(retryEvent.Kind), retryEvent) - if runtime.extensions == nil { - return - } - if err := runtime.extensions.Emit(ctx, string(retryEvent.Kind), map[string]any{ - "attempt": retryEvent.Attempt, - "max_attempts": retryEvent.MaxAttempts, - "delay_ms": retryEvent.Delay.Milliseconds(), - "error": retryEvent.Error, - }); err != nil && runtime.logger != nil { - runtime.logger.Debug("extension retry event failed", "event", retryEvent.Kind, "error", err) - } -} - -func (runtime *Runtime) selectedModel() (model.Model, error) { - provider := runtime.cfg.Assistant.Provider - modelID := runtime.cfg.Assistant.Model - models := runtime.models.All() - for index := range models { - candidate := &models[index] - if candidate.Provider == provider && candidate.ID == modelID { - return *candidate, nil - } - } - if provider == "" || modelID == "" { - return model.Model{}, oops.In("assistant").Code("model_missing").Errorf("select a model with /model or /login") - } - - return model.Model{ - ThinkingLevelMap: nil, - Headers: nil, - Compat: nil, - Provider: provider, - ID: modelID, - Name: modelID, - API: "openai-completions", - BaseURL: "", - Input: []model.InputMode{model.InputText}, - Cost: model.Cost{Input: 0, Output: 0, CacheRead: 0, CacheWrite: 0}, - ContextWindow: 0, - MaxTokens: 0, - Reasoning: false, - }, nil -} - -func (runtime *Runtime) emitActivatedSkillReads( - ctx context.Context, - cwd string, - skills []core.ActivatedSkill, - onEvent func(StreamEvent), -) []ToolEvent { - if len(skills) == 0 { - return nil - } - limit := maxActiveSkillReadLines() - toolEvents := make([]ToolEvent, 0, len(skills)) - for index := range skills { - skill := &skills[index].Skill - _, toolEvent, err := runtime.loadSkillWithReadTool(ctx, cwd, skill, &limit) - if err != nil { - runtime.logger.Debug( - "failed to emit activated skill read", - slog.String("skill", skill.Name), - slog.Any("error", err), - ) - } - emitStreamEvent(onEvent, StreamEvent{ - ToolEvent: &toolEvent, - Usage: nil, - Kind: StreamEventSkillLoaded, - Text: skill.Name, - }) - toolEvents = append(toolEvents, toolEvent) - } - - return toolEvents -} - -func maxActiveSkillReadLines() int { - return 2000 -} - -func activeSkillEventPayload(skills []core.ActivatedSkill) []map[string]any { - payload := make([]map[string]any, 0, len(skills)) - for index := range skills { - skill := skills[index].Skill - payload = append(payload, map[string]any{ - "name": skill.Name, - "description": skill.Description, - jsonPathKey: skill.FilePath, - "truncated": skills[index].Truncated, - }) - } - - return payload -} - -func activeSkillMatchPayload(matches []core.SkillActivationDiagnostic) []map[string]any { - payload := make([]map[string]any, 0, len(matches)) - for index := range matches { - match := matches[index] - payload = append(payload, map[string]any{ - "name": match.Skill.Name, - jsonPathKey: match.Skill.FilePath, - "reason": match.Reason, - "score": match.Score, - }) - } - - return payload -} - -func (runtime *Runtime) modelContextMessages(ctx context.Context, sessionID string) ([]database.MessageEntity, error) { - leafEntry, _, err := runtime.sessions.LeafEntry(ctx, sessionID) - if err != nil { - return nil, oops.In("assistant").Code("load_context_leaf").Wrapf(err, "load session leaf") - } - leafID := "" - if leafEntry != nil { - leafID = leafEntry.ID - } - contextEntity, err := runtime.sessions.BuildContext(ctx, sessionID, leafID) - if err != nil { - return nil, oops.In("assistant").Code("load_context").Wrapf(err, "load session context") - } - - return modelFacingMessages(contextEntity.Messages), nil -} - -func modelFacingMessages(messages []database.MessageEntity) []database.MessageEntity { - filtered := make([]database.MessageEntity, 0, len(messages)) - for index := range messages { - message := messages[index] - if !isModelFacingRole(message.Role) || strings.TrimSpace(message.Content) == "" { - continue - } - filtered = append(filtered, message) - } - - return filtered -} - -func isModelFacingRole(role database.Role) bool { - switch role { - case database.RoleUser, database.RoleAssistant: - return true - case database.RoleToolResult, - database.RoleThinking, - database.RoleCustom, - database.RoleBashExecution, - database.RoleBranchSummary, - database.RoleCompactionSummary: - return false - } - - return false -} - -func baseSystemPrompt(cwd string) string { - return strings.Join([]string{ - "You are librecode, an AI coding assistant. Be concise, helpful, and accurate.", - "You are running inside a local filesystem workspace.", - fmt.Sprintf("Current working directory: %s", cwd), - "Use built-in tools (ls, find, grep, read, bash, edit, write) " + - "to inspect or change workspace files when needed.", - "Do not claim you cannot access files; inspect them with tools instead.", - "Respect .gitignore and default ignored paths; avoid ignored files unless explicitly needed.", - "Use the fewest tool calls needed; once you have enough evidence, stop using tools and answer.", - }, "\n") -} - -func (runtime *Runtime) cacheKey(sessionID, prompt string) string { - return strings.Join( - []string{runtime.cfg.Assistant.Provider, runtime.cfg.Assistant.Model, sessionID, prompt}, - "\x00", - ) -} - -func parentIDFromEntry(entry *database.EntryEntity) *string { - if entry == nil { - return nil - } - - return &entry.ID -} - func splitSlashCommand(prompt string) (name, args string) { trimmedPrompt := strings.TrimSpace(strings.TrimPrefix(prompt, slashPrefix)) if trimmedPrompt == "" { diff --git a/internal/assistant/runtime_context.go b/internal/assistant/runtime_context.go new file mode 100644 index 00000000..a41f2f49 --- /dev/null +++ b/internal/assistant/runtime_context.go @@ -0,0 +1,71 @@ +// Package assistant orchestrates conversations, extensions, cache, and prompt execution. +package assistant + +import ( + "context" + "fmt" + "strings" + + "github.com/samber/oops" + + "github.com/omarluq/librecode/internal/database" +) + +func (runtime *Runtime) modelContextMessages(ctx context.Context, sessionID string) ([]database.MessageEntity, error) { + leafEntry, _, err := runtime.sessions.LeafEntry(ctx, sessionID) + if err != nil { + return nil, oops.In("assistant").Code("load_context_leaf").Wrapf(err, "load session leaf") + } + leafID := "" + if leafEntry != nil { + leafID = leafEntry.ID + } + contextEntity, err := runtime.sessions.BuildContext(ctx, sessionID, leafID) + if err != nil { + return nil, oops.In("assistant").Code("load_context").Wrapf(err, "load session context") + } + + return modelFacingMessages(contextEntity.Messages), nil +} + +func modelFacingMessages(messages []database.MessageEntity) []database.MessageEntity { + filtered := make([]database.MessageEntity, 0, len(messages)) + for index := range messages { + message := messages[index] + if !isModelFacingRole(message.Role) || strings.TrimSpace(message.Content) == "" { + continue + } + filtered = append(filtered, message) + } + + return filtered +} + +func isModelFacingRole(role database.Role) bool { + switch role { + case database.RoleUser, database.RoleAssistant: + return true + case database.RoleToolResult, + database.RoleThinking, + database.RoleCustom, + database.RoleBashExecution, + database.RoleBranchSummary, + database.RoleCompactionSummary: + return false + } + + return false +} + +func baseSystemPrompt(cwd string) string { + return strings.Join([]string{ + "You are librecode, an AI coding assistant. Be concise, helpful, and accurate.", + "You are running inside a local filesystem workspace.", + fmt.Sprintf("Current working directory: %s", cwd), + "Use built-in tools (ls, find, grep, read, bash, edit, write) " + + "to inspect or change workspace files when needed.", + "Do not claim you cannot access files; inspect them with tools instead.", + "Respect .gitignore and default ignored paths; avoid ignored files unless explicitly needed.", + "Use the fewest tool calls needed; once you have enough evidence, stop using tools and answer.", + }, "\n") +} diff --git a/internal/assistant/runtime_model.go b/internal/assistant/runtime_model.go new file mode 100644 index 00000000..dbf73d05 --- /dev/null +++ b/internal/assistant/runtime_model.go @@ -0,0 +1,257 @@ +// Package assistant orchestrates conversations, extensions, cache, and prompt execution. +package assistant + +import ( + "context" + "fmt" + "strings" + + "github.com/samber/oops" + + "github.com/omarluq/librecode/internal/database" + "github.com/omarluq/librecode/internal/model" + "github.com/omarluq/librecode/internal/tool" +) + +func (runtime *Runtime) respond( + ctx context.Context, + sessionID string, + cwd string, + prompt string, + onEvent func(StreamEvent), + onRetry RetryEventHandler, +) ( + bundle *responseBundle, + cached bool, + err error, +) { + if strings.HasPrefix(prompt, slashPrefix) { + slashResponse, slashToolEvents, slashErr := runtime.respondToSlashCommand(ctx, cwd, prompt, onEvent) + return &responseBundle{ + Text: slashResponse, + Thinking: nil, + ToolEvents: slashToolEvents, + Usage: model.EmptyTokenUsage(), + }, false, slashErr + } + + cacheKey := runtime.cacheKey(sessionID, prompt) + cachedResponse, found, err := runtime.cache.Get(cacheKey) + if err != nil { + return nil, false, oops.In("assistant").Code("cache_get").Wrapf(err, "read response cache") + } + if found { + return &responseBundle{ + Text: cachedResponse, + Thinking: nil, + ToolEvents: nil, + Usage: model.EmptyTokenUsage(), + }, true, nil + } + + bundle, err = runtime.modelResponse(ctx, sessionID, cwd, prompt, onEvent, onRetry) + if err != nil { + return nil, false, err + } + runtime.cache.Set(cacheKey, bundle.Text) + + return bundle, false, nil +} + +func (runtime *Runtime) modelResponse( + ctx context.Context, + sessionID string, + cwd string, + prompt string, + onEvent func(StreamEvent), + onRetry RetryEventHandler, +) (*responseBundle, error) { + if runtime.models == nil { + return nil, oops.In("assistant").Code("models_unavailable").Errorf("model registry is not configured") + } + selectedModel, err := runtime.selectedModel() + if err != nil { + return nil, err + } + auth := runtime.models.RequestAuthContext(ctx, selectedModel.Provider) + if !auth.OK { + return nil, oops.In("assistant"). + Code("auth_missing"). + With("provider", selectedModel.Provider). + Wrapf(fmt.Errorf("%s", auth.Error), "resolve model auth") + } + contextResult, err := runtime.buildModelContext(ctx, sessionID, cwd, prompt, &selectedModel, onEvent) + if err != nil { + return nil, err + } + runtime.emitUsage(ctx, onEvent, contextResult.Usage) + registry, err := newToolRegistry(cwd, runtime.extensions) + if err != nil { + return nil, err + } + request := runtime.modelCompletionRequest( + &selectedModel, + auth, + contextResult.Messages, + sessionID, + contextResult.SystemPrompt, + cwd, + contextResult.Usage, + registry, + onEvent, + ) + result, err := runtime.completeWithRetry(ctx, request, onRetry) + if err != nil { + return nil, err + } + usage := mergeUsage(contextResult.Usage, result.Usage) + runtime.emitUsage(ctx, onEvent, usage) + + return &responseBundle{ + Text: result.Text, + Thinking: result.Thinking, + ToolEvents: result.ToolEvents, + Usage: usage, + }, nil +} + +func (runtime *Runtime) modelCompletionRequest( + selectedModel *model.Model, + auth model.RequestAuth, + messages []database.MessageEntity, + sessionID string, + systemPrompt string, + cwd string, + usage model.TokenUsage, + registry *tool.Registry, + onEvent func(StreamEvent), +) *CompletionRequest { + return &CompletionRequest{ + OnEvent: onEvent, + OnProviderObserve: runtime.emitProviderRequest, + OnProviderRequest: runtime.dispatchProviderRequestHook, + OnToolCall: runtime.dispatchToolCallLifecycle, + OnToolResult: runtime.dispatchToolResultLifecycle, + ToolRegistry: registry, + SessionID: sessionID, + SystemPrompt: systemPrompt, + ThinkingLevel: runtime.cfg.Assistant.ThinkingLevel, + CWD: cwd, + Auth: auth, + Messages: messages, + Usage: usage, + Model: *selectedModel, + ProviderAttempt: 0, + } +} + +func (runtime *Runtime) completeWithRetry( + ctx context.Context, + request *CompletionRequest, + onRetry RetryEventHandler, +) (*CompletionResult, error) { + retry := retryConfig(runtime.cfg) + if !retry.Enabled || retry.MaxAttempts <= 1 { + request.ProviderAttempt = 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++ { + request.ProviderAttempt = 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, + Error: "", + Attempt: attempt, + MaxAttempts: retry.MaxAttempts, + Delay: 0, + }) + } + return result, nil + } + lastErr = err + runtime.emitProviderError(ctx, request, attempt, err) + if attempt == retry.MaxAttempts || !ShouldRetryModelError(err) { + return nil, err + } + delay := retryDelay(attempt, retry) + runtime.emitRetryEvent(ctx, onRetry, RetryEvent{ + Kind: RetryEventStart, + Attempt: attempt + 1, + MaxAttempts: retry.MaxAttempts, + Delay: delay, + Error: err.Error(), + }) + if waitErr := waitForRetry(ctx, delay); waitErr != nil { + return nil, oops.In("assistant").Code("retry_canceled").Wrapf(waitErr, "wait before retry") + } + } + + return nil, lastErr +} + +func (runtime *Runtime) emitRetryEvent(ctx context.Context, handler RetryEventHandler, retryEvent RetryEvent) { + if handler != nil { + handler(retryEvent) + } + runtime.emit(ctx, string(retryEvent.Kind), retryEvent) + if runtime.extensions == nil { + return + } + if err := runtime.extensions.Emit(ctx, string(retryEvent.Kind), map[string]any{ + "attempt": retryEvent.Attempt, + "max_attempts": retryEvent.MaxAttempts, + "delay_ms": retryEvent.Delay.Milliseconds(), + "error": retryEvent.Error, + }); err != nil && runtime.logger != nil { + runtime.logger.Debug("extension retry event failed", "event", retryEvent.Kind, "error", err) + } +} + +func (runtime *Runtime) selectedModel() (model.Model, error) { + provider := runtime.cfg.Assistant.Provider + modelID := runtime.cfg.Assistant.Model + models := runtime.models.All() + for index := range models { + candidate := &models[index] + if candidate.Provider == provider && candidate.ID == modelID { + return *candidate, nil + } + } + if provider == "" || modelID == "" { + return model.Model{}, oops.In("assistant").Code("model_missing").Errorf("select a model with /model or /login") + } + + return model.Model{ + ThinkingLevelMap: nil, + Headers: nil, + Compat: nil, + Provider: provider, + ID: modelID, + Name: modelID, + API: "openai-completions", + BaseURL: "", + Input: []model.InputMode{model.InputText}, + Cost: model.Cost{Input: 0, Output: 0, CacheRead: 0, CacheWrite: 0}, + ContextWindow: 0, + MaxTokens: 0, + Reasoning: false, + }, nil +} + +func (runtime *Runtime) cacheKey(sessionID, prompt string) string { + return strings.Join( + []string{runtime.cfg.Assistant.Provider, runtime.cfg.Assistant.Model, sessionID, prompt}, + "\x00", + ) +} diff --git a/internal/assistant/runtime_persist.go b/internal/assistant/runtime_persist.go new file mode 100644 index 00000000..22adbe02 --- /dev/null +++ b/internal/assistant/runtime_persist.go @@ -0,0 +1,243 @@ +// Package assistant orchestrates conversations, extensions, cache, and prompt execution. +package assistant + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/samber/oops" + + "github.com/omarluq/librecode/internal/database" +) + +type partialPromptBlock struct { + Role database.Role + Content string +} + +type partialPromptProgress struct { + forward func(StreamEvent) + blocks []partialPromptBlock + fallbackBlocks []partialPromptBlock +} + +func (runtime *Runtime) appendAssistantSideEffects( + ctx context.Context, + sessionID string, + userEntryID string, + bundle *responseBundle, +) (*string, error) { + parentID := &userEntryID + for _, thinking := range bundle.Thinking { + trimmed := strings.TrimSpace(thinking) + if trimmed == "" { + continue + } + message := database.MessageEntity{ + Timestamp: time.Now().UTC(), + Role: database.RoleThinking, + Content: trimmed, + Provider: runtime.cfg.Assistant.Provider, + Model: runtime.cfg.Assistant.Model, + } + entry, err := runtime.sessions.AppendMessage(ctx, sessionID, parentID, &message) + if err != nil { + return nil, oops.In("assistant").Code("append_thinking").Wrapf(err, "append thinking message") + } + runtime.dispatchMessageAppend(ctx, entry) + parentID = &entry.ID + } + for _, event := range bundle.ToolEvents { + message := database.MessageEntity{ + Timestamp: time.Now().UTC(), + Role: database.RoleToolResult, + Content: formatToolEvent(&event), + Provider: runtime.cfg.Assistant.Provider, + Model: runtime.cfg.Assistant.Model, + } + entry, err := runtime.sessions.AppendMessage(ctx, sessionID, parentID, &message) + if err != nil { + return nil, oops.In("assistant").Code("append_tool_result").Wrapf(err, "append tool result") + } + runtime.dispatchMessageAppend(ctx, entry) + parentID = &entry.ID + } + + return parentID, nil +} + +func (runtime *Runtime) respondWithPartialProgress( + ctx context.Context, + sessionID string, + userEntryID string, + request *PromptRequest, +) (*responseBundle, bool, error) { + progress := newPartialPromptProgress(request.OnEvent) + bundle, cached, err := runtime.respond( + ctx, + sessionID, + request.CWD, + request.Text, + progress.handle, + progress.retryHandler(request.OnRetry), + ) + if err != nil { + persistErr := runtime.appendPartialPromptFailure(ctx, sessionID, userEntryID, progress, err) + if persistErr != nil { + return nil, false, oops. + In("assistant"). + Code("persist_failed_prompt"). + Wrapf(persistErr, "persist failed prompt progress") + } + + return nil, false, err + } + + return bundle, cached, nil +} + +func newPartialPromptProgress(forward func(StreamEvent)) *partialPromptProgress { + return &partialPromptProgress{forward: forward, blocks: []partialPromptBlock{}, fallbackBlocks: nil} +} + +func (progress *partialPromptProgress) handle(streamEvent StreamEvent) { + if progress != nil { + progress.record(streamEvent) + } + if progress != nil && progress.forward != nil { + progress.forward(streamEvent) + } +} + +func (progress *partialPromptProgress) record(streamEvent StreamEvent) { + switch streamEvent.Kind { + case StreamEventTextDelta: + progress.append(database.RoleAssistant, streamEvent.Text) + case StreamEventThinkingDelta: + progress.append(database.RoleThinking, streamEvent.Text) + case StreamEventToolResult: + if streamEvent.ToolEvent != nil { + progress.append(database.RoleToolResult, formatToolEvent(streamEvent.ToolEvent)) + } + case StreamEventToolStart, + StreamEventSkillLoaded, + StreamEventUsage: + return + } +} + +func (progress *partialPromptProgress) retryHandler(forward RetryEventHandler) RetryEventHandler { + return func(retryEvent RetryEvent) { + if retryEvent.Kind == RetryEventStart { + progress.reset() + } + if forward != nil { + forward(retryEvent) + } + } +} + +func (progress *partialPromptProgress) reset() { + if progress == nil { + return + } + if len(progress.blocks) > 0 { + progress.fallbackBlocks = progressBlocks(progress.blocks) + } + progress.blocks = progress.blocks[:0] +} + +func (progress *partialPromptProgress) append(role database.Role, content string) { + if progress == nil || content == "" { + return + } + lastIndex := len(progress.blocks) - 1 + if lastIndex >= 0 && progress.blocks[lastIndex].Role == role && canMergePartialPromptBlock(role) { + progress.blocks[lastIndex].Content += content + return + } + progress.blocks = append(progress.blocks, partialPromptBlock{Role: role, Content: content}) +} + +func canMergePartialPromptBlock(role database.Role) bool { + return role == database.RoleAssistant || role == database.RoleThinking +} + +func (runtime *Runtime) appendPartialPromptFailure( + ctx context.Context, + sessionID string, + userEntryID string, + progress *partialPromptProgress, + promptErr error, +) error { + parentID := &userEntryID + for _, block := range progress.persistableBlocks() { + message := database.MessageEntity{ + Timestamp: time.Now().UTC(), + Role: block.Role, + Content: block.Content, + Provider: runtime.cfg.Assistant.Provider, + Model: runtime.cfg.Assistant.Model, + } + entry, err := runtime.sessions.AppendMessage(ctx, sessionID, parentID, &message) + if err != nil { + return oops.In("assistant").Code("append_partial_prompt").Wrapf(err, "append partial prompt progress") + } + runtime.dispatchMessageAppend(ctx, entry) + parentID = &entry.ID + } + message := database.MessageEntity{ + Timestamp: time.Now().UTC(), + Role: database.RoleCustom, + Content: "[system] " + promptErr.Error(), + Provider: runtime.cfg.Assistant.Provider, + Model: runtime.cfg.Assistant.Model, + } + entry, err := runtime.sessions.AppendMessage(ctx, sessionID, parentID, &message) + if err != nil { + return oops.In("assistant").Code("append_prompt_error").Wrapf(err, "append prompt error") + } + runtime.dispatchMessageAppend(ctx, entry) + + return nil +} + +func (progress *partialPromptProgress) persistableBlocks() []partialPromptBlock { + if progress == nil { + return nil + } + if len(progress.blocks) > 0 { + return progressBlocks(progress.blocks) + } + return progressBlocks(progress.fallbackBlocks) +} + +func progressBlocks(blocks []partialPromptBlock) []partialPromptBlock { + if len(blocks) == 0 { + return nil + } + clone := make([]partialPromptBlock, len(blocks)) + copy(clone, blocks) + + return clone +} + +func formatToolEvent(toolEvent *ToolEvent) string { + parts := []string{fmt.Sprintf("tool: %s", toolEvent.Name)} + if strings.TrimSpace(toolEvent.ArgumentsJSON) != "" { + parts = append(parts, "arguments:", toolEvent.ArgumentsJSON) + } + if toolEvent.Error != "" { + parts = append(parts, "error:", toolEvent.Error) + } + if strings.TrimSpace(toolEvent.DetailsJSON) != "" { + parts = append(parts, "details:", toolEvent.DetailsJSON) + } + if strings.TrimSpace(toolEvent.Result) != "" { + parts = append(parts, "output:", toolEvent.Result) + } + + return strings.Join(parts, "\n") +} diff --git a/internal/assistant/runtime_session.go b/internal/assistant/runtime_session.go new file mode 100644 index 00000000..126898c5 --- /dev/null +++ b/internal/assistant/runtime_session.go @@ -0,0 +1,145 @@ +// Package assistant orchestrates conversations, extensions, cache, and prompt execution. +package assistant + +import ( + "context" + + "github.com/samber/oops" + + "github.com/omarluq/librecode/internal/database" + "github.com/omarluq/librecode/internal/extension" +) + +func (runtime *Runtime) resolveSession( + ctx context.Context, + request *PromptRequest, +) (*database.SessionEntity, extension.LifecycleEventName, error) { + if request.SessionID != "" { + return runtime.resolveRequestedSession(ctx, request) + } + if request.ResumeLatest { + return runtime.resolveLatestOrNewSession(ctx, request) + } + + return runtime.createPromptSession(ctx, request) +} + +func (runtime *Runtime) resolveRequestedSession( + ctx context.Context, + request *PromptRequest, +) (*database.SessionEntity, extension.LifecycleEventName, error) { + if request.ResumeLatest { + return nil, "", oops. + In("assistant"). + Code("session_selection_conflict"). + Errorf("resume latest cannot be used with an explicit session") + } + loadedSession, found, err := runtime.sessions.GetSession(ctx, request.SessionID) + if err != nil { + return nil, "", oops. + In("assistant"). + Code("load_session"). + With("session_id", request.SessionID). + Wrapf(err, "load requested session") + } + if !found { + return nil, "", oops. + In("assistant"). + Code("session_not_found"). + With("session_id", request.SessionID). + Errorf("session not found") + } + + return loadedSession, extension.LifecycleSessionLoad, nil +} + +func (runtime *Runtime) resolveLatestOrNewSession( + ctx context.Context, + request *PromptRequest, +) (*database.SessionEntity, extension.LifecycleEventName, error) { + if request.Name != "" { + return nil, "", oops. + In("assistant"). + Code("session_selection_conflict"). + Errorf("resume latest cannot be used with a new session name") + } + latestSession, found, err := runtime.sessions.LatestSession(ctx, request.CWD) + if err != nil { + return nil, "", oops. + In("assistant"). + Code("load_latest_session"). + With("cwd", request.CWD). + Wrapf(err, "load latest session") + } + if found { + return latestSession, extension.LifecycleSessionLoad, nil + } + + return runtime.createPromptSession(ctx, request) +} + +func (runtime *Runtime) createPromptSession( + ctx context.Context, + request *PromptRequest, +) (*database.SessionEntity, extension.LifecycleEventName, error) { + if request.Name != "" { + session, err := runtime.sessions.CreateSession(ctx, request.CWD, request.Name, "") + if err != nil { + return nil, "", oops. + In("assistant"). + Code("create_named_session"). + With("cwd", request.CWD). + With("name", request.Name). + Wrapf(err, "create named session") + } + + return session, extension.LifecycleSessionStart, nil + } + + session, err := runtime.sessions.CreateSession(ctx, request.CWD, "", "") + if err != nil { + return nil, "", oops. + In("assistant"). + Code("create_session"). + With("cwd", request.CWD). + Wrapf(err, "create session") + } + + return session, extension.LifecycleSessionStart, nil +} + +func (runtime *Runtime) notifyPromptUserEntry(request *PromptRequest, sessionID, entryID string) { + if request.OnUserEntry == nil { + return + } + request.OnUserEntry(PromptUserEntryEvent{SessionID: sessionID, EntryID: entryID}) +} + +func (runtime *Runtime) promptParentID(ctx context.Context, sessionID string, explicitParent *string) (*string, error) { + if explicitParent != nil { + return explicitPromptParentID(explicitParent), nil + } + + leaf, _, err := runtime.sessions.LeafEntry(ctx, sessionID) + if err != nil { + return nil, err + } + + return parentIDFromEntry(leaf), nil +} + +func explicitPromptParentID(explicitParent *string) *string { + if *explicitParent == "" { + return nil + } + + return explicitParent +} + +func parentIDFromEntry(entry *database.EntryEntity) *string { + if entry == nil { + return nil + } + + return &entry.ID +} diff --git a/internal/assistant/runtime_skills.go b/internal/assistant/runtime_skills.go new file mode 100644 index 00000000..7fc900ee --- /dev/null +++ b/internal/assistant/runtime_skills.go @@ -0,0 +1,76 @@ +// Package assistant orchestrates conversations, extensions, cache, and prompt execution. +package assistant + +import ( + "context" + "log/slog" + + "github.com/omarluq/librecode/internal/core" +) + +func (runtime *Runtime) emitActivatedSkillReads( + ctx context.Context, + cwd string, + skills []core.ActivatedSkill, + onEvent func(StreamEvent), +) []ToolEvent { + if len(skills) == 0 { + return nil + } + limit := maxActiveSkillReadLines() + toolEvents := make([]ToolEvent, 0, len(skills)) + for index := range skills { + skill := &skills[index].Skill + _, toolEvent, err := runtime.loadSkillWithReadTool(ctx, cwd, skill, &limit) + if err != nil { + runtime.logger.Debug( + "failed to emit activated skill read", + slog.String("skill", skill.Name), + slog.Any("error", err), + ) + } + emitStreamEvent(onEvent, StreamEvent{ + ToolEvent: &toolEvent, + Usage: nil, + Kind: StreamEventSkillLoaded, + Text: skill.Name, + }) + toolEvents = append(toolEvents, toolEvent) + } + + return toolEvents +} + +func maxActiveSkillReadLines() int { + return 2000 +} + +func activeSkillEventPayload(skills []core.ActivatedSkill) []map[string]any { + payload := make([]map[string]any, 0, len(skills)) + for index := range skills { + skill := skills[index].Skill + payload = append(payload, map[string]any{ + "name": skill.Name, + "description": skill.Description, + jsonPathKey: skill.FilePath, + "truncated": skills[index].Truncated, + }) + } + + return payload +} + +func activeSkillMatchPayload(matches []core.SkillActivationDiagnostic) []map[string]any { + payload := make([]map[string]any, 0, len(matches)) + for index := range matches { + match := matches[index] + payload = append(payload, map[string]any{ + "name": match.Skill.Name, + jsonPathKey: match.Skill.FilePath, + "reason": match.Reason, + "score": match.Score, + }) + } + + return payload +} diff --git a/internal/assistant/runtime_slash.go b/internal/assistant/runtime_slash.go new file mode 100644 index 00000000..40f8842a --- /dev/null +++ b/internal/assistant/runtime_slash.go @@ -0,0 +1,145 @@ +// Package assistant orchestrates conversations, extensions, cache, and prompt execution. +package assistant + +import ( + "context" + "fmt" + "strings" + + "github.com/samber/oops" + + "github.com/omarluq/librecode/internal/core" + "github.com/omarluq/librecode/internal/tool" +) + +const slashPrefix = "/" + +func (runtime *Runtime) respondToSlashCommand( + ctx context.Context, + cwd string, + prompt string, + onEvent func(StreamEvent), +) (string, []ToolEvent, error) { + commandName, commandArgs := splitSlashCommand(prompt) + if commandName == "" { + return "", nil, fmt.Errorf("assistant: empty slash command") + } + + if commandName == "skill" { + return runtime.respondToSkillCommand(ctx, cwd, commandArgs, onEvent) + } + if commandName == "tool" { + response, err := runtime.respondToToolCommand(ctx, cwd, commandArgs) + return response, nil, err + } + + response, err := runtime.extensions.ExecuteCommand(ctx, commandName, commandArgs) + if err != nil { + return "", nil, oops. + In("assistant"). + Code("extension_command"). + With("command", commandName). + Wrapf(err, "execute command") + } + + return response, nil, nil +} + +func (runtime *Runtime) respondToSkillCommand( + ctx context.Context, + cwd string, + args string, + onEvent func(StreamEvent), +) (string, []ToolEvent, error) { + skills := core.LoadSkills(cwd, nil, true).Skills + name := strings.TrimSpace(args) + if name == "" { + if len(skills) == 0 { + return "No skills found.", nil, nil + } + lines := []string{"Available skills:"} + for index := range skills { + lines = append(lines, fmt.Sprintf("- %s: %s", skills[index].Name, skills[index].Description)) + } + + return strings.Join(lines, "\n"), nil, nil + } + + for index := range skills { + skill := &skills[index] + if skill.Name != name { + continue + } + result, toolEvent, err := runtime.loadSkillWithReadTool(ctx, cwd, skill, nil) + if err != nil { + return "", nil, err + } + emitStreamEvent(onEvent, StreamEvent{ + ToolEvent: &toolEvent, + Usage: nil, + Kind: StreamEventSkillLoaded, + Text: skill.Name, + }) + + return result, []ToolEvent{toolEvent}, nil + } + + return "", nil, fmt.Errorf("assistant: skill %q not found", name) +} + +func (runtime *Runtime) loadSkillWithReadTool( + ctx context.Context, + cwd string, + skill *core.Skill, + limit *int, +) (string, ToolEvent, error) { + registry := tool.NewRegistry(cwd) + input := map[string]any{jsonPathKey: skill.FilePath} + if limit != nil { + input["limit"] = *limit + } + result, err := registry.Execute(ctx, string(tool.NameRead), input) + toolEvent := ToolEvent{ + Name: "load skill: " + skill.Name, + ArgumentsJSON: skillReadArgumentsJSON(skill.FilePath, limit), + DetailsJSON: "", + Result: result.Text(), + Error: "", + } + if err != nil { + toolEvent.Error = err.Error() + return "", toolEvent, oops.In("assistant").Code("skill_read").Wrapf(err, "load skill with read tool") + } + + return result.Text(), toolEvent, nil +} + +func skillReadArgumentsJSON(path string, limit *int) string { + if limit == nil { + return fmt.Sprintf(`{"path":%q}`, path) + } + + return fmt.Sprintf(`{"path":%q,"limit":%d}`, path, *limit) +} + +func (runtime *Runtime) respondToToolCommand(ctx context.Context, cwd, args string) (string, error) { + toolName, payload, found := strings.Cut(strings.TrimSpace(args), " ") + if toolName == "" { + return "", fmt.Errorf("assistant: tool command requires a tool name") + } + if !found || strings.TrimSpace(payload) == "" { + payload = "{}" + } + + registry := tool.NewRegistry(cwd) + result, err := registry.ExecuteJSON(ctx, toolName, []byte(payload)) + if err != nil { + return "", oops. + In("assistant"). + Code("builtin_tool"). + With("tool", toolName). + Wrapf(err, "execute built-in tool") + } + + return result.Text(), nil +} From 0f401d9577aafd3952f8f256719ec40b928d7900 Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Sat, 30 May 2026 13:51:47 -0500 Subject: [PATCH 2/3] fix(assistant): address runtime split review --- internal/assistant/runtime_model.go | 64 ++++++++++++++-------------- internal/assistant/runtime_skills.go | 8 ++-- internal/assistant/runtime_slash.go | 4 +- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/internal/assistant/runtime_model.go b/internal/assistant/runtime_model.go index dbf73d05..8dc49336 100644 --- a/internal/assistant/runtime_model.go +++ b/internal/assistant/runtime_model.go @@ -89,17 +89,17 @@ func (runtime *Runtime) modelResponse( if err != nil { return nil, err } - request := runtime.modelCompletionRequest( - &selectedModel, - auth, - contextResult.Messages, - sessionID, - contextResult.SystemPrompt, - cwd, - contextResult.Usage, - registry, - onEvent, - ) + request := runtime.modelCompletionRequest(&modelCompletionRequestInput{ + selectedModel: &selectedModel, + registry: registry, + onEvent: onEvent, + messages: contextResult.Messages, + auth: auth, + usage: contextResult.Usage, + sessionID: sessionID, + systemPrompt: contextResult.SystemPrompt, + cwd: cwd, + }) result, err := runtime.completeWithRetry(ctx, request, onRetry) if err != nil { return nil, err @@ -115,32 +115,34 @@ func (runtime *Runtime) modelResponse( }, nil } -func (runtime *Runtime) modelCompletionRequest( - selectedModel *model.Model, - auth model.RequestAuth, - messages []database.MessageEntity, - sessionID string, - systemPrompt string, - cwd string, - usage model.TokenUsage, - registry *tool.Registry, - onEvent func(StreamEvent), -) *CompletionRequest { +type modelCompletionRequestInput struct { + selectedModel *model.Model + registry *tool.Registry + onEvent func(StreamEvent) + sessionID string + systemPrompt string + cwd string + auth model.RequestAuth + messages []database.MessageEntity + usage model.TokenUsage +} + +func (runtime *Runtime) modelCompletionRequest(input *modelCompletionRequestInput) *CompletionRequest { return &CompletionRequest{ - OnEvent: onEvent, + OnEvent: input.onEvent, OnProviderObserve: runtime.emitProviderRequest, OnProviderRequest: runtime.dispatchProviderRequestHook, OnToolCall: runtime.dispatchToolCallLifecycle, OnToolResult: runtime.dispatchToolResultLifecycle, - ToolRegistry: registry, - SessionID: sessionID, - SystemPrompt: systemPrompt, + ToolRegistry: input.registry, + SessionID: input.sessionID, + SystemPrompt: input.systemPrompt, ThinkingLevel: runtime.cfg.Assistant.ThinkingLevel, - CWD: cwd, - Auth: auth, - Messages: messages, - Usage: usage, - Model: *selectedModel, + CWD: input.cwd, + Auth: input.auth, + Messages: input.messages, + Usage: input.usage, + Model: *input.selectedModel, ProviderAttempt: 0, } } diff --git a/internal/assistant/runtime_skills.go b/internal/assistant/runtime_skills.go index 7fc900ee..9a99f641 100644 --- a/internal/assistant/runtime_skills.go +++ b/internal/assistant/runtime_skills.go @@ -8,6 +8,8 @@ import ( "github.com/omarluq/librecode/internal/core" ) +const maxActiveSkillReadLines = 2000 + func (runtime *Runtime) emitActivatedSkillReads( ctx context.Context, cwd string, @@ -17,7 +19,7 @@ func (runtime *Runtime) emitActivatedSkillReads( if len(skills) == 0 { return nil } - limit := maxActiveSkillReadLines() + limit := maxActiveSkillReadLines toolEvents := make([]ToolEvent, 0, len(skills)) for index := range skills { skill := &skills[index].Skill @@ -41,10 +43,6 @@ func (runtime *Runtime) emitActivatedSkillReads( return toolEvents } -func maxActiveSkillReadLines() int { - return 2000 -} - func activeSkillEventPayload(skills []core.ActivatedSkill) []map[string]any { payload := make([]map[string]any, 0, len(skills)) for index := range skills { diff --git a/internal/assistant/runtime_slash.go b/internal/assistant/runtime_slash.go index 40f8842a..ca5f3887 100644 --- a/internal/assistant/runtime_slash.go +++ b/internal/assistant/runtime_slash.go @@ -22,7 +22,7 @@ func (runtime *Runtime) respondToSlashCommand( ) (string, []ToolEvent, error) { commandName, commandArgs := splitSlashCommand(prompt) if commandName == "" { - return "", nil, fmt.Errorf("assistant: empty slash command") + return "", nil, oops.In("assistant").Code("empty_slash_command").Errorf("empty slash command") } if commandName == "skill" { @@ -84,7 +84,7 @@ func (runtime *Runtime) respondToSkillCommand( return result, []ToolEvent{toolEvent}, nil } - return "", nil, fmt.Errorf("assistant: skill %q not found", name) + return "", nil, oops.In("assistant").Code("skill_not_found").With("skill", name).Errorf("skill %q not found", name) } func (runtime *Runtime) loadSkillWithReadTool( From 5025fe0853b09bff3d0e71ebe8f35089a760a7ca Mon Sep 17 00:00:00 2001 From: Omar Alani Date: Sat, 30 May 2026 14:12:22 -0500 Subject: [PATCH 3/3] chore: apply go modernizations --- internal/assistant/client_test.go | 1 - internal/assistant/context_usage.go | 6 ++-- internal/assistant/lifecycle.go | 5 ++-- internal/assistant/lifecycle_diagnostics.go | 5 ++-- internal/assistant/provider_hooks.go | 9 ++---- internal/assistant/retry.go | 31 +++++++++++++++------ internal/assistant/tool_schema.go | 5 ++-- internal/database/entry_metadata.go | 8 +++--- internal/database/validation_test.go | 1 - internal/extension/lifecycle.go | 13 +++------ internal/extension/sources.go | 2 +- internal/terminal/token_usage.go | 5 ++-- internal/tool/bash.go | 3 +- internal/tool/edit_diff.go | 4 +-- 14 files changed, 48 insertions(+), 50 deletions(-) diff --git a/internal/assistant/client_test.go b/internal/assistant/client_test.go index fa40efcf..67700a8a 100644 --- a/internal/assistant/client_test.go +++ b/internal/assistant/client_test.go @@ -110,7 +110,6 @@ func TestParseSSEResultFailureCases(t *testing.T) { } for _, testCase := range tests { - testCase := testCase t.Run(testCase.name, func(t *testing.T) { t.Parallel() diff --git a/internal/assistant/context_usage.go b/internal/assistant/context_usage.go index 8950be71..b5309eea 100644 --- a/internal/assistant/context_usage.go +++ b/internal/assistant/context_usage.go @@ -1,13 +1,13 @@ package assistant +import "maps" + func cloneIntMapForUsage(values map[string]int) map[string]int { if len(values) == 0 { return nil } cloned := make(map[string]int, len(values)) - for key, value := range values { - cloned[key] = value - } + maps.Copy(cloned, values) return cloned } diff --git a/internal/assistant/lifecycle.go b/internal/assistant/lifecycle.go index 48317922..90b6cc7b 100644 --- a/internal/assistant/lifecycle.go +++ b/internal/assistant/lifecycle.go @@ -3,6 +3,7 @@ package assistant import ( "context" "log/slog" + "maps" "github.com/omarluq/librecode/internal/database" "github.com/omarluq/librecode/internal/extension" @@ -319,9 +320,7 @@ func contextBuildLifecyclePayload( func cloneAnyMap(values map[string]any) map[string]any { cloned := make(map[string]any, len(values)) - for key, value := range values { - cloned[key] = value - } + maps.Copy(cloned, values) return cloned } diff --git a/internal/assistant/lifecycle_diagnostics.go b/internal/assistant/lifecycle_diagnostics.go index 3c0cfe45..ec5d178a 100644 --- a/internal/assistant/lifecycle_diagnostics.go +++ b/internal/assistant/lifecycle_diagnostics.go @@ -3,6 +3,7 @@ package assistant import ( "context" "fmt" + "maps" "sort" "time" @@ -29,9 +30,7 @@ func (runtime *Runtime) emitLifecycleDiagnostics( if len(result.Errors) > 0 { payload[lifecycleErrorsKey] = append([]string{}, result.Errors...) } - for key, value := range extra { - payload[key] = value - } + maps.Copy(payload, extra) runtime.emit(ctx, string(name)+"_diagnostic", payload) } diff --git a/internal/assistant/provider_hooks.go b/internal/assistant/provider_hooks.go index e5b1b2e4..0d37d7ba 100644 --- a/internal/assistant/provider_hooks.go +++ b/internal/assistant/provider_hooks.go @@ -2,6 +2,7 @@ package assistant import ( "context" + "maps" "strings" "github.com/samber/oops" @@ -138,9 +139,7 @@ func providerPayloadFromLifecycle(payload, fallback map[string]any) map[string]a func mergeProviderHeaders(headers, additions map[string]string) map[string]string { merged := cloneStringMap(headers) - for key, value := range additions { - merged[key] = value - } + maps.Copy(merged, additions) return merged } @@ -176,9 +175,7 @@ func cloneStringMap(values map[string]string) map[string]string { return map[string]string{} } cloned := make(map[string]string, len(values)) - for key, value := range values { - cloned[key] = value - } + maps.Copy(cloned, values) return cloned } diff --git a/internal/assistant/retry.go b/internal/assistant/retry.go index 737fb6e3..4cb1c616 100644 --- a/internal/assistant/retry.go +++ b/internal/assistant/retry.go @@ -98,24 +98,37 @@ func ShouldRetryModelError(err error) bool { if errors.Is(err, context.DeadlineExceeded) { return retryableDeadlineExceeded(message) } - if code, ok := providerErrorCode(err); ok { - if nonRetryableProviderCode(code) { - return false - } - if retryableProviderCode(code) { - return true - } + if retry, ok := retryDecisionFromProviderCode(err); ok { + return retry } if status, ok := providerErrorStatus(err); ok { return retryableStatus(status) } - var netErr net.Error - if errors.As(err, &netErr) { + if retryableNetworkError(err) { return true } return retryableProviderMessage(message) } +func retryDecisionFromProviderCode(err error) (retry, known bool) { + code, ok := providerErrorCode(err) + if !ok { + return false, false + } + if nonRetryableProviderCode(code) { + return false, true + } + if retryableProviderCode(code) { + return true, true + } + return false, false +} + +func retryableNetworkError(err error) bool { + netErr, ok := errors.AsType[net.Error](err) + return ok && netErr != nil +} + func retryableDeadlineExceeded(message string) bool { // Match provider/client timeout details, not wrapper call-site labels such as // "request provider response", so caller-owned deadlines remain non-retryable. diff --git a/internal/assistant/tool_schema.go b/internal/assistant/tool_schema.go index 2724d110..37767309 100644 --- a/internal/assistant/tool_schema.go +++ b/internal/assistant/tool_schema.go @@ -2,6 +2,7 @@ package assistant import ( "encoding/json" + "maps" "github.com/omarluq/librecode/internal/tool" ) @@ -197,9 +198,7 @@ func lsToolSchema() map[string]any { func cloneToolSchema(schema map[string]any) map[string]any { clone := make(map[string]any, len(schema)) - for key, value := range schema { - clone[key] = value - } + maps.Copy(clone, schema) return clone } diff --git a/internal/database/entry_metadata.go b/internal/database/entry_metadata.go index 399785e2..277d23d0 100644 --- a/internal/database/entry_metadata.go +++ b/internal/database/entry_metadata.go @@ -121,8 +121,8 @@ func parseToolMetadata(content string) toolMetadata { func splitToolSections(content string) map[string]string { sections := map[string]string{} current := "" - lines := strings.Split(content, "\n") - for _, line := range lines { + lines := strings.SplitSeq(content, "\n") + for line := range lines { name, value, ok := splitToolHeader(line) if ok { current = name @@ -145,8 +145,8 @@ func splitToolSections(content string) map[string]string { func splitToolHeader(line string) (name, value string, ok bool) { for _, section := range []string{"tool", "arguments", "error", "details", "output"} { prefix := section + ":" - if strings.HasPrefix(line, prefix) { - return section, strings.TrimSpace(strings.TrimPrefix(line, prefix)), true + if after, ok0 := strings.CutPrefix(line, prefix); ok0 { + return section, strings.TrimSpace(after), true } } diff --git a/internal/database/validation_test.go b/internal/database/validation_test.go index 725a0bc1..73ca058d 100644 --- a/internal/database/validation_test.go +++ b/internal/database/validation_test.go @@ -23,7 +23,6 @@ func TestRepositoryRejectsInvalidUUIDs(t *testing.T) { t.Parallel() for _, test := range invalidUUIDCases() { - test := test t.Run(test.name, func(t *testing.T) { t.Parallel() diff --git a/internal/extension/lifecycle.go b/internal/extension/lifecycle.go index 525633ff..92cdd47f 100644 --- a/internal/extension/lifecycle.go +++ b/internal/extension/lifecycle.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "time" lua "github.com/yuin/gopher-lua" @@ -191,12 +192,8 @@ func applyLifecycleLuaResult(result *LifecycleDispatchResult, value lua.LValue) func mergeProviderRequestMutation(base, override ProviderRequestMutation) ProviderRequestMutation { merged := ProviderRequestMutation{Headers: map[string]string{}} - for key, value := range base.Headers { - merged.Headers[key] = value - } - for key, value := range override.Headers { - merged.Headers[key] = value - } + maps.Copy(merged.Headers, base.Headers) + maps.Copy(merged.Headers, override.Headers) return merged } @@ -206,9 +203,7 @@ func mergeToolCallMutation(base, override ToolCallMutation) ToolCallMutation { return base } arguments := cloneMap(base.Arguments) - for key, value := range override.Arguments { - arguments[key] = value - } + maps.Copy(arguments, override.Arguments) return ToolCallMutation{Arguments: arguments} } diff --git a/internal/extension/sources.go b/internal/extension/sources.go index 91856e46..8e533451 100644 --- a/internal/extension/sources.go +++ b/internal/extension/sources.go @@ -108,7 +108,7 @@ func invalidGitHubSubdir(subdir string) bool { if subdir == "" { return false } - for _, part := range strings.Split(subdir, "/") { + for part := range strings.SplitSeq(subdir, "/") { if part == "" || part == "." || part == ".." { return true } diff --git a/internal/terminal/token_usage.go b/internal/terminal/token_usage.go index 524892b8..024fa412 100644 --- a/internal/terminal/token_usage.go +++ b/internal/terminal/token_usage.go @@ -2,6 +2,7 @@ package terminal import ( "fmt" + "maps" "github.com/omarluq/librecode/internal/model" ) @@ -66,9 +67,7 @@ func formatContextUsage(usage model.TokenUsage) string { func cloneTokenBreakdown(values map[string]int) map[string]int { cloned := make(map[string]int, len(values)) - for key, value := range values { - cloned[key] = value - } + maps.Copy(cloned, values) return cloned } diff --git a/internal/tool/bash.go b/internal/tool/bash.go index 4fe2b27e..032cff55 100644 --- a/internal/tool/bash.go +++ b/internal/tool/bash.go @@ -167,8 +167,7 @@ func formatBashWaitError(output []byte, waitErr error) (Result, error) { if err != nil { return emptyToolResult(), err } - var exitErr *exec.ExitError - if errors.As(waitErr, &exitErr) { + if exitErr, ok := errors.AsType[*exec.ExitError](waitErr); ok { status := fmt.Sprintf("Command exited with code %d", exitErr.ExitCode()) return emptyToolResult(), errors.New(appendStatus(outputText, status)) } diff --git a/internal/tool/edit_diff.go b/internal/tool/edit_diff.go index 9e706470..889b12bb 100644 --- a/internal/tool/edit_diff.go +++ b/internal/tool/edit_diff.go @@ -2,6 +2,7 @@ package tool import ( "fmt" + "slices" "strings" "unicode" @@ -246,8 +247,7 @@ func applyMatchedEdits(baseContent string, edits []matchedEdit) string { sortedEdits := append([]matchedEdit{}, edits...) sortMatchedEdits(sortedEdits) newContent := baseContent - for editIndex := len(sortedEdits) - 1; editIndex >= 0; editIndex-- { - edit := sortedEdits[editIndex] + for _, edit := range slices.Backward(sortedEdits) { newContent = newContent[:edit.matchIndex] + edit.newText + newContent[edit.matchIndex+edit.matchLength:] }