/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package main
import (
"context"
"fmt"
"log/slog"
"os"
"github.com/urfave/cli/v3"
"github.com/raoptimus/kodrun/internal/cliapp"
"github.com/raoptimus/kodrun/internal/cliapp/commands"
_ "github.com/raoptimus/kodrun/internal/llm/ollama"
_ "github.com/raoptimus/kodrun/internal/llm/openai"
)
// version is the kodrun release tag. Declared as a var (not const) so the
// release pipeline can override it at link time via
// `-ldflags "-X main.version=..."`.
var version = "v1.4.0-beta"
func main() {
defer func() {
if r := recover(); r != nil {
cliapp.RestoreTerminal()
slog.Error("kodrun panic", "recover", r)
os.Exit(1)
}
}()
f := &cliapp.Flags{}
app := &cli.Command{
Name: "kodrun",
Usage: "CLI agent for Go code",
Version: version,
Flags: cliapp.CLIFlags(f),
Action: func(ctx context.Context, cmd *cli.Command) error {
return cliapp.Run(ctx, cmd, f, version)
},
Commands: []*cli.Command{
commands.Build(f), commands.Test(f), commands.Lint(f),
commands.Fix(f), commands.Init(f),
},
}
if err := app.Run(context.Background(), os.Args); err != nil {
fmt.Fprintf(os.Stderr, "Error: %s\n", err)
cliapp.ExitWithCode(1)
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"fmt"
"maps"
"strings"
"time"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/projectlang"
"github.com/raoptimus/kodrun/internal/tools"
)
// ErrMaxIterations is returned when the agent exceeds max iterations.
var ErrMaxIterations = errors.New("max iterations reached")
// maxToolResultBytes limits tool output stored in history to prevent unbounded memory growth.
const maxToolResultBytes = 16 * 1024 // 16KB
const (
maxFingerprintLen = 80 // max chars for fingerprint/display truncation
ragTopK = 5 // number of top RAG results to include
truncatePreviewLen = 200 // max chars for tool result preview
diffPreviewMaxLines = 30 // max lines in edit/write diff preview
previewNewFileLines = 20 // max lines when previewing a new file
toolDetailTruncate = 60 // max chars for tool detail truncation
planPrefixScanLen = 5 // max chars to scan for list markers in plan detection
charsPerToken = 4 // approximate chars per token for ASCII text
autoCompactThreshold = 0.99 // context fill ratio that triggers auto-compact
inferenceProgressInterval = 2 * time.Second // throttle interval for inference progress events
nanosPerSec = 1e9 // nanoseconds in one second
pctMultiplier = 100 // multiplier to convert ratio to percentage
fileListMaxLen = 120 // max chars for file list in tool result
toolArgTruncateLen = 80 // max chars for tool argument display
actionDelete = "Delete"
headerPLAN = "PLAN"
headerPLANRu = "ПЛАН"
langEnglish = "English"
roleAssistant = "assistant"
)
// EventHandler receives agent events for display.
type EventHandler func(event *Event)
// Event represents an agent lifecycle event.
type Event struct {
Type EventType
// GroupID optionally associates the event with a named collapsible group
// in the TUI. When non-empty, EventAgent/EventTool events are routed
// into that group's log buffer instead of the main log. The group is
// created by an EventGroupStart and closed by an EventGroupEnd carrying
// the same GroupID. Events without GroupID retain the legacy single-
// active-group behaviour.
GroupID string
Message string
Tool string
Success bool
PromptTokens int
EvalTokens int
EvalTkPerSec float64 // output tokens per second
ContextUsed int // last prompt token count (how full the context is)
ContextTotal int // max context size
SystemPromptTokens int // estimated system prompt token count
FileAction string // Add/Update/Delete/Rename
LinesAdded int
LinesRemoved int
Diff string
Stats *SessionStats // set on EventDone
// CacheHits / CacheMisses are populated on EventCacheStats events.
CacheHits int64
CacheMisses int64
// Progress is set on EventRAGProgress events. Done == Total signals
// completion; the TUI hides the indicator on the next event.
ProgressDone int
ProgressTotal int
ProgressLabel string
// CacheHit is true when the tool result was served from cache.
CacheHit bool
// FullOutput carries the complete tool result for the transcript view.
// Only populated on EventTool events; empty for other event types.
FullOutput string
// InferenceTokens is the running count of eval tokens during inference.
// Set on EventInferenceProgress events.
InferenceTokens int
// InferenceElapsed is the time since inference started.
// Set on EventInferenceProgress events.
InferenceElapsed time.Duration
// InferenceContent carries accumulated LLM output since the last progress event.
// Set on EventInferenceProgress events for the transcript view.
InferenceContent string
}
// SessionStats accumulates statistics for the current task execution.
type SessionStats struct {
FilesAdded int
FilesModified int
FilesDeleted int
FilesRenamed int
LinesAdded int
LinesRemoved int
ToolCalls int
TotalPrompt int
TotalEval int
PeakContextPct int // peak context usage percentage
AvgTkPerSec float64
ChangedFiles []string // paths of files added/modified/deleted/renamed
tkPerSecSum float64
tkPerSecCount int
// Performance breakdown (populated per iteration).
Iterations int
TotalLLMTime time.Duration
TotalToolTime time.Duration
}
func (s *SessionStats) reset() {
*s = SessionStats{}
}
func (s *SessionStats) recordFileAction(action, path string, added, removed int) {
switch action {
case "Add":
s.FilesAdded++
case "Update":
s.FilesModified++
case actionDelete:
s.FilesDeleted++
case "Rename":
s.FilesRenamed++
}
s.LinesAdded += added
s.LinesRemoved += removed
if path != "" {
s.ChangedFiles = append(s.ChangedFiles, path)
}
}
func (s *SessionStats) recordTokens(prompt, eval int, tkPerSec float64, contextPct int) {
s.TotalPrompt += prompt
s.TotalEval += eval
if tkPerSec > 0 {
s.tkPerSecSum += tkPerSec
s.tkPerSecCount++
}
if contextPct > s.PeakContextPct {
s.PeakContextPct = contextPct
}
}
func (s *SessionStats) avgTkPerSec() float64 {
if s.tkPerSecCount == 0 {
return 0
}
return s.tkPerSecSum / float64(s.tkPerSecCount)
}
// EventType categorizes events.
type EventType int
const (
EventAgent EventType = iota
EventTool
EventFix
EventError
EventDone
EventTokens
EventCompact
EventGroupStart
EventGroupEnd
EventGroupTitleUpdate
EventModeChange
// EventPhase signals an orchestrator phase transition (planning, executing,
// reviewing). Message holds the phase name.
EventPhase
// EventCacheStats reports the latest tool-cache hit/miss counters and hit
// rate. The TUI uses these to render a compact footer.
EventCacheStats
// EventReplan signals that the executor requested a replan because the
// approved plan lacked necessary context.
EventReplan
// EventRAGProgress reports background RAG indexing progress. Fields:
// ProgressDone, ProgressTotal, ProgressLabel. When Done == Total the TUI
// stops displaying the indicator.
EventRAGProgress
// EventInferenceProgress reports live token generation during LLM inference.
// Fields: InferenceTokens, InferenceElapsed. Emitted periodically (throttled)
// while the model is generating tokens so the TUI can show progress.
EventInferenceProgress
// EventModelChange signals that the active model was changed at runtime.
// Message holds the new model name. The TUI updates the toolbar display.
EventModelChange
)
// Agent orchestrates the LLM-tool loop.
type Agent struct {
client llm.Client
model string
reg *tools.Registry
history []llm.Message
maxIter int
workDir string
events eventEmitter
confirmFn ConfirmFunc
permMgr *PermissionManager
ctxMgr *ContextManager
contextSize int
ruleCatalog string
projectContext string
contextInjected bool
mode Mode
think bool
language string
autoCompact bool
lastPromptEvalCount int
lastPlan string
planBuf strings.Builder
stats SessionStats
hasSnippets bool
hasRAG bool
taskLabel string
ragIndex tools.RAGSearcher
godocIndexer tools.GoDocIndexer
langState *projectlang.State
pool *WorkerPool
extraConfirmTools map[string]bool
extraReadOnlyTools map[string]bool
disabledTools map[string]bool
toolsDisabled bool
session sessionStore
// Generation overrides used by specialised roles (extractor uses
// temperature=0 + format="json" to coerce structured output from the same
// underlying model).
temperature float64
hasTemperature bool
format string
// allowedReadPaths, when non-nil, restricts which paths read-only file
// tools may access. The executor uses this to prevent the model from
// drifting into open-ended exploration after a plan has been approved.
// Empty/nil means no restriction.
allowedReadPaths map[string]struct{}
// editNudges counts how many times the current Send() invocation has
// pushed a "stop describing, call tools" follow-up message into history
// because the model returned a markdown plan instead of tool calls in
// EDIT mode. Reset at the start of every Send. See nudgeOrTerminate.
editNudges int
// planToolNudges counts how many times the current Send() invocation has
// pushed a "you must call tools first" nudge in PLAN mode because the
// model returned NO_ISSUES without calling any tools. Reset in Send().
planToolNudges int
// toolCallCount counts the total number of tool calls executed during
// the current Send() invocation. Reset at the start of every Send.
toolCallCount int
// planBlockedStreak counts consecutive iterations where ALL tool calls
// were blocked (write tools in plan mode). When the streak reaches
// maxPlanBlocked the agent stops early to avoid wasting iterations.
planBlockedStreak int
// hasCalledReadFile tracks whether read_file was called during the
// current Send() invocation. Used by the guided-flow nudge to detect
// when a model did file_stat but skipped read_file.
hasCalledReadFile bool
// hasCalledWriteTool tracks whether any write tool (edit_file,
// write_file, bash, etc.) was called during the current Send()
// invocation. Used by the EDIT-mode nudge to detect when the model
// read files but never applied changes.
hasCalledWriteTool bool
// readFileNudges counts how many times the current Send() pushed a
// "now call read_file" nudge after detecting file_stat without read_file.
readFileNudges int
// readPathsSession tracks paths the model has already read via read_file
// in the current Send() invocation. A repeat read on the same path is
// intercepted by guardDuplicateRead and answered with a short synthetic
// message pointing back to the prior tool result, saving tokens. Cleared
// per-path when a write tool affects that path so a re-read after
// modification still works. Reset at the start of every Send.
readPathsSession map[string]struct{}
// verbose enables per-iteration timing diagnostics.
verbose bool
// exec runs tool calls. Currently a thin scaffold; stages 4b–4e migrate
// helpers, guards and the run-loop step into it.
exec *toolExecutor
}
// New creates a new Agent.
func New(client llm.Client, model string, reg *tools.Registry, maxIter int, workDir string, contextSize int) *Agent {
permMgr := NewPermissionManager()
a := &Agent{
client: client,
model: model,
reg: reg,
maxIter: maxIter,
workDir: workDir,
contextSize: contextSize,
permMgr: permMgr,
exec: newToolExecutor(reg, permMgr, workDir),
}
a.exec.attach(a)
return a
}
// SetEventHandler sets the handler for agent events.
func (a *Agent) SetEventHandler(h EventHandler) {
a.events.SetHandler(h)
}
// readOnlyTools returns the set of registered read-only tool names. The
// registry derives this from each tool's IsReadOnly() declaration.
func (a *Agent) readOnlyTools() map[string]bool {
return a.exec.readOnlyTools()
}
// SetConfirmFunc sets the confirmation callback for destructive operations.
// If nil, all operations are allowed without confirmation.
func (a *Agent) SetConfirmFunc(fn ConfirmFunc) {
a.confirmFn = fn
}
// GetConfirmFunc returns the current confirmation callback.
func (a *Agent) GetConfirmFunc() ConfirmFunc {
return a.confirmFn
}
// ClearSessionPermissions resets all session-level tool permissions.
func (a *Agent) ClearSessionPermissions() {
a.permMgr.Reset()
}
// SetLanguage sets the language for assistant responses.
func (a *Agent) SetLanguage(lang string) {
a.language = lang
}
// SetAutoCompact enables or disables automatic context compaction.
func (a *Agent) SetAutoCompact(enabled bool) {
a.autoCompact = enabled
}
// SetTaskLabel sets a human-readable label that Send() emits instead of the
// generic "Processing task..." message, so the TUI can show which sub-agent
// is currently working (e.g. "Reviewing: security"). Empty string disables
// the start-of-task status line entirely (useful for sub-agents that run
// inside a group and whose activity is expressed by tool events).
func (a *Agent) SetTaskLabel(label string) {
a.taskLabel = label
}
// SetGroupID associates every event this agent emits with the given TUI
// group. Sub-agents running concurrently inside their own collapsible
// group (e.g. parallel specialist reviewers) set this so their tool calls
// and status messages do not pollute the main log or another agent's group.
func (a *Agent) SetGroupID(id string) {
a.events.SetGroupID(id)
}
// SetVerbose enables per-iteration timing diagnostics.
func (a *Agent) SetVerbose(v bool) { a.verbose = v }
// SetHasSnippets marks that the snippets tool is available.
func (a *Agent) SetHasSnippets(v bool) {
a.hasSnippets = v
}
// SetHasRAG marks that the RAG search_docs tool is available.
func (a *Agent) SetHasRAG(v bool) {
a.hasRAG = v
}
// SetRAGIndex sets the RAG index for automatic context prefetch.
func (a *Agent) SetRAGIndex(idx tools.RAGSearcher) {
a.ragIndex = idx
}
// SetGodocIndexer sets the Go documentation RAG indexer used by go_doc
// for lazy language tool re-registration.
func (a *Agent) SetGodocIndexer(idx tools.GoDocIndexer) {
a.godocIndexer = idx
}
// SetLanguageState wires a project-language state for lazy re-detection.
// When set, Send/Run will re-detect on each call and lazily register
// language-specific tools if the language transitions from unknown.
func (a *Agent) SetLanguageState(s *projectlang.State) {
a.langState = s
}
// LanguageState returns the configured language state, or nil.
func (a *Agent) LanguageState() *projectlang.State {
return a.langState
}
// currentProgLang returns the detected project programming language as a string,
// or "" if unknown.
func (a *Agent) currentProgLang() string {
if a.langState == nil {
return ""
}
return string(a.langState.Current())
}
// ToolRegistry exposes the tool registry for callers that need to register
// additional tools after construction (e.g. lazy language detection).
func (a *Agent) ToolRegistry() *tools.Registry {
return a.reg
}
// WorkDir returns the agent's working directory.
func (a *Agent) WorkDir() string {
return a.workDir
}
// SetMaxToolWorkers configures the worker pool for parallel tool execution.
func (a *Agent) SetMaxToolWorkers(n int) {
a.pool = NewWorkerPool(n)
}
// SetTemperature pins the sampling temperature used in chat requests. This is
// primarily used by the extractor role to force deterministic JSON output.
func (a *Agent) SetTemperature(t float64) {
a.temperature = t
a.hasTemperature = true
}
// SetFormat sets the response format hint for the LLM (e.g. "json"). When set,
// it is forwarded to ollama via the chat request Format field.
func (a *Agent) SetFormat(f string) {
a.format = f
}
// SetAllowedReadPaths constrains which paths the agent's read-only file tools
// may access. Pass nil to remove the restriction. The path strings are
// normalised relative to the agent's work directory.
//
// This is used by the executor role after plan approval to enforce the
// "executor does not re-explore the codebase" contract.
func (a *Agent) SetAllowedReadPaths(paths []string) {
if len(paths) == 0 {
a.allowedReadPaths = nil
return
}
m := make(map[string]struct{}, len(paths))
for _, p := range paths {
p = strings.TrimSpace(p)
if p == "" {
continue
}
m[p] = struct{}{}
}
a.allowedReadPaths = m
}
// AddConfirmTools adds tool names that require user confirmation (e.g. MCP tools).
func (a *Agent) AddConfirmTools(names map[string]bool) {
if a.extraConfirmTools == nil {
a.extraConfirmTools = make(map[string]bool)
}
maps.Copy(a.extraConfirmTools, names)
}
// AddReadOnlyTools adds tool names that are safe for parallel execution (e.g. MCP read-only tools).
func (a *Agent) AddReadOnlyTools(names map[string]bool) {
if a.extraReadOnlyTools == nil {
a.extraReadOnlyTools = make(map[string]bool)
}
maps.Copy(a.extraReadOnlyTools, names)
}
// DisableTools marks tool names that must never be offered to the model or
// executed, regardless of mode. Used to honour config flags like auto_commit.
func (a *Agent) DisableTools(names ...string) {
if a.disabledTools == nil {
a.disabledTools = make(map[string]bool, len(names))
}
for _, n := range names {
a.disabledTools[n] = true
}
}
// SetModel changes the model used for subsequent LLM calls.
func (a *Agent) SetModel(model string) {
a.model = model
}
// Model returns the current model name.
func (a *Agent) Model() string {
return a.model
}
// SetMode sets the agent operating mode.
func (a *Agent) SetMode(mode Mode) {
a.mode = mode
}
// Mode returns the current operating mode.
func (a *Agent) Mode() Mode {
return a.mode
}
// SetThink enables or disables thinking mode.
func (a *Agent) SetThink(think bool) {
a.think = think
}
// Think returns whether thinking is enabled.
func (a *Agent) Think() bool {
return a.think
}
// SavePlan stores a plan for later execution in edit mode.
func (a *Agent) SavePlan(plan string) {
a.lastPlan = plan
}
// LastPlan returns the last saved plan.
func (a *Agent) LastPlan() string {
return a.lastPlan
}
// ToolCallCount returns the number of tool calls executed during the last Send.
func (a *Agent) ToolCallCount() int {
return a.toolCallCount
}
// LastAssistantMessage returns the content of the most recent assistant
// message in the agent's history, or "" if there is none. Used by the
// structurer/extractor pipeline to fetch raw model output without going
// through the lastPlan extraction.
func (a *Agent) LastAssistantMessage() string {
for i := len(a.history) - 1; i >= 0; i-- {
if a.history[i].Role == roleAssistant {
return a.history[i].Content
}
}
return ""
}
// EnterEditWithPlan switches to edit mode, clears context,
// and injects the approved plan as the first user message.
func (a *Agent) EnterEditWithPlan() {
a.mode = ModeEdit
a.think = false
systemPrompt := a.buildSystemPrompt()
a.history = []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: fmt.Sprintf("Execute the following approved plan:\n\n%s\n\nImplement each step. Confirm plan steps as you go. Always respond in %s.", a.lastPlan, langName(a.language))},
}
a.contextInjected = false
}
// SetSessionDir enables session auto-save. Sessions are stored in dir.
func (a *Agent) SetSessionDir(dir string) {
a.session.SetDir(dir)
}
// SessionID returns the current session ID.
func (a *Agent) SessionID() string {
return a.session.ID()
}
// LoadFromSession restores agent state from a saved session.
func (a *Agent) LoadFromSession(s *Session) {
a.history = make([]llm.Message, len(s.Messages))
copy(a.history, s.Messages)
switch s.Mode {
case string(ClassifyKindPlan):
a.mode = ModePlan
case modeChatStr:
a.mode = ModeChat
default:
a.mode = ModeEdit
}
a.lastPlan = s.Plan
a.stats = s.Stats
a.session.SetID(s.ID)
a.contextInjected = true // context was already in saved history
}
// autoSave persists the current agent state to disk if sessionDir is set.
func (a *Agent) autoSave() {
if err := a.session.save(&Session{
Model: a.model,
Mode: a.mode.String(),
Messages: a.History(),
Plan: a.lastPlan,
Stats: a.stats,
WorkDir: a.workDir,
}); err != nil {
a.emit(&Event{Type: EventError, Message: "session auto-save failed: " + err.Error()})
}
}
func (a *Agent) emit(e *Event) {
a.events.emit(e)
}
// Init initializes the agent with system prompt. Call once before Send().
func (a *Agent) Init(ruleCatalog string) {
a.ruleCatalog = ruleCatalog
systemPrompt := a.buildSystemPrompt()
a.projectContext = a.buildProjectContext()
a.contextInjected = false
a.history = []llm.Message{
{Role: "system", Content: systemPrompt},
}
a.ctxMgr = NewContextManager(a.contextSize, a.client, a.model)
a.ctxMgr.SetLanguage(a.language)
a.emit(&Event{
Type: EventAgent,
SystemPromptTokens: len(systemPrompt) / charsPerToken,
})
}
// InitWithPrompt initializes the agent with a custom system prompt.
// Used by the Orchestrator to set role-specific prompts.
func (a *Agent) InitWithPrompt(systemPrompt string) {
a.projectContext = a.buildProjectContext()
a.contextInjected = false
a.history = []llm.Message{
{Role: "system", Content: systemPrompt},
}
a.ctxMgr = NewContextManager(a.contextSize, a.client, a.model)
a.ctxMgr.SetLanguage(a.language)
}
// ClearHistory resets conversation history, keeping the system prompt.
func (a *Agent) ClearHistory() {
systemPrompt := a.buildSystemPrompt()
a.history = []llm.Message{
{Role: "system", Content: systemPrompt},
}
a.contextInjected = false
a.lastPromptEvalCount = 0
}
// Compact summarizes the conversation history to free context space.
// Instructions are optional hints for the summarizer (e.g. "focus on file changes").
func (a *Agent) Compact(ctx context.Context, instructions string) error {
if a.ctxMgr == nil {
return errors.New("context manager not initialized")
}
before := a.ctxMgr.estimateTokens(a.history)
trimmed, err := a.ctxMgr.ForceTrim(ctx, a.history, instructions)
if err != nil {
return errors.WithMessage(err, "compact")
}
a.history = trimmed
after := a.ctxMgr.estimateTokens(a.history)
a.emit(&Event{
Type: EventCompact,
Message: fmt.Sprintf("Context compacted: ~%d → ~%d tokens (freed ~%d)", before, after, before-after),
ContextUsed: after,
ContextTotal: a.contextSize,
})
return nil
}
// History returns a copy of the current conversation history.
func (a *Agent) History() []llm.Message {
h := make([]llm.Message, len(a.history))
copy(h, a.history)
return h
}
// Stats returns the current session statistics.
func (a *Agent) Stats() SessionStats {
return a.stats
}
// ContextUsage returns estimated token usage and max context size.
func (a *Agent) ContextUsage() (used, total int) {
if a.ctxMgr != nil {
used = a.ctxMgr.estimateTokens(a.history)
}
return used, a.contextSize
}
// ensureLanguageDetected re-runs project language detection if it is still
// unknown and lazily registers the matching language-specific tools when a
// language is discovered for the first time.
func (a *Agent) ensureLanguageDetected() {
if a.langState == nil {
return
}
lang, changed := a.langState.EnsureDetected()
if !changed || lang == projectlang.LangUnknown {
return
}
tools.RegisterLanguageTools(a.reg, lang, a.workDir, a.godocIndexer)
a.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("Project language detected: %s — language tools registered", lang)})
}
// prepareUserContent enriches the raw task text with RAG search results and
// (on the first Send) the cached project context block, then appends the
// resulting message to history.
func (a *Agent) prepareUserContent(ctx context.Context, task string) {
userContent := task
if a.hasRAG && a.ragIndex != nil {
if results, err := a.ragIndex.Search(ctx, task, ragTopK); err == nil && len(results) > 0 {
userContent = formatRAGResults(results) + "\n" + userContent
}
}
if a.projectContext != "" && !a.contextInjected {
userContent = "[Project context]\n" + a.projectContext + "\n\n[Task]\n" + userContent
a.contextInjected = true
}
a.history = append(a.history, llm.Message{Role: "user", Content: userContent})
}
// resetSendState clears per-Send counters and scratch maps before the chat
// loop starts a fresh task. Stats are reset so tokens/iterations from the
// previous Send don't bleed into this one.
func (a *Agent) resetSendState() {
a.stats.reset()
a.planBuf.Reset()
a.editNudges = 0
a.planToolNudges = 0
a.toolCallCount = 0
a.planBlockedStreak = 0
a.hasCalledReadFile = false
a.hasCalledWriteTool = false
a.readFileNudges = 0
a.readPathsSession = make(map[string]struct{})
}
// maintainContext applies auto-compact (when prompt usage crosses the
// threshold) and the safety-net trim before the next LLM call. Errors from
// either path are non-fatal — the loop just proceeds with the un-shrunk
// history.
func (a *Agent) maintainContext(ctx context.Context) {
if a.autoCompact && a.ctxMgr != nil && a.lastPromptEvalCount > 0 && a.contextSize > 0 {
ratio := float64(a.lastPromptEvalCount) / float64(a.contextSize)
if ratio >= autoCompactThreshold {
if err := a.Compact(ctx, ""); err == nil {
a.lastPromptEvalCount = 0
}
}
}
if a.ctxMgr != nil {
before := a.ctxMgr.estimateTokens(a.history)
trimmed, err := a.ctxMgr.Trim(ctx, a.history)
if err == nil && a.ctxMgr.estimateTokens(trimmed) < before {
a.history = trimmed
after := a.ctxMgr.estimateTokens(a.history)
a.emit(&Event{
Type: EventCompact,
Message: fmt.Sprintf("Auto-trimmed: ~%d → ~%d tokens", before, after),
ContextUsed: after,
ContextTotal: a.contextSize,
})
} else if err == nil {
a.history = trimmed
}
}
}
// handleEmptyResponse reacts to an LLM turn with no tool calls. Returns true
// when Send should continue the chat loop (synthetic tool-call recovery, an
// edit-mode nudge, or a plan-mode nudge); false means Send should emit Done
// and finish the task. This is the heart of the "model went text-only when
// it should have called a tool" recovery path.
func (a *Agent) handleEmptyResponse(ctx context.Context, resp *llm.ChatChunk) bool {
if a.mode == ModeEdit {
if synth := parseTextToolCall(resp.Content); synth != nil {
a.emit(&Event{Type: EventAgent, Message: "Recovered text-form tool call — executing synthetically."})
a.history[len(a.history)-1] = llm.Message{
Role: roleAssistant,
Content: "",
ToolCalls: []llm.ToolCall{*synth},
}
a.toolCallCount++
a.exec.executeSingle(ctx, *synth)
return true
}
}
shouldNudge := looksLikeMarkdownPlan(resp.Content) ||
(a.toolCallCount > 0 && !a.hasCalledWriteTool)
if a.mode == ModeEdit && a.editNudges < maxEditNudges && shouldNudge {
a.editNudges++
a.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("Model responded with text instead of tool calls; nudging (%d/%d)...", a.editNudges, maxEditNudges)})
a.history = append(a.history, llm.Message{
Role: "user",
Content: editNudgeMessage(a.editNudges, a.hasCalledReadFile),
})
return true
}
if a.mode == ModePlan {
content := strings.TrimSpace(resp.Content)
if !a.toolsDisabled && a.toolCallCount == 0 && a.planToolNudges < maxPlanToolNudges &&
(content == "" || isNoIssues(content)) {
a.planToolNudges++
a.emit(&Event{Type: EventAgent, Message: fmt.Sprintf(
"Model responded without calling any tools; nudging (%d/%d)...",
a.planToolNudges, maxPlanToolNudges)})
a.history = append(a.history, llm.Message{
Role: "user",
Content: "You responded without calling any tools. This is FORBIDDEN. Re-read the task above: it contains a file list section with bullet paths. You MUST call file_stat on EACH file from that list. Do NOT guess file paths — use ONLY the paths listed in the task. Start now.",
})
return true
}
if a.toolCallCount > 0 && !a.hasCalledReadFile && a.readFileNudges < maxReadFileNudges {
a.readFileNudges++
a.emit(&Event{Type: EventAgent, Message: fmt.Sprintf(
"Model called file_stat but not read_file; nudging (%d/%d)...",
a.readFileNudges, maxReadFileNudges)})
a.history = append(a.history, llm.Message{
Role: "user",
Content: "Good, you called file_stat. Now you MUST call read_file(path) on each file to read its contents. Use the EXACT paths from the file list in the task — do NOT guess or modify paths. You cannot review code without reading it. Call read_file now.",
})
return true
}
if resp.Content != "" {
a.planBuf.WriteString(resp.Content)
}
raw := a.planBuf.String()
a.lastPlan = extractPlan(raw)
if a.lastPlan == "" && len(raw) > 0 {
a.lastPlan = strings.TrimSpace(raw)
}
return false
}
if resp.Content != "" {
a.emit(&Event{Type: EventAgent, Message: resp.Content})
if a.editNudges >= maxEditNudges {
a.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("Model returned text-only response in EDIT mode after %d nudges — aborting without changes. Consider using a stronger model or lowering executor temperature (see executor_provider: precise).", maxEditNudges)})
}
}
return false
}
// Send processes a single user message, preserving conversation history.
func (a *Agent) Send(ctx context.Context, task string) error {
a.ensureLanguageDetected()
a.prepareUserContent(ctx, task)
a.resetSendState()
// Emit a start-of-task status line only when a label was set. Sub-agents
// that live inside a collapsible group (parallel specialist reviewers)
// set an empty label on purpose so their activity in the UI is limited
// to actual tool calls, keeping the group tail free for read_file/etc.
if a.taskLabel != "" {
a.emit(&Event{Type: EventAgent, Message: a.taskLabel})
}
var iterCount int
for range a.maxIter {
if ctx.Err() != nil {
return ctx.Err()
}
iterCount++
iterStart := time.Now()
if a.verbose {
a.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("Iteration %d/%d: requesting model...", iterCount, a.maxIter)})
}
a.maintainContext(ctx)
opts := map[string]any{
"num_ctx": a.contextSize,
"think": a.think,
}
if a.hasTemperature {
opts["temperature"] = a.temperature
}
var lastProgressEmit time.Time
var contentBuf strings.Builder
progressCb := func(tokensSoFar int, content string) {
contentBuf.WriteString(content)
if time.Since(lastProgressEmit) >= inferenceProgressInterval {
a.emit(&Event{
Type: EventInferenceProgress,
InferenceTokens: tokensSoFar,
InferenceElapsed: time.Since(iterStart),
InferenceContent: contentBuf.String(),
})
contentBuf.Reset()
lastProgressEmit = time.Now()
}
}
resp, err := a.client.ChatSyncWithCallback(ctx, &llm.ChatRequest{
Model: a.model,
Messages: a.history,
Tools: a.exec.toolDefsForMode(),
Options: opts,
Format: a.format,
}, progressCb)
llmDuration := time.Since(iterStart)
// Flush remaining streaming content that didn't hit the throttle interval.
if contentBuf.Len() > 0 {
a.emit(&Event{
Type: EventInferenceProgress,
InferenceTokens: resp.EvalCount,
InferenceElapsed: llmDuration,
InferenceContent: contentBuf.String(),
})
contentBuf.Reset()
}
if err != nil {
a.emit(&Event{Type: EventError, Message: err.Error()})
return errors.WithMessage(err, "chat")
}
// Track real prompt token count for auto-compact and calibrate estimator
if resp.PromptEvalCount > 0 {
a.lastPromptEvalCount = resp.PromptEvalCount
if a.ctxMgr != nil {
a.ctxMgr.Calibrate(a.history, resp.PromptEvalCount)
}
}
// Emit token usage
if resp.PromptEvalCount > 0 || resp.EvalCount > 0 {
var tkPerSec float64
if resp.EvalDuration > 0 {
tkPerSec = float64(resp.EvalCount) / float64(resp.EvalDuration) * nanosPerSec
}
a.emit(&Event{
Type: EventTokens,
PromptTokens: resp.PromptEvalCount,
EvalTokens: resp.EvalCount,
EvalTkPerSec: tkPerSec,
ContextUsed: resp.PromptEvalCount,
ContextTotal: a.contextSize,
})
var ctxPct int
if a.contextSize > 0 {
ctxPct = resp.PromptEvalCount * pctMultiplier / a.contextSize
}
a.stats.recordTokens(resp.PromptEvalCount, resp.EvalCount, tkPerSec, ctxPct)
}
// Add assistant response to history
msg := llm.Message{
Role: roleAssistant,
Content: resp.Content,
ToolCalls: resp.ToolCalls,
}
a.history = append(a.history, msg)
if len(resp.ToolCalls) == 0 {
if a.handleEmptyResponse(ctx, &resp) {
continue
}
a.stats.AvgTkPerSec = a.stats.avgTkPerSec()
a.emit(&Event{Type: EventDone, Message: "Done", Stats: &a.stats})
a.autoSave()
return nil
}
toolStep := a.exec.Run(ctx, resp.ToolCalls)
// Track consecutive iterations where every tool call was blocked.
// When parallel read-only calls succeeded, the iteration is useful
// regardless of sequential blocked calls.
if toolStep.AllBlocked && !toolStep.HadParallel {
a.planBlockedStreak++
if a.mode == ModePlan && a.planBlockedStreak >= maxPlanBlocked {
a.emit(&Event{Type: EventAgent, Message: fmt.Sprintf(
"Model called only blocked tools for %d iterations — stopping analysis early.", a.planBlockedStreak)})
raw := a.planBuf.String()
a.lastPlan = extractPlan(raw)
if a.lastPlan == "" && len(raw) > 0 {
a.lastPlan = strings.TrimSpace(raw)
}
a.stats.AvgTkPerSec = a.stats.avgTkPerSec()
a.emit(&Event{Type: EventDone, Message: "Done (blocked streak)", Stats: &a.stats})
a.autoSave()
return nil
}
} else {
a.planBlockedStreak = 0
}
toolDuration := time.Since(iterStart) - llmDuration
if a.verbose {
var ctxPct int
if a.contextSize > 0 && resp.PromptEvalCount > 0 {
ctxPct = resp.PromptEvalCount * pctMultiplier / a.contextSize
}
a.emit(&Event{
Type: EventAgent,
Message: fmt.Sprintf("[perf] iter=%d llm=%s tools=%s total=%s ctx=%d%% tools_called=%d",
iterCount, llmDuration.Truncate(time.Millisecond), toolDuration.Truncate(time.Millisecond),
time.Since(iterStart).Truncate(time.Millisecond), ctxPct, len(resp.ToolCalls)),
})
}
a.stats.Iterations++
a.stats.TotalLLMTime += llmDuration
a.stats.TotalToolTime += toolDuration
if toolStep.Augmented {
continue // re-send to LLM with constraint
}
// Capture intermediate text. In plan mode, accumulate into planBuf
// so findings emitted alongside tool calls are not lost.
if resp.Content != "" && a.mode == ModePlan {
a.planBuf.WriteString(resp.Content)
} else if resp.Content != "" {
a.emit(&Event{Type: EventAgent, Message: resp.Content})
}
}
// Extract plan from accumulated text on max iterations.
if a.mode == ModePlan {
if a.planBuf.Len() > 0 {
a.lastPlan = extractPlan(a.planBuf.String())
}
if a.lastPlan == "" && a.planBuf.Len() > 0 {
// Max iterations exhausted with content but no structured plan.
// Use the raw text as-is — better than nothing.
a.lastPlan = strings.TrimSpace(a.planBuf.String())
}
}
a.stats.AvgTkPerSec = a.stats.avgTkPerSec()
a.emit(&Event{Type: EventDone, Message: "Max iterations reached", Stats: &a.stats})
a.autoSave()
return errors.WithStack(ErrMaxIterations)
}
// Run executes the agent loop for a given task (one-shot, no history preservation).
func (a *Agent) Run(ctx context.Context, task, ruleCatalog string) error {
a.Init(ruleCatalog)
return a.Send(ctx, task)
}
// SetToolsDisabled forces the agent to advertise no tools to the model and
// blocks any tool call attempt. Used by the extractor role: a deterministic
// JSON-only pass that must not be tempted into hallucinating tool calls.
func (a *Agent) SetToolsDisabled(disabled bool) {
a.toolsDisabled = disabled
}
func fallbackDiffPreview(oldStr, newStr string, maxLines int) string {
var b strings.Builder
count := 0
for _, line := range strings.Split(oldStr, "\n") {
if count >= maxLines {
break
}
b.WriteString("-" + line + "\n")
count++
}
for _, line := range strings.Split(newStr, "\n") {
if count >= maxLines {
break
}
b.WriteString("+" + line + "\n")
count++
}
return b.String()
}
func truncateOneLine(s string, n int) string {
s = strings.ReplaceAll(s, "\n", "⏎")
if len(s) > n {
return s[:n] + "..."
}
return s
}
func previewNewFile(content string, maxLines int) string {
lines := strings.Split(content, "\n")
if len(lines) <= maxLines {
return content
}
return strings.Join(lines[:maxLines], "\n") + fmt.Sprintf("\n... (%d more lines)", len(lines)-maxLines)
}
func toolDetail(name string, args map[string]any) string {
switch name {
case toolNameReadFile, toolNameWriteFile, toolNameEditFile, toolNameDeleteFile, toolNameListDir, toolNameFindFiles, toolNameCreateDir:
if p, ok := args["path"].(string); ok {
return p
}
case toolNameMoveFile:
from := stringFromMap(args, "from")
to := stringFromMap(args, "to")
return from + " → " + to
case toolNameGrep:
pattern := stringFromMap(args, "pattern")
path := stringFromMap(args, "path")
return fmt.Sprintf("%q in %s", pattern, path)
case toolNameGoBuild, toolNameGoTest, toolNameGoLint, toolNameGoVet:
if p, ok := args["packages"].(string); ok && p != "" {
return p
}
return "./..."
case toolNameGoDoc:
if p, ok := args["packages"].(string); ok && p != "" {
return p
}
if q, ok := args["query"].(string); ok && q != "" {
if len(q) > toolArgTruncateLen {
q = q[:toolArgTruncateLen] + "..."
}
return q
}
case toolNameSearchDocs:
if q, ok := args["query"].(string); ok && q != "" {
if len(q) > toolArgTruncateLen {
q = q[:toolArgTruncateLen] + "..."
}
return q
}
case toolNameBash:
if cmd, ok := args["command"].(string); ok {
if len(cmd) > toolArgTruncateLen {
cmd = cmd[:toolArgTruncateLen] + "..."
}
return cmd
}
case toolNameSnippets:
if action, ok := args["action"].(string); ok && action != "" {
return action
}
return "match"
case toolNameGitStatus:
return ""
case toolNameGitDiff, toolNameGitLog:
if p, ok := args["path"].(string); ok && p != "" {
return p
}
return ""
case toolNameGitCommit:
if msg, ok := args["message"].(string); ok && msg != "" {
return truncateOneLine(msg, toolDetailTruncate)
}
return ""
case toolNameWebFetch:
if u, ok := args["url"].(string); ok {
return u
}
case toolNameGetRule:
if n, ok := args["name"].(string); ok && n != "" {
return n
}
}
// Generic fallback: try common arg keys before giving up.
for _, key := range []string{"path", "file", "name", "query", "url", "input", "command", "pattern"} {
if v, ok := args[key].(string); ok && v != "" {
if len(v) > maxFingerprintLen {
v = v[:maxFingerprintLen] + "..."
}
return v
}
}
return ""
}
func truncate(s string, maxRunes int) string {
runes := []rune(s)
if len(runes) <= maxRunes {
return s
}
return string(runes[:maxRunes]) + "..."
}
// extractPlan extracts the plan section from accumulated planner text.
// Takes the LAST occurrence of a context marker (## Context / CONTEXT).
// If no context marker found, falls back to the last plan marker (## Plan / PLAN).
// Trims trailing garbage (non-plan text after the numbered list).
func extractPlan(text string) string {
// Priority 1: find last context marker (includes both context + plan sections).
contextMarkers := []string{"## Context", "## Контекст", "\nCONTEXT", "\nКОНТЕКСТ"}
for _, marker := range contextMarkers {
if idx := strings.LastIndex(text, marker); idx >= 0 {
return trimPlanGarbage(strings.TrimSpace(text[idx:]))
}
}
// Priority 2: find last plan marker (prefix match — "ПЛАН УЛУЧШЕНИЙ" matches "\nПЛАН").
planMarkers := []string{
"## Plan", "## План",
"\nPLAN", "\nПЛАН",
"\nКОНКРЕТНЫЕ ИЗМЕНЕНИЯ", "\nКОНКРЕТНЫЕ ПРАВКИ",
}
for _, marker := range planMarkers {
if idx := strings.LastIndex(text, marker); idx >= 0 {
return trimPlanGarbage(strings.TrimSpace(text[idx:]))
}
}
// No markers found. Only return text if it contains a numbered list (actual plan).
// Otherwise, it's just conversational garbage — return empty.
if hasNumberedList(text) {
return trimPlanGarbage(strings.TrimSpace(text))
}
return ""
}
// hasNumberedList checks if the text contains at least one numbered list item (e.g. "1. ..." or "1) ...").
func hasNumberedList(text string) bool {
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
if len(trimmed) > 2 && trimmed[0] >= '1' && trimmed[0] <= '9' &&
(trimmed[1] == '.' || trimmed[1] == ')') {
return true
}
}
return false
}
// trimPlanGarbage removes trailing non-plan text after the numbered list.
// Keeps the header + numbered items; stops at the first line that is neither
// empty, a header (##/PLAN/CONTEXT), nor a numbered list item / continuation.
func trimPlanGarbage(text string) string {
lines := strings.Split(text, "\n")
lastPlanLine := 0
inPlan := false
for i, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
}
// Headers
if strings.HasPrefix(trimmed, "##") ||
trimmed == headerPLAN || trimmed == headerPLANRu ||
trimmed == "CONTEXT" || trimmed == "КОНТЕКСТ" {
lastPlanLine = i
inPlan = strings.HasPrefix(trimmed, "##") && (strings.Contains(trimmed, "Plan") || strings.Contains(trimmed, "План")) ||
trimmed == headerPLAN || trimmed == headerPLANRu
continue
}
// Numbered list items: "1. ..." or "1) ..."
if len(trimmed) > 2 && trimmed[0] >= '0' && trimmed[0] <= '9' &&
(strings.Contains(trimmed[:min(planPrefixScanLen, len(trimmed))], ".") || strings.Contains(trimmed[:min(planPrefixScanLen, len(trimmed))], ")")) {
lastPlanLine = i
inPlan = true
continue
}
// Continuation of a numbered item (indented or starts with text after a number was seen).
if inPlan && (line[0] == ' ' || line[0] == '\t') {
lastPlanLine = i
continue
}
// Non-plan line after plan started — stop.
if inPlan {
break
}
// Context section text before PLAN.
lastPlanLine = i
}
return strings.TrimSpace(strings.Join(lines[:lastPlanLine+1], "\n"))
}
// stringFromMap safely extracts a string value from a map[string]any.
func stringFromMap(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"encoding/json"
"strings"
"time"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
)
// ClassifyKind enumerates the categories the response classifier may assign.
type ClassifyKind string
const (
ClassifyKindPlan ClassifyKind = "plan"
ClassifyKindQuestionAnswer ClassifyKind = "question_answer"
ClassifyKindClarificationRequest ClassifyKind = "clarification_request"
ClassifyKindStatus ClassifyKind = "status"
ClassifyKindOther ClassifyKind = "other"
)
// ClassifySuggestedAction enumerates the follow-up actions the classifier may suggest.
type ClassifySuggestedAction string
const (
ClassifyActionApprovePlan ClassifySuggestedAction = "approve_plan"
ClassifyActionAnswerQuestion ClassifySuggestedAction = "answer_question"
ClassifyActionNone ClassifySuggestedAction = "none"
)
// ClassifyResult is the structured verdict of the response classifier.
type ClassifyResult struct {
Kind ClassifyKind `json:"kind"`
NeedsUserAction bool `json:"needs_user_action"`
SuggestedAction ClassifySuggestedAction `json:"suggested_action"`
CTAText string `json:"cta_text"`
}
// safeClassifyDefault is the value returned when the classifier fails or times out.
// It must be inert: no dialog, no extra UI events.
func safeClassifyDefault() ClassifyResult {
return ClassifyResult{
Kind: ClassifyKindOther,
NeedsUserAction: false,
SuggestedAction: ClassifyActionNone,
}
}
// ClassifyResponse asks an LLM to classify a previously produced agent response.
// It is intended to be called from a background goroutine after the main agent
// finished its turn. On any error or timeout it returns safeClassifyDefault().
//
// timeout caps the whole call (including LLM time). Pass 0 to disable.
func ClassifyResponse(
ctx context.Context,
client llm.Client,
model, lang, userInput, agentResponse string,
timeout time.Duration,
) (ClassifyResult, error) {
if client == nil || model == "" {
return safeClassifyDefault(), errors.New("classifier: missing client or model")
}
if strings.TrimSpace(agentResponse) == "" {
return safeClassifyDefault(), nil
}
if timeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, timeout)
defer cancel()
}
systemPrompt := systemPromptForRole(RoleResponseClassifier, lang, "", "", nil)
userPayload := "USER_INPUT:\n" + userInput + "\n\nAGENT_RESPONSE:\n" + agentResponse
req := llm.ChatRequest{
Model: model,
Messages: []llm.Message{
{Role: "system", Content: systemPrompt},
{Role: "user", Content: userPayload},
},
Options: map[string]any{
"temperature": 0,
},
}
chunk, err := client.ChatSync(ctx, &req)
if err != nil {
return safeClassifyDefault(), errors.WithMessage(err, "classifier chat")
}
res, err := parseClassifyJSON(chunk.Content)
if err != nil {
return safeClassifyDefault(), errors.WithMessage(err, "classifier parse")
}
return res, nil
}
// parseClassifyJSON extracts a JSON object from raw model output and decodes it
// into a ClassifyResult. It tolerates surrounding markdown fences and trailing prose.
func parseClassifyJSON(raw string) (ClassifyResult, error) {
s := strings.TrimSpace(raw)
if s == "" {
return ClassifyResult{}, errors.New("empty response")
}
// Strip markdown code fences if present.
if strings.HasPrefix(s, "```") {
s = strings.TrimPrefix(s, "```json")
s = strings.TrimPrefix(s, "```JSON")
s = strings.TrimPrefix(s, "```")
if i := strings.LastIndex(s, "```"); i >= 0 {
s = s[:i]
}
s = strings.TrimSpace(s)
}
// Locate the first '{' and the matching last '}' to tolerate trailing prose.
start := strings.Index(s, "{")
end := strings.LastIndex(s, "}")
if start < 0 || end <= start {
return ClassifyResult{}, errors.New("no JSON object found")
}
jsonBlob := s[start : end+1]
var res ClassifyResult
if err := json.Unmarshal([]byte(jsonBlob), &res); err != nil {
return ClassifyResult{}, errors.WithMessage(err, "unmarshal")
}
// Defensive normalization: enforce schema invariants.
switch res.Kind {
case ClassifyKindPlan, ClassifyKindQuestionAnswer, ClassifyKindClarificationRequest, ClassifyKindStatus, ClassifyKindOther:
default:
res.Kind = ClassifyKindOther
}
switch res.SuggestedAction {
case ClassifyActionApprovePlan, ClassifyActionAnswerQuestion, ClassifyActionNone:
default:
res.SuggestedAction = ClassifyActionNone
}
if res.SuggestedAction != ClassifyActionApprovePlan {
res.CTAText = ""
}
return res, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"fmt"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
)
const (
summarizeTruncateLen = 500 // max chars per message when building summarization input
keepFirstMessages = 2 // system + first user message to preserve on trim
keepLastMessages = 6 // recent messages to preserve on trim
maxASCIICodepoint = 127 // highest ASCII code point
nonASCIIThresholdPct = 30 // percentage of non-ASCII runes triggering non-ASCII ratio
charsPerTokenNonASCII = 2 // approximate chars per token for CJK/Cyrillic text
charsPerTokenASCII = 4 // approximate chars per token for ASCII text
calibrationWeight = 0.3 // EMA weight for new calibration samples (0..1)
responseReserveRatio = 0.15 // fraction of maxTokens reserved for model response
responseReserveMin = 1024 // minimum tokens reserved for model response
smartTruncateHead = 300 // runes to keep from the beginning
smartTruncateTail = 200 // runes to keep from the end
smartTruncateSep = "\n... [truncated] ...\n"
smartTruncateHeadRatio = 3 // numerator for proportional head split (3/5 = 60%)
smartTruncateRatioDen = 5 // denominator for proportional split
summaryMarker = "[Summary of earlier conversation.]"
)
// ContextManager handles context window management.
type ContextManager struct {
maxTokens int
keepFirst int
keepLast int
client llm.Client
model string
language string
runningSummary string
calibrationRatio float64 // EMA correction ratio: actual/estimated tokens
}
// SetLanguage sets the language for summarization prompts.
func (cm *ContextManager) SetLanguage(lang string) {
cm.language = lang
}
// NewContextManager creates a new context manager.
func NewContextManager(maxTokens int, client llm.Client, model string) *ContextManager {
return &ContextManager{
maxTokens: maxTokens,
keepFirst: keepFirstMessages,
keepLast: keepLastMessages,
client: client,
model: model,
}
}
// effectiveBudget returns the token budget for message history,
// reserving space for the model's response.
func (cm *ContextManager) effectiveBudget() int {
reserve := max(int(float64(cm.maxTokens)*responseReserveRatio), responseReserveMin)
budget := cm.maxTokens - reserve
if budget < 0 {
return 0
}
return budget
}
// Trim reduces message history if it exceeds the token budget.
func (cm *ContextManager) Trim(ctx context.Context, messages []llm.Message) ([]llm.Message, error) {
estimated := cm.estimateTokens(messages)
if estimated <= cm.effectiveBudget() {
return messages, nil
}
return cm.doTrim(ctx, messages, "")
}
// ForceTrim summarizes conversation history regardless of token count.
// Instructions are optional hints for the summarizer (e.g. "focus on file changes").
func (cm *ContextManager) ForceTrim(ctx context.Context, messages []llm.Message, instructions string) ([]llm.Message, error) {
return cm.doTrim(ctx, messages, instructions)
}
func (cm *ContextManager) doTrim(ctx context.Context, messages []llm.Message, instructions string) ([]llm.Message, error) {
if len(messages) <= cm.keepFirst+cm.keepLast {
return messages, nil
}
kf, kl := adjustBoundaries(messages, cm.keepFirst, cm.keepLast)
if kf+kl >= len(messages) {
return messages, nil
}
head := messages[:kf]
tail := messages[len(messages)-kl:]
middle := messages[kf : len(messages)-kl]
// Extract previous summary from middle for incremental summarization.
var previousSummary string
var nonSummaryMiddle []llm.Message
for _, m := range middle {
if m.Role == "user" && strings.HasPrefix(m.Content, summaryMarker) {
previousSummary = m.Content
} else {
nonSummaryMiddle = append(nonSummaryMiddle, m)
}
}
if previousSummary != "" && cm.runningSummary == "" {
cm.runningSummary = previousSummary
}
summary, err := cm.summarize(ctx, nonSummaryMiddle, instructions)
msg := fmt.Sprintf("[Earlier conversation was trimmed to save context.]\nIMPORTANT REMINDER: ALL your responses MUST be in %s. This is mandatory.", langName(cm.language))
if err == nil {
msg = fmt.Sprintf("%s\n%s\n\nIMPORTANT REMINDER: ALL your responses MUST be in %s. This is mandatory.", summaryMarker, summary, langName(cm.language))
}
return cm.buildTrimmed(head, tail, msg), nil
}
func (cm *ContextManager) buildTrimmed(head, tail []llm.Message, summaryMsg string) []llm.Message {
result := make([]llm.Message, 0, len(head)+1+len(tail))
result = append(result, head...)
result = append(result, llm.Message{Role: "user", Content: summaryMsg})
result = append(result, tail...)
return result
}
func (cm *ContextManager) summarize(ctx context.Context, messages []llm.Message, instructions string) (string, error) {
var content strings.Builder
if cm.runningSummary != "" {
fmt.Fprintf(&content, "[Previous summary — incorporate and update, do not discard]:\n%s\n\n[New messages to summarize]:\n", cm.runningSummary)
}
for _, m := range messages {
fmt.Fprintf(&content, "[%s]: %s\n", m.Role, smartTruncate(m.Content, summarizeTruncateLen))
}
sysPrompt := "Summarize the following conversation concisely, preserving key decisions, file changes, and errors encountered."
if cm.runningSummary != "" {
sysPrompt += " A previous summary is provided — merge it with new information, preserving all key facts."
}
if instructions != "" {
sysPrompt += " Also focus on: " + instructions
}
if cm.language != "" {
sysPrompt += fmt.Sprintf(" Always respond in %s.", langName(cm.language))
}
resp, err := cm.client.ChatSync(ctx, &llm.ChatRequest{
Model: cm.model,
Messages: []llm.Message{
{Role: "system", Content: sysPrompt},
{Role: "user", Content: content.String()},
},
Options: map[string]any{
"num_ctx": cm.maxTokens,
},
})
if err != nil {
return "", err
}
cm.runningSummary = resp.Content
return resp.Content, nil
}
// Calibrate updates the internal correction ratio using a real token count
// from the API response. Call this after each API response with the actual
// prompt token count (PromptEvalCount) and the messages that were sent.
func (cm *ContextManager) Calibrate(messages []llm.Message, actualTokens int) {
if actualTokens <= 0 {
return
}
estimated := cm.estimateTokensRaw(messages)
if estimated <= 0 {
return
}
sample := float64(actualTokens) / float64(estimated)
if cm.calibrationRatio == 0 {
cm.calibrationRatio = sample
} else {
cm.calibrationRatio = cm.calibrationRatio*(1-calibrationWeight) + sample*calibrationWeight
}
}
func (cm *ContextManager) estimateTokens(messages []llm.Message) int {
raw := cm.estimateTokensRaw(messages)
if cm.calibrationRatio > 0 {
return int(float64(raw) * cm.calibrationRatio)
}
return raw
}
func (cm *ContextManager) estimateTokensRaw(messages []llm.Message) int {
total := 0
for _, m := range messages {
total += estimateStringTokens(m.Content)
total += 4 // message framing overhead (role, separators)
}
return total
}
// toolCallIndex maps tool call IDs to the index of the assistant message that produced them.
func toolCallIndex(messages []llm.Message) map[string]int {
idx := make(map[string]int)
for i, m := range messages {
if m.Role == roleAssistant && len(m.ToolCalls) > 0 {
for _, tc := range m.ToolCalls {
if tc.ID != "" {
idx[tc.ID] = i
}
}
}
}
return idx
}
// adjustBoundaries ensures tool_call/tool_result pairs are not split across
// head/middle/tail boundaries. Returns adjusted (keepFirst, keepLast).
func adjustBoundaries(messages []llm.Message, keepFirst, keepLast int) (adjustedFirst, adjustedLast int) {
n := len(messages)
if keepFirst+keepLast >= n {
return keepFirst, keepLast
}
tcIdx := toolCallIndex(messages)
// Expand head: if messages at the head border are assistant with tool calls,
// include all corresponding tool results.
for keepFirst < n-keepLast {
last := messages[keepFirst-1]
if last.Role != roleAssistant || len(last.ToolCalls) == 0 {
break
}
maxResultIdx := keepFirst - 1
for _, tc := range last.ToolCalls {
for j := keepFirst; j < n-keepLast; j++ {
if messages[j].ToolCallID == tc.ID {
if j > maxResultIdx {
maxResultIdx = j
}
break
}
}
}
if maxResultIdx < keepFirst {
break
}
keepFirst = maxResultIdx + 1
if keepFirst+keepLast >= n {
return keepFirst, keepLast
}
}
// Expand tail: if messages at the tail border are tool results,
// include the assistant message that produced them.
for keepLast < n-keepFirst {
tailStart := n - keepLast
first := messages[tailStart]
if first.ToolCallID == "" {
break
}
assistantIdx, ok := tcIdx[first.ToolCallID]
if !ok || assistantIdx >= tailStart {
break
}
keepLast = n - assistantIdx
if keepFirst+keepLast >= n {
return keepFirst, keepLast
}
}
return keepFirst, keepLast
}
// smartTruncate preserves both the beginning and end of long strings.
// This is important for code and error messages where the end often
// contains critical information (error names, stack trace endings).
func smartTruncate(s string, maxLen int) string {
runes := []rune(s)
if len(runes) <= maxLen {
return s
}
headLen := smartTruncateHead
tailLen := smartTruncateTail
if headLen+tailLen > maxLen {
headLen = maxLen * smartTruncateHeadRatio / smartTruncateRatioDen
tailLen = maxLen - headLen
}
sepRunes := []rune(smartTruncateSep)
result := make([]rune, 0, headLen+len(sepRunes)+tailLen)
result = append(result, runes[:headLen]...)
result = append(result, sepRunes...)
result = append(result, runes[len(runes)-tailLen:]...)
return string(result)
}
// estimateStringTokens estimates token count for a string.
// Uses different ratios for ASCII vs non-ASCII (CJK, Cyrillic) text.
func estimateStringTokens(s string) int {
if len(s) == 0 {
return 0
}
var nonASCII, totalRunes int
for _, r := range s {
totalRunes++
if r > maxASCIICodepoint {
nonASCII++
}
}
if totalRunes > 0 && nonASCII*pctMultiplier/totalRunes > nonASCIIThresholdPct {
// Non-ASCII heavy text (Cyrillic, CJK): ~2 chars per token
return totalRunes / charsPerTokenNonASCII
}
// ASCII-heavy text: ~4 chars per token
return len(s) / charsPerTokenASCII
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"fmt"
"path/filepath"
"sort"
"strings"
"sync"
"github.com/pkg/errors"
)
const stepTitleMaxLen = 50 // max chars for step title in group messages
// stepDAG is the dependency graph built from a structured Plan. It supports
// streaming "ready" steps as their dependencies complete and tracks remaining
// work for the parallel runner.
type stepDAG struct {
steps map[int]*Step
indegree map[int]int // step id -> number of unmet dependencies
dependents map[int][]int // step id -> ids that depend on it
}
func buildStepDAG(plan *Plan) *stepDAG {
d := &stepDAG{
steps: make(map[int]*Step, len(plan.Steps)),
indegree: make(map[int]int, len(plan.Steps)),
dependents: make(map[int][]int),
}
for i := range plan.Steps {
s := &plan.Steps[i]
d.steps[s.ID] = s
d.indegree[s.ID] = 0
}
for i := range plan.Steps {
s := &plan.Steps[i]
for _, dep := range s.DependsOn {
if _, ok := d.steps[dep]; !ok {
continue // unknown dep, ignore
}
d.indegree[s.ID]++
d.dependents[dep] = append(d.dependents[dep], s.ID)
}
}
return d
}
// readyRoots returns all step ids with no remaining dependencies. Sorted by
// id for deterministic execution order.
func (d *stepDAG) readyRoots() []int {
out := make([]int, 0)
for id, deg := range d.indegree {
if deg == 0 {
out = append(out, id)
}
}
sort.Ints(out)
return out
}
// markDone decrements the indegree of every dependent of `id` and returns the
// ids that just became ready.
func (d *stepDAG) markDone(id int) []int {
var newlyReady []int
for _, child := range d.dependents[id] {
d.indegree[child]--
if d.indegree[child] == 0 {
newlyReady = append(newlyReady, child)
}
}
sort.Ints(newlyReady)
return newlyReady
}
// hasRemaining returns true if any step still needs to run.
func (d *stepDAG) hasRemaining(done map[int]bool) bool {
for id := range d.steps {
if !done[id] {
return true
}
}
return false
}
// fileLockSet provides per-path mutual exclusion so that two parallel steps
// touching the same file are serialised even when MaxParallelTasks > 1.
//
// Locks are always acquired in sorted path order to avoid deadlock between
// pairs of steps with overlapping file sets.
type fileLockSet struct {
mu sync.Mutex
locks map[string]*sync.Mutex
}
func newFileLockSet() *fileLockSet {
return &fileLockSet{locks: make(map[string]*sync.Mutex)}
}
func (s *fileLockSet) lockFor(path string) *sync.Mutex {
abs := filepath.Clean(path)
s.mu.Lock()
defer s.mu.Unlock()
if m, ok := s.locks[abs]; ok {
return m
}
m := &sync.Mutex{}
s.locks[abs] = m
return m
}
// acquire takes locks on every path in the slice in deterministic sorted
// order, returning a release function the caller must defer.
func (s *fileLockSet) acquire(paths []string) func() {
if len(paths) == 0 {
return func() {}
}
sorted := append([]string(nil), paths...)
sort.Strings(sorted)
mutexes := make([]*sync.Mutex, 0, len(sorted))
for _, p := range sorted {
m := s.lockFor(p)
m.Lock()
mutexes = append(mutexes, m)
}
return func() {
for i := len(mutexes) - 1; i >= 0; i-- {
mutexes[i].Unlock()
}
}
}
// formatStepDescription builds a human-readable description of a step for
// the per-step confirmation dialog.
func formatStepDescription(step *Step) string {
var b strings.Builder
fmt.Fprintf(&b, "Step %d: %s\n", step.ID, step.Title)
if len(step.Files) > 0 {
fmt.Fprintf(&b, "Files: %s\n", strings.Join(step.Files, ", "))
}
if step.Action != "" {
fmt.Fprintf(&b, "Action: %s\n", step.Action)
}
if step.Rationale != "" {
fmt.Fprintf(&b, "Rationale: %s\n", step.Rationale)
}
return b.String()
}
// runPlanDAG executes a structured Plan using a topological scheduler with
// bounded parallelism (`maxParallel`) and per-file locking. It returns the
// merged SessionStats from all sub-agent runs.
//
// Sub-agents share the orchestrator's tool result cache (Block 2), so
// repeated reads across steps are free.
func (o *Orchestrator) runPlanDAG(ctx context.Context, plan *Plan, maxParallel int, confirmFn ConfirmFunc, autoAccept bool) (SessionStats, error) {
if maxParallel < 1 {
maxParallel = 1
}
dag := buildStepDAG(plan)
locks := newFileLockSet()
// Enrich step rule names from file paths. The structurer may already
// have populated RuleNames from the plan text; this adds rules detected
// from the step's file paths so that perStepRAG loads them explicitly.
for i := range plan.Steps {
s := &plan.Steps[i]
detected := entityTypesFromPaths(s.Files, o.ruleNames)
s.RuleNames = mergeRuleNames(s.RuleNames, detected)
}
// Pre-compute per-step RAG bundles once. Each bundle is independent of
// scheduling order, so doing this up-front lets parallel sub-agents share
// the result instead of issuing duplicate embedding searches.
o.stepRAGBundles = make(map[int]string, len(plan.Steps))
for i := range plan.Steps {
s := &plan.Steps[i]
o.stepRAGBundles[s.ID] = o.perStepRAG(ctx, s)
}
defer func() { o.stepRAGBundles = nil }()
type stepResult struct {
id int
stats SessionStats
err error
}
var (
mu sync.Mutex
done = make(map[int]bool)
ready = dag.readyRoots()
pending []int
results = make(chan stepResult, len(plan.Steps))
active int
merged SessionStats
firstErr error
)
// Cancellable child context so a hard error stops in-flight steps.
runCtx, cancel := context.WithCancel(ctx)
defer cancel()
startStep := func(id int) {
active++
step := dag.steps[id]
go func() {
release := locks.acquire(step.Files)
defer release()
groupID := fmt.Sprintf("step-%d", step.ID)
o.emit(&Event{
Type: EventGroupStart,
GroupID: groupID,
Message: fmt.Sprintf("Executor(%s)", truncateTask(step.Title, stepTitleMaxLen)),
})
defer o.emit(&Event{Type: EventGroupEnd, GroupID: groupID})
stats, err := o.runStep(runCtx, step, plan.Context, confirmFn)
results <- stepResult{id: id, stats: stats, err: err}
}()
}
// trySchedule runs step confirm (if configured) and either starts the
// step, skips it, or signals cancellation. Returns false when the user
// chose StepDenyAll.
trySchedule := func(id int) bool {
step := dag.steps[id]
desc := formatStepDescription(step)
if !autoAccept && o.stepConfirmFn != nil {
switch o.stepConfirmFn(desc) {
case StepSkip:
done[id] = true
newly := dag.markDone(id)
pending = append(pending, newly...)
return true
case StepDenyAll:
firstErr = errors.New("execution cancelled by user")
cancel()
return false
}
} else if autoAccept {
o.emit(&Event{Type: EventAgent, Message: desc})
}
startStep(id)
return true
}
// Prime the first batch.
for _, id := range ready {
if active >= maxParallel || firstErr != nil {
break
}
if !trySchedule(id) {
break
}
}
pending = ready[min(len(ready), maxParallel):]
for active > 0 {
res := <-results
active--
mu.Lock()
mergeStats(&merged, &res.stats)
mu.Unlock()
if res.err != nil && firstErr == nil {
firstErr = res.err
cancel()
}
done[res.id] = true
newly := dag.markDone(res.id)
pending = append(pending, newly...)
// Schedule next.
for active < maxParallel && len(pending) > 0 && firstErr == nil {
next := pending[0]
pending = pending[1:]
if !trySchedule(next) {
break
}
}
}
if firstErr != nil {
return merged, errors.WithMessage(firstErr, "step")
}
if dag.hasRemaining(done) {
return merged, errors.New("plan DAG ended with unscheduled steps (likely a cycle)")
}
return merged, nil
}
// mergeStats accumulates child SessionStats into a parent. The parent is
// expected to be the orchestrator-level aggregate.
func mergeStats(dst, src *SessionStats) {
dst.FilesAdded += src.FilesAdded
dst.FilesModified += src.FilesModified
dst.FilesDeleted += src.FilesDeleted
dst.FilesRenamed += src.FilesRenamed
dst.LinesAdded += src.LinesAdded
dst.LinesRemoved += src.LinesRemoved
dst.ToolCalls += src.ToolCalls
dst.TotalPrompt += src.TotalPrompt
dst.TotalEval += src.TotalEval
if src.PeakContextPct > dst.PeakContextPct {
dst.PeakContextPct = src.PeakContextPct
}
dst.ChangedFiles = append(dst.ChangedFiles, src.ChangedFiles...)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import "strings"
// ToolDisplayName maps internal tool names to user-friendly display names.
func ToolDisplayName(name string) string {
switch name {
case toolNameReadFile:
return "Read"
case toolNameWriteFile:
return "Write"
case toolNameEditFile:
return "Edit"
case "delete_file":
return actionDelete
case "list_dir":
return "ListDir"
case "find_files":
return "Find"
case "create_dir":
return "CreateDir"
case toolNameMoveFile:
return "Move"
case "grep":
return "Grep"
case "go_build":
return "Build"
case toolNameGoTest:
return "Test"
case "go_lint":
return "Lint"
case toolNameGoVet:
return "Vet"
case "go_doc":
return "GoDoc"
case "go_structure":
return "Structure"
case "search_docs":
return "SearchDocs"
case toolNameBash:
return "Bash"
case "snippets":
return "Snippets"
case "git_status":
return "GitStatus"
case "git_diff":
return "GitDiff"
case "git_log":
return "GitLog"
case toolNameGitCommit:
return "GitCommit"
case "get_rule":
return "GetRule"
default:
return snakeToPascal(name)
}
}
func snakeToPascal(s string) string {
parts := strings.Split(s, "_")
for i, p := range parts {
if len(p) > 0 {
parts[i] = strings.ToUpper(p[:1]) + p[1:]
}
}
return strings.Join(parts, "")
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"regexp"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/tools"
)
// numberedListItem matches a top-level numbered list bullet at the start of
// a line: "1.", "2)", "10.". Used to detect plan-shaped responses.
var numberedListItem = regexp.MustCompile(`(?m)^[ \t]*\d{1,3}[.)]\s`)
// Heuristic thresholds for looksLikeMarkdownPlan. Tuned against local-model
// failure modes where the model dumps a markdown plan instead of calling
// tools; see the function comment for rationale.
const (
planMinContentLen = 80 // shorter replies are treated as real answers
planMinNumberedItems = 3 // "1. / 2. / 3." at line start → list of steps
planMinMarkdownHeaders = 2 // "## A" + "## B" with no code fence → plan
)
// maxEditNudges caps how many times a single Send() invocation will nudge
// the model back into tool-calling mode when it answers EDIT requests with
// prose. Each nudge uses a progressively stronger message (see
// editNudgeMessage). Prevents loops on models that refuse to call tools.
const maxEditNudges = 3
// maxPlanToolNudges caps how many times a single Send() invocation will
// nudge the model to call tools when it returns NO_ISSUES in plan mode
// without having called any tools first.
const maxPlanToolNudges = 1
// maxReadFileNudges caps how many times a single Send() will nudge the
// model to call read_file after it called file_stat but stopped without
// reading any file contents. Part of the guided-flow mechanism for weak
// models that execute only one workflow step per turn.
const maxReadFileNudges = 1
// maxPlanBlocked caps consecutive iterations in plan mode where every tool
// call was blocked (model hallucinated write tools). After this many
// iterations the agent stops early to avoid wasting the iteration budget.
const maxPlanBlocked = 3
// planMarkerWords are case-insensitive substrings that strongly indicate a
// plan/analysis dump rather than executed work. Local models in RU/EN both
// fall back to these headings when they refuse to call tools.
var planMarkerWords = []string{
"план исправлен",
"план измен",
"план действ",
"анализ текущ",
"подробное описание изменений",
"plan of changes",
"implementation plan",
"step-by-step plan",
"## plan",
"## план",
}
// textToolCallKeys are the known key names that appear in text-form tool calls.
// The order matters: we scan for these in sequence to delimit multiline values.
var textToolCallKeys = []string{"path:", "old_str:", "new_str:", "content:"}
// parseTextToolCall attempts to parse a model response that contains an
// edit_file or write_file tool call written as plain text instead of a JSON
// tool call. Returns nil if the pattern is not detected.
//
// Recognised format (lines are trimmed):
//
// edit_file
// path: <value>
// old_str: <value — may span multiple lines>
// new_str: <value — may span multiple lines>
func parseTextToolCall(content string) *llm.ToolCall {
lines := strings.Split(content, "\n")
// Find the tool name line.
toolName := ""
startIdx := -1
for i, line := range lines {
t := strings.TrimSpace(line)
if t == toolNameEditFile || t == toolNameWriteFile {
toolName = t
startIdx = i + 1
break
}
}
if toolName == "" || startIdx >= len(lines) {
return nil
}
// Parse key: value pairs, collecting multiline values.
kv := make(map[string]string)
var currentKey string
var currentVal strings.Builder
flushKey := func() {
if currentKey != "" {
kv[currentKey] = strings.TrimRight(currentVal.String(), "\n")
currentVal.Reset()
}
}
for i := startIdx; i < len(lines); i++ {
line := lines[i]
trimmed := strings.TrimSpace(line)
// Check if this line starts a new key.
foundKey := ""
for _, k := range textToolCallKeys {
if strings.HasPrefix(trimmed, k) {
foundKey = k
break
}
}
if foundKey != "" {
flushKey()
currentKey = strings.TrimSuffix(foundKey, ":")
val := strings.TrimSpace(strings.TrimPrefix(trimmed, foundKey))
currentVal.WriteString(val)
} else if currentKey != "" {
// Continuation of the current multiline value.
if currentVal.Len() > 0 {
currentVal.WriteByte('\n')
}
currentVal.WriteString(line)
}
}
flushKey()
path := kv["path"]
if path == "" {
return nil
}
args := map[string]any{"path": path}
switch toolName {
case toolNameEditFile:
oldStr, hasOld := kv["old_str"]
newStr, hasNew := kv["new_str"]
if !hasOld && !hasNew {
return nil
}
if hasOld {
args["old_str"] = oldStr
}
if hasNew {
args["new_str"] = newStr
}
case toolNameWriteFile:
c, has := kv["content"]
if !has {
return nil
}
args["content"] = c
}
return &llm.ToolCall{
ID: "synth_0",
Function: llm.ToolCallFunc{
Name: toolName,
Arguments: args,
},
}
}
// ExtractDiffFromText attempts to extract old_str/new_str from a text-form
// edit_file tool call and returns a SimpleDiff string for TUI rendering.
// Returns "" if the content does not match the pattern.
func ExtractDiffFromText(content string) string {
tc := parseTextToolCall(content)
if tc == nil || tc.Function.Name != toolNameEditFile {
return ""
}
oldStr := stringFromMap(tc.Function.Arguments, "old_str")
newStr := stringFromMap(tc.Function.Arguments, "new_str")
path := stringFromMap(tc.Function.Arguments, "path")
if oldStr == "" && newStr == "" {
return ""
}
return tools.SimpleDiff(oldStr, newStr, path, editDiffMaxLines)
}
// editDiffMaxLines limits the number of diff lines shown for text-form tool calls.
const editDiffMaxLines = 30
// editNudgeMessage returns an escalating nudge message for the given attempt.
// nudgeNum is 1-based (1 = first nudge). hasReadFiles indicates whether the
// model has already called read_file during this Send() invocation, which
// means it should not re-read — it should edit.
// editNudgeLevel enumerates escalation levels for EDIT-mode nudges.
const (
editNudgeLevel1 = 1 // gentle reminder
editNudgeLevel2 = 2 // forceful, context-aware
)
func editNudgeMessage(nudgeNum int, hasReadFiles bool) string {
switch nudgeNum {
case editNudgeLevel1:
return "Stop. You are in EDIT mode. Do NOT describe the changes — apply them now " +
"via write_file/edit_file/bash. Begin with the FIRST change only. " +
"Do not explain. Do not output another plan."
case editNudgeLevel2:
if hasReadFiles {
return "You have already read the files. Do NOT read them again. " +
"Call edit_file RIGHT NOW for the first fix. Produce exactly ONE edit_file call " +
"with path, old_str, and new_str. No text. No plan. Just the tool call."
}
return "CRITICAL: You are STILL outputting text instead of calling tools. " +
"Call edit_file with path, old_str, new_str for a SINGLE change. " +
"Do not describe anything. Do not output a plan. Just ONE tool call."
default:
return "LAST CHANCE. You MUST call edit_file or write_file RIGHT NOW. " +
"Pick exactly ONE thing to fix and call edit_file(path, old_str, new_str). " +
"If you cannot use edit_file — call bash to apply a patch. " +
"Any text response without a tool call will be discarded."
}
}
// looksLikeMarkdownPlan reports whether content is shaped like a markdown
// plan/analysis the model produced instead of calling tools. The intent is
// to catch the EDIT-mode failure mode where the model lists steps it would
// take instead of taking them. False positives are acceptable (we nudge
// up to maxEditNudges times with escalating messages before giving up).
//
// Heuristic, in order of weight:
// 1. Empty/short content → not a plan (a real answer to a question is fine).
// 2. Contains any planMarkerWords → very strong signal.
// 3. Has 3 or more numbered-list items at line start → likely a plan.
// 4. Has 2+ markdown headers (##) AND no fenced code block → likely a plan.
func looksLikeMarkdownPlan(content string) bool {
c := strings.TrimSpace(content)
if len(c) < planMinContentLen {
return false
}
low := strings.ToLower(c)
for _, w := range planMarkerWords {
if strings.Contains(low, w) {
return true
}
}
if matches := numberedListItem.FindAllStringIndex(c, -1); len(matches) >= planMinNumberedItems {
return true
}
headerCount := 0
for _, line := range strings.Split(c, "\n") {
t := strings.TrimSpace(line)
if strings.HasPrefix(t, "## ") || strings.HasPrefix(t, "### ") {
headerCount++
}
}
if headerCount >= planMinMarkdownHeaders && !strings.Contains(c, "```") {
return true
}
return false
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
// eventEmitter ships agent lifecycle events to a handler. It owns the optional
// TUI group id so sub-agents can stamp every event without each call site
// having to know about groups.
type eventEmitter struct {
handler EventHandler
groupID string
}
// SetHandler installs the handler invoked for every emitted event. Pass nil
// to silence emission.
func (e *eventEmitter) SetHandler(h EventHandler) {
e.handler = h
}
// SetGroupID associates every event emitted by this emitter with the given
// TUI collapsible group. Call sites can still override by setting GroupID on
// the event explicitly before emit.
func (e *eventEmitter) SetGroupID(id string) {
e.groupID = id
}
// emit dispatches the event to the handler, stamping the configured group id
// when the event does not already carry one. Safe to call when no handler is
// configured — drops the event silently.
func (e *eventEmitter) emit(ev *Event) {
if e.handler == nil {
return
}
if ev.GroupID == "" && e.groupID != "" {
ev.GroupID = e.groupID
}
e.handler(ev)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
// planLabel returns the localized string for a plan-related key.
// Falls back to English if the language or key is not found.
func planLabel(lang, key string) string {
if m, ok := planLabels[lang]; ok {
if s, ok := m[key]; ok {
return s
}
}
return planLabels["en"][key]
}
var planLabels = map[string]map[string]string{
"en": {
"tasks": "## Tasks\n",
"tasks2": "## Tasks\n\n",
"affected": "\n## Affected files\n",
"affected2": "## Affected files\n\n",
"verification": "\n## Post-execution verification\n",
"verification2": "## Post-execution verification\n\n",
"lines": " (lines: ",
"build": "Build project",
"lint": "Run linter",
"test": "Run tests",
},
"ru": {
"tasks": "## Задачи\n",
"tasks2": "## Задачи\n\n",
"affected": "\n## Затронутые файлы\n",
"affected2": "## Затронутые файлы\n\n",
"verification": "\n## Проверка после выполнения\n",
"verification2": "## Проверка после выполнения\n\n",
"lines": " (строки: ",
"build": "Собрать проект",
"lint": "Запустить линтер",
"test": "Запустить тесты",
},
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import "github.com/raoptimus/kodrun/internal/tools"
// readOnlyToolsFor returns the set of read-only tool names registered with reg.
// Tools advertise read-only status by implementing tools.ReadOnlyMarker;
// anything else is considered a write tool.
func readOnlyToolsFor(reg *tools.Registry) map[string]bool {
return reg.ReadOnlyTools()
}
// Mode represents the agent operating mode.
type Mode int
const (
// ModePlan is read-only analysis mode.
ModePlan Mode = iota
// ModeEdit is full tool access mode.
ModeEdit
// ModeChat is free-form discussion mode with read-only tool access.
ModeChat
)
const modeChatStr = "chat"
// String returns the mode name.
func (m Mode) String() string {
switch m {
case ModePlan:
return string(ClassifyKindPlan)
case ModeChat:
return modeChatStr
default:
return "edit"
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"fmt"
"slices"
"time"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/projectlang"
"github.com/raoptimus/kodrun/internal/rag"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/tools"
)
const (
taskTruncateLen = 40 // max chars for task in phase group messages
maxToolWorkersDefault = 4 // default parallel tool workers for sub-agents
orchestratorRAGTopK = 5 // top-K results for orchestrator RAG prefetch
perStepRAGTopK = 3 // top-K results for per-step RAG search
maxPlanIterations = 100 // max LLM iterations for planning phase
maxExecIterations = 50 // max LLM iterations for execution phase
hitRatePctMultiplier = 100 // multiplier to convert hit rate ratio to percentage
minStructuredSteps = 4 // min steps for a plan to be considered structured
ragRuleTopK = 2 // top-K for broad rule-name RAG queries
minRegexMatches = 2 // min regex submatch groups / plan steps for validity
minStepTextLen = 10 // min chars for a plan step to be meaningful
structurerMaxIter = 3 // max iterations for structurer sub-agent
extractorMaxIter = 5 // max iterations for extractor sub-agent
severityMinor = 2 // severity rank for minor findings
severityUnknown = 3 // severity rank for unrecognized severity
perfStatusOK = "ok"
perfStatusErr = "err"
)
// roleProfile bundles every per-role knob the orchestrator needs to spin up
// a sub-agent: which LLM client/model/context to use plus optional sampling
// hints (used by the extractor / structurer roles).
//
// Roles other than RolePlanner that have nil profiles inherit RolePlanner's
// wiring at lookup time — that's the "fall back to main" rule.
type roleProfile struct {
Client llm.Client
Model string
ContextSize int
Temperature float64
Format string
}
// Orchestrator coordinates sub-agents in a Plan → Execute → Review pipeline.
type Orchestrator struct {
reg *tools.Registry
workDir string
// roles maps a Role to its profile. RolePlanner is always set (it doubles
// as the default profile). Other roles may be nil — see profileFor.
roles map[Role]*roleProfile
language string
ruleCatalog string
onEvent EventHandler
confirmFn ConfirmFunc
planConfirm PlanConfirmFunc
stepConfirmFn StepConfirmFunc
review bool
hasSnippets bool
hasRAG bool
ragIndex tools.RAGSearcher
godocIndexer tools.GoDocIndexer
langState *projectlang.State
rulesLoader *rules.Loader
ruleNames []string
prefetchCode bool
maxPlanIter int
maxExecIter int
maxRevIter int
maxParallelTasks int
maxReplans int
specialistTimeout time.Duration
autoCommit bool
think bool
verbose bool
cachedProjectFiles string
lastPlannerTask string
// stepRAGBundles is populated once at the start of runPlanDAG and read
// by runStep. Sharing the per-step RAG payload across the DAG run avoids
// re-issuing identical embedding searches for every parallel sub-agent.
// nil means "fall back to live perStepRAG()" (used outside DAG mode).
stepRAGBundles map[int]string
}
// profileFor returns the role profile or RolePlanner's profile as fallback.
// The extractor profile inherits the planner's client/model/context when its
// own values are zero — sampling hints (Temperature/Format) always come from
// the role-specific profile so deterministic-extraction tuning is preserved.
func (o *Orchestrator) profileFor(role Role) *roleProfile {
main := o.roles[RolePlanner]
p := o.roles[role]
if p == nil {
return main
}
out := *p
if out.Client == nil {
out.Client = main.Client
}
if out.Model == "" {
out.Model = main.Model
}
if out.ContextSize <= 0 {
out.ContextSize = main.ContextSize
}
return &out
}
// OrchestratorConfig holds configuration for the orchestrator.
type OrchestratorConfig struct {
EventHandler EventHandler
ConfirmFunc ConfirmFunc
PlanConfirm PlanConfirmFunc
StepConfirmFn StepConfirmFunc
Language string
RuleCatalog string
Review bool
HasSnippets bool
HasRAG bool
RAGIndex tools.RAGSearcher
GodocIndexer tools.GoDocIndexer
LangState *projectlang.State
RulesLoader *rules.Loader
PrefetchCode bool
// Optional dedicated executor wiring. When ExecutorClient is nil
// or ExecutorModel is empty, the orchestrator reuses the main client/model
// for the executor role.
ExecutorClient llm.Client
ExecutorModel string
ExecutorContextSize int
// Optional dedicated extractor wiring. The extractor is always invoked
// with deterministic settings (Temperature=0, Format="json") to coerce
// structured output. When ExtractorClient is nil or ExtractorModel is
// empty, the orchestrator reuses the main client/model.
ExtractorClient llm.Client
ExtractorModel string
ExtractorContextSize int
ExtractorTemperature float64
ExtractorFormat string
// Parallel DAG execution (Block 3). When >1, the executor splits the
// approved plan into a DAG and runs independent steps concurrently with
// per-file locking. Default 1 keeps current sequential behaviour.
MaxParallelTasks int
// Maximum number of REPLAN cycles allowed inside a single Run. Default 2.
MaxReplans int
// AutoCommit controls whether the executor may call git_commit.
// When false, git_commit is disabled for all sub-agents.
AutoCommit bool
// Think enables thinking mode for planners and reviewers.
// When false, planners and reviewers skip the thinking phase,
// which speeds up inference on slower models.
Think bool
// Verbose enables per-iteration and per-specialist timing diagnostics.
Verbose bool
// MaxIterations is the agent loop limit, used for review specialists.
MaxIterations int
// SpecialistTimeout caps wall time for a single review specialist.
// 0 means no per-specialist deadline.
SpecialistTimeout time.Duration
}
// NewOrchestrator creates a new orchestrator.
func NewOrchestrator(
client llm.Client,
model string,
reg *tools.Registry,
workDir string,
contextSize int,
cfg *OrchestratorConfig,
) *Orchestrator {
o := &Orchestrator{
reg: reg,
workDir: workDir,
roles: map[Role]*roleProfile{
RolePlanner: {
Client: client,
Model: model,
ContextSize: contextSize,
},
RoleExecutor: {
Client: cfg.ExecutorClient,
Model: cfg.ExecutorModel,
ContextSize: cfg.ExecutorContextSize,
},
RoleExtractor: {
Client: cfg.ExtractorClient,
Model: cfg.ExtractorModel,
ContextSize: cfg.ExtractorContextSize,
Temperature: cfg.ExtractorTemperature,
Format: cfg.ExtractorFormat,
},
},
maxParallelTasks: cfg.MaxParallelTasks,
maxReplans: cfg.MaxReplans,
onEvent: cfg.EventHandler,
confirmFn: cfg.ConfirmFunc,
planConfirm: cfg.PlanConfirm,
stepConfirmFn: cfg.StepConfirmFn,
language: cfg.Language,
ruleCatalog: cfg.RuleCatalog,
review: cfg.Review,
hasSnippets: cfg.HasSnippets,
hasRAG: cfg.HasRAG,
ragIndex: cfg.RAGIndex,
godocIndexer: cfg.GodocIndexer,
langState: cfg.LangState,
rulesLoader: cfg.RulesLoader,
ruleNames: collectRuleNames(cfg.RulesLoader),
prefetchCode: cfg.PrefetchCode,
maxPlanIter: maxPlanIterations,
maxExecIter: maxExecIterations,
maxRevIter: cfg.MaxIterations,
specialistTimeout: cfg.SpecialistTimeout,
autoCommit: cfg.AutoCommit,
think: cfg.Think,
verbose: cfg.Verbose,
}
if o.maxRevIter <= 0 {
o.maxRevIter = maxExecIterations
}
return o
}
// SetEventHandler sets the event handler shared by all sub-agents.
func (o *Orchestrator) SetEventHandler(h EventHandler) { o.onEvent = h }
func (o *Orchestrator) emit(e *Event) {
if o.onEvent != nil {
o.onEvent(e)
}
}
// Run executes the full Plan → Execute → (Review) pipeline.
// progLang returns the detected project programming language as a string,
// or "" if unknown.
func (o *Orchestrator) progLang() string {
if o.langState == nil {
return ""
}
return string(o.langState.Current())
}
// ensureLanguageDetected re-runs project language detection if it is still
// unknown and lazily registers the matching language-specific tools.
func (o *Orchestrator) ensureLanguageDetected() {
if o.langState == nil {
return
}
lang, changed := o.langState.EnsureDetected()
if !changed || lang == projectlang.LangUnknown {
return
}
tools.RegisterLanguageTools(o.reg, lang, o.workDir, o.godocIndexer)
o.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("Project language detected: %s — language tools registered", lang)})
}
// emitCacheStats logs the final cache hit/miss summary if the cache saw any
// activity. Used at the end of an orchestrator Run for visibility.
func (o *Orchestrator) emitCacheStats(c *tools.ResultCache) {
if c == nil {
return
}
hits := c.Hits()
misses := c.Misses()
if hits == 0 && misses == 0 {
return
}
rate := c.HitRate() * hitRatePctMultiplier
o.emit(&Event{
Type: EventCacheStats,
Message: fmt.Sprintf("Tool cache: %d hits / %d misses (%.0f%% hit rate)", hits, misses, rate),
CacheHits: hits,
CacheMisses: misses,
})
}
// emitPhase fires an EventPhase so the TUI can render a phase indicator. Name
// should be one of: planning, awaiting_approval, executing, verifying.
func (o *Orchestrator) emitPhase(name string) {
o.emit(&Event{Type: EventPhase, Message: name})
}
// Run executes the full Plan → Execute → (Review) pipeline. The planner sub-agent
// drafts a plan from task; if non-empty it is shown to the user and approved
// (or revised) via planConfirm; then the executor sub-agent applies the plan
// step-by-step, with optional verification/review afterwards. Returns the
// first error from any phase, or nil on success.
func (o *Orchestrator) Run(ctx context.Context, task string) error {
o.ensureLanguageDetected()
// Attach a per-run result cache so that read-only tool calls (read_file in
// particular) are served from memory across phases and sub-agents. The
// cache is invalidated automatically by write tools via the registry.
cache := tools.NewResultCache()
o.reg.WithCache(cache)
defer func() {
o.reg.WithCache(nil)
o.emitCacheStats(cache)
}()
// Phase 1: Planning
o.emitPhase("planning")
o.emit(&Event{Type: EventAgent, Message: "▸ Phase 1: Planning..."})
o.emit(&Event{Type: EventGroupStart, Message: fmt.Sprintf("Analyze(%s)", truncateTask(task, taskTruncateLen))})
plan, err := o.runPlanner(ctx, task)
o.emit(&Event{Type: EventGroupEnd})
if err != nil {
return errors.WithMessage(err, "planner")
}
if plan == "" {
return errors.New("planner produced empty plan")
}
return o.confirmAndExecute(ctx, plan, "Orchestrator completed")
}
// confirmAndExecute is the shared second half of the orchestrator pipeline.
// It shows the plan, runs the confirm dialog (with optional revision), then
// executes and optionally reviews. Used by both Run() and RunCodeReview().
func (o *Orchestrator) confirmAndExecute(ctx context.Context, plan, doneMsg string) error {
// Show the plan to the user
o.emit(&Event{Type: EventAgent, Message: plan})
// Confirm before execution (3-option dialog)
autoAccept := false
if o.planConfirm != nil {
for {
cr := o.planConfirm(plan)
switch cr.Action {
case PlanDeny:
o.emit(&Event{Type: EventAgent, Message: "Execution cancelled by user."})
o.emit(&Event{Type: EventModeChange, Message: "plan"})
o.emit(&Event{Type: EventDone, Message: doneMsg})
return nil
case PlanAutoAccept:
autoAccept = true
case PlanManualApprove:
// keep confirmFn as-is
case PlanAugment:
o.emit(&Event{Type: EventAgent, Message: "▸ Revising plan..."})
o.emit(&Event{Type: EventGroupStart, Message: "Revise(plan)"})
revised, err := o.runPlannerRevision(ctx, plan, cr.Augment)
o.emit(&Event{Type: EventGroupEnd})
if err != nil {
return errors.WithMessage(err, "planner revision")
}
if revised == "" {
return errors.New("planner revision produced empty plan")
}
plan = revised
o.emit(&Event{Type: EventAgent, Message: plan})
continue
}
break
}
}
// Execution phase.
// If context was cancelled (e.g. ESC during planning) but user approved
// the plan anyway, use a detached context so the executor can proceed.
execCtx := ctx
if ctx.Err() != nil {
execCtx = context.WithoutCancel(ctx)
}
o.emitPhase("executing")
o.emit(&Event{Type: EventModeChange, Message: "edit"})
o.emit(&Event{Type: EventAgent, Message: "▸ Executing plan..."})
var confirmForExec ConfirmFunc
if !autoAccept {
confirmForExec = o.confirmFn
}
execStats, err := o.runExecutor(execCtx, plan, confirmForExec, autoAccept)
if err != nil {
return errors.WithMessage(err, "executor")
}
// Verification loop (optional, planner path only).
// The verifier checks that every step in the approved plan was actually
// implemented. If incomplete items are found, it produces a fix plan and
// re-runs the executor. Repeats up to maxReplans times.
if o.review {
for attempt := 0; attempt < o.maxReplans; attempt++ {
o.emitPhase("verifying")
o.emit(&Event{
Type: EventAgent,
Message: fmt.Sprintf("▸ Verifying plan completion (%d/%d)...", attempt+1, o.maxReplans),
})
fixPlan, err := o.verifyOnce(execCtx, plan, &execStats)
if err != nil {
return err
}
if fixPlan == "" {
o.emit(&Event{Type: EventAgent, Message: "✓ All plan steps verified."})
break
}
o.emit(&Event{Type: EventAgent, Message: "▸ Applying fix plan for incomplete items..."})
fixStats, err := o.runExecutor(execCtx, fixPlan, o.confirmFn, false)
if err != nil {
return errors.WithMessage(err, "executor (verification fix)")
}
mergeStats(&execStats, &fixStats)
}
}
// Restore plan mode in TUI after orchestrator completes.
o.emit(&Event{Type: EventModeChange, Message: "plan"})
o.emit(&Event{Type: EventDone, Message: doneMsg, Stats: &execStats})
return nil
}
func (o *Orchestrator) newAgent(role Role, maxIter int) *Agent {
prof := o.profileFor(role)
ag := New(prof.Client, prof.Model, o.reg, maxIter, o.workDir, prof.ContextSize)
ag.SetLanguage(o.language)
ag.SetAutoCompact(true)
ag.SetMaxToolWorkers(maxToolWorkersDefault)
ag.SetHasSnippets(o.hasSnippets)
ag.SetHasRAG(o.hasRAG)
switch role {
case RolePlanner, RoleExecutor, RoleReviewer, RoleVerifier:
ag.SetTaskLabel(taskLabelForRole(role))
default:
if o.verbose {
ag.SetTaskLabel(taskLabelForRole(role))
}
}
ag.SetVerbose(o.verbose)
switch role {
case RolePlanner:
ag.SetMode(ModePlan)
ag.SetThink(o.think)
case RoleExecutor:
ag.SetMode(ModeEdit)
ag.SetThink(false)
case RoleReviewer, RoleVerifier:
ag.SetMode(ModePlan)
ag.SetThink(o.think)
case RoleCodeReviewer, RoleArchReviewer:
ag.SetMode(ModePlan)
ag.SetThink(o.think)
ag.SetToolsDisabled(true)
case RoleExtractor:
ag.SetMode(ModePlan)
ag.SetThink(false) // No thinking — just structured extraction
// Extractor must produce raw JSON only. Hide all tools so local models
// (qwen3-coder, etc.) cannot hallucinate tool_calls instead of output.
ag.SetToolsDisabled(true)
// Force deterministic generation for the extractor. The format hint
// pushes the local model toward valid JSON.
ag.SetTemperature(prof.Temperature)
if prof.Format != "" {
ag.SetFormat(prof.Format)
}
case RoleStructurer:
ag.SetMode(ModePlan)
ag.SetThink(false)
// Structurer only converts text to JSON — no tools needed.
// Structurer reuses the extractor profile so deterministic settings
// stay in one place.
ag.SetToolsDisabled(true)
extProf := o.profileFor(RoleExtractor)
ag.SetTemperature(extProf.Temperature)
if extProf.Format != "" {
ag.SetFormat(extProf.Format)
}
}
// Disable git_commit when auto_commit is off.
if !o.autoCommit {
ag.DisableTools("git_commit")
}
// Filter out EventDone from sub-agents — only the orchestrator emits the final Done.
ag.SetEventHandler(func(e *Event) {
if e.Type == EventDone {
return
}
o.emit(e)
})
ag.SetConfirmFunc(o.confirmFn)
return ag
}
// ragPrefetch performs an automatic RAG search and returns formatted results
// to be injected into the user message. Returns "" if RAG is not configured.
func (o *Orchestrator) ragPrefetch(ctx context.Context, query string) string {
if !o.hasRAG || o.ragIndex == nil {
return ""
}
results, err := o.ragIndex.Search(ctx, query, orchestratorRAGTopK)
if err != nil || len(results) == 0 {
return ""
}
return formatRAGResults(results)
}
// ragPrefetchForReview combines four RAG signals into a single deduplicated block:
// 1. Semantic search by the raw task text.
// 2. Searches by rule names detected from git-changed .go files in workDir
// (deterministic mapping file path → .kodrun/rules/<name>.md).
// 3. Searches by rule names detected from .go file paths mentioned directly
// in the task text.
// 4. Global fan-out: a small search per known rule name from rules.Loader —
// covers horizontal rules (bootstrap, go-effective, styleguide, etc.) that
// don't bind to a file type.
//
// Returns "" if RAG is not configured or nothing was found.
func (o *Orchestrator) ragPrefetchForReview(ctx context.Context, task string) string {
if !o.hasRAG || o.ragIndex == nil {
return ""
}
seen := make(map[string]bool)
var allResults []rag.SearchResult
add := func(results []rag.SearchResult) {
for _, r := range results {
key := fmt.Sprintf("%s:%d", r.Chunk.FilePath, r.Chunk.StartLine)
if seen[key] {
continue
}
seen[key] = true
allResults = append(allResults, r)
}
}
// 1. Semantic search by task text.
if results, err := o.ragIndex.Search(ctx, task, orchestratorRAGTopK); err == nil {
add(results)
}
// 2. Rule names from git-changed files.
changed := gitChangedGoFiles(ctx, o.workDir)
types := entityTypesFromPaths(changed, o.ruleNames)
// 3. Rule names from .go paths mentioned in the task text.
for _, m := range sourceFilePathRe.FindAllStringSubmatch(task, -1) {
if len(m) >= minRegexMatches {
if t := detectEntityTypeFromPath(m[1], o.ruleNames); t != "" && !slices.Contains(types, t) {
types = append(types, t)
}
}
}
for _, t := range types {
if results, err := o.ragIndex.Search(ctx, t, orchestratorRAGTopK); err == nil {
add(results)
}
}
// 4. Global fan-out: every known rule name as a small separate query.
// This catches horizontal rules (bootstrap, go-effective, styleguide,
// project structure) that don't map to a specific file type.
for _, name := range o.ruleNames {
if slices.Contains(types, name) {
continue // already covered above
}
if results, err := o.ragIndex.Search(ctx, name, ragRuleTopK); err == nil {
add(results)
}
}
// 5. Embedded language standards fan-out: search by each embedded
// reference doc name for the detected project language. This ensures
// standards (effective_go, go_common_mistakes, etc.) are reliably
// surfaced even when semantic search on task text misses them.
for _, name := range rag.EmbeddedDocNames(o.progLang()) {
if results, err := o.ragIndex.Search(ctx, name, ragRuleTopK); err == nil {
add(results)
}
}
if len(allResults) == 0 {
return ""
}
return formatRAGResults(allResults)
}
// ragPrefetchBySteps splits a plan into individual steps and performs
// a separate RAG search for each step. This produces more focused queries
// that match specific conventions (e.g., "create validator" finds validator snippets,
// "configure bootstrap" finds bootstrap patterns).
func (o *Orchestrator) ragPrefetchBySteps(ctx context.Context, plan string) string {
if !o.hasRAG || o.ragIndex == nil {
return ""
}
steps := splitPlanSteps(plan)
if len(steps) == 0 {
return o.ragPrefetch(ctx, plan)
}
seen := make(map[string]bool)
var allResults []rag.SearchResult
for _, step := range steps {
results, err := o.ragIndex.Search(ctx, step, perStepRAGTopK)
if err != nil {
continue
}
for _, r := range results {
key := fmt.Sprintf("%s:%d", r.Chunk.FilePath, r.Chunk.StartLine)
if !seen[key] {
seen[key] = true
allResults = append(allResults, r)
}
}
}
// Embedded language standards fan-out (same as ragPrefetchForReview step 5).
for _, name := range rag.EmbeddedDocNames(o.progLang()) {
results, err := o.ragIndex.Search(ctx, name, ragRuleTopK)
if err != nil {
continue
}
for _, r := range results {
key := fmt.Sprintf("%s:%d", r.Chunk.FilePath, r.Chunk.StartLine)
if !seen[key] {
seen[key] = true
allResults = append(allResults, r)
}
}
}
if len(allResults) == 0 {
return ""
}
return formatRAGResults(allResults)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"fmt"
"maps"
"strings"
"github.com/pkg/errors"
)
// RunExecutor executes an already-approved plan without running the planner phase.
// It is intended for callers that obtained a plan outside of the orchestrator
// (e.g. via the standalone agent + classifier path) and want to delegate just
// the execution to the orchestrator's executor sub-agent.
func (o *Orchestrator) RunExecutor(ctx context.Context, plan string, confirmFn ConfirmFunc, autoAccept bool) error {
o.emit(&Event{Type: EventModeChange, Message: "edit"})
stats, err := o.runExecutor(ctx, plan, confirmFn, autoAccept)
if err != nil {
return errors.WithMessage(err, "executor")
}
o.emit(&Event{Type: EventModeChange, Message: "plan"})
o.emit(&Event{Type: EventDone, Message: "Executor completed", Stats: &stats})
return nil
}
func (o *Orchestrator) runExecutor(ctx context.Context, plan string, confirmFn ConfirmFunc, autoAccept bool) (SessionStats, error) {
// Always attempt to structure the plan into a JSON DAG. maxParallelTasks
// controls only the number of concurrent workers, not whether we use the
// DAG path. This ensures every step gets its own clean sub-agent context
// (and examples, RAG, whitelist) regardless of parallelism settings.
structured := o.structurePlan(ctx, plan)
if structured != nil && len(structured.Steps) > 0 {
// TODO: parallel executor support requires proper TUI multiplexing;
// hardcode to 1 until that is implemented.
parallel := 1
o.emit(&Event{
Type: EventAgent,
Message: fmt.Sprintf("Executing plan as DAG: %d steps, max %d parallel", len(structured.Steps), parallel),
})
return o.runPlanDAG(ctx, structured, parallel, confirmFn, autoAccept)
}
// Fallback: structurer unavailable or returned empty plan. Use single
// monolithic executor (no per-step examples in this path).
o.emit(&Event{Type: EventAgent, Message: "Structurer unavailable; falling back to sequential executor"})
// Pre-read project files so executor doesn't waste iterations on read_file/list_dir.
o.emit(&Event{Type: EventGroupStart, Message: "Reading project files..."})
codeContext := o.collectProjectFiles()
o.emit(&Event{Type: EventGroupEnd})
ag := o.newAgent(RoleExecutor, o.maxExecIter)
prompt := systemPromptForRole(RoleExecutor, o.language, o.progLang(), o.ruleCatalog, ag.reg.Names(), o.hasSnippets, o.hasRAG)
ag.InitWithPrompt(prompt)
ag.SetConfirmFunc(confirmFn)
// Derive the read-path whitelist from the plan and lock the executor to
// it. The executor may only read files mentioned in the plan; any attempt
// to read elsewhere is refused with a REPLAN hint.
mdPlan := PlanFromMarkdown(plan)
whitelist := mdPlan.AffectedFiles()
if len(whitelist) > 0 {
ag.SetAllowedReadPaths(whitelist)
o.emit(&Event{
Type: EventAgent,
Message: fmt.Sprintf("Executor read whitelist: %d file(s)", len(whitelist)),
})
}
ltc := langToolsForLang(o.progLang())
verifyHint := ""
if ltc.buildTool != "" {
verifyHint = fmt.Sprintf(" After all changes run %s, %s, %s.", ltc.buildTool, ltc.lintTool, ltc.testTool)
}
execTask := fmt.Sprintf("## Source Code (already read — do NOT call read_file or list_dir)\n%s\n## Approved Plan\n\n%s\n\n---\nImplement each step by calling edit_file/write_file.%s", codeContext, plan, verifyHint)
if ragContext := o.ragPrefetchBySteps(ctx, plan); ragContext != "" {
execTask = ragContext + "\n" + execTask
}
err := ag.Send(ctx, execTask)
if err != nil && !errors.Is(err, ErrMaxIterations) {
return ag.Stats(), err
}
// Detect REPLAN sentinel from the executor.
if last := ag.LastPlan(); strings.Contains(last, "REPLAN:") {
o.emit(&Event{
Type: EventReplan,
Message: extractReplanReason(last),
})
}
return ag.Stats(), nil
}
// verifyOnce runs a single verification pass with optional timeout. Returns
// the fix plan (empty if all steps verified) or an error.
func (o *Orchestrator) verifyOnce(ctx context.Context, plan string, execStats *SessionStats) (string, error) {
verifyCtx := ctx
if o.specialistTimeout > 0 {
var cancel context.CancelFunc
verifyCtx, cancel = context.WithTimeout(ctx, o.specialistTimeout)
defer cancel()
}
fixPlan, err := o.runVerifier(verifyCtx, plan, execStats)
if err != nil {
if verifyCtx.Err() != nil {
o.emit(&Event{Type: EventAgent, Message: "⚠ Verification timed out — skipping."})
return "", nil
}
return "", errors.WithMessage(err, "verifier")
}
return fixPlan, nil
}
func (o *Orchestrator) runVerifier(ctx context.Context, plan string, execStats *SessionStats) (string, error) {
ag := o.newAgent(RoleVerifier, o.maxRevIter)
ltc := langToolsForLang(o.progLang())
verifyTools := make(map[string]bool)
if ltc.buildTool != "" {
verifyTools[ltc.buildTool] = true
}
if ltc.lintTool != "" {
verifyTools[ltc.lintTool] = true
}
if len(verifyTools) > 0 {
ag.AddReadOnlyTools(verifyTools)
}
roTools := ag.readOnlyTools()
mergedNames := make(map[string]bool, len(roTools)+len(verifyTools))
maps.Copy(mergedNames, roTools)
maps.Copy(mergedNames, verifyTools)
toolNames := ag.reg.NamesFiltered(mergedNames)
prompt := systemPromptForRole(RoleVerifier, o.language, o.progLang(), o.ruleCatalog, toolNames, o.hasSnippets, o.hasRAG)
ag.InitWithPrompt(prompt)
// Pre-read affected files so the verifier does not waste iterations on
// read_file calls. The code is injected into the task prompt, same
// approach as the executor uses for step context.
o.emit(&Event{Type: EventGroupStart, Message: "Reading files for verification..."})
codeContext := o.collectProjectFiles()
o.emit(&Event{Type: EventGroupEnd})
var taskBuf strings.Builder
if codeContext != "" {
fmt.Fprintf(&taskBuf, "## Source Code (already read — do NOT call read_file or list_dir)\n%s\n\n", codeContext)
}
if o.lastPlannerTask != "" {
fmt.Fprintf(&taskBuf, "## Original Task\n\n%s\n\n", o.lastPlannerTask)
}
fmt.Fprintf(&taskBuf, "## Approved Plan\n\n%s\n\n", plan)
fmt.Fprintf(&taskBuf,
"## Execution Stats\n\n%d files added, %d modified, %d deleted, +%d -%d lines, %d tool calls.\n\n",
execStats.FilesAdded, execStats.FilesModified, execStats.FilesDeleted,
execStats.LinesAdded, execStats.LinesRemoved, execStats.ToolCalls,
)
taskBuf.WriteString("Verify that every step in the approved plan was implemented correctly. The source code is provided above — do NOT call read_file. If all steps are done, respond with VERIFIED. Otherwise, list only the incomplete or incorrect items as a numbered plan.")
verifyTask := taskBuf.String()
if ragContext := o.ragPrefetchForReview(ctx, plan); ragContext != "" {
verifyTask = ragContext + "\n" + verifyTask
}
if err := ag.Send(ctx, verifyTask); err != nil && !errors.Is(err, ErrMaxIterations) {
return "", err
}
// Check if verifier confirmed all steps.
lastPlan := ag.LastPlan()
if lastPlan == "" || strings.Contains(strings.ToUpper(lastPlan), "VERIFIED") {
return "", nil
}
// Normalize incomplete items through extractor for re-execution.
return o.extractPlan(ctx, lastPlan)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"fmt"
"regexp"
"sort"
"strconv"
"strings"
"time"
)
// reviewResult holds the outcome of a single specialist reviewer sub-agent.
type reviewResult struct {
role Role
text string
err error
duration time.Duration
stats SessionStats
toolCalls int
}
// specialistFinding is a parsed finding from a specialist's output.
// Supports both single-line (`path:LINE — SEVERITY — body`) and multi-line
// block format (header + WHAT/WHY/FIX/BEFORE/AFTER/RULES fields).
type specialistFinding struct {
file string
line int
severity string
body string // WHAT: description of the problem
why string // WHY: rationale for fixing
fix string // FIX: concrete suggestion
before string // BEFORE: existing code snippet
after string // AFTER: corrected code snippet
ruleNames []string // RULES: referenced rule names
roles []Role // specialists that reported this finding (for dedup)
examples []Example // EXAMPLE: continuation lines parsed from reviewer output
}
// severityRank orders findings: blocker first, then major, then minor.
func severityRank(s string) int {
switch strings.ToLower(s) {
case "blocker":
return 0
case "major":
return 1
case "minor":
return severityMinor
}
return severityUnknown
}
// specialistFindingRe captures file, line, severity and body from a single
// finding line. Accepts em-dash, en-dash or hyphen as separators and
// tolerates optional leading list markers.
var specialistFindingRe = regexp.MustCompile(
`(?i)^[\s\-*>0-9.)]*\**\s*([\w./\\-]+?):(\d+)\**\s*[—–\-:]+\s*\**(blocker|major|minor)\**\s*[—–\-:]+\s*(.+?)\**$`,
)
// specialistFindingHeaderRe captures the header of a multi-line finding block:
// path:LINE — SEVERITY (no body on the same line).
var specialistFindingHeaderRe = regexp.MustCompile(
`(?i)^[\s\-*>0-9.)]*\**\s*([\w./\\-]+?):(\d+)\**\s*[—–\-:]+\s*\**(blocker|major|minor)\**\s*$`,
)
// findingFieldRe captures structured fields (WHAT/WHY/FIX/BEFORE/AFTER/RULES)
// from continuation lines of a multi-line finding block.
var findingFieldRe = regexp.MustCompile(
`(?i)^\s*\**\s*(WHAT|WHY|FIX|BEFORE|AFTER|RULES)\s*:\s*\**\s*(.+)$`,
)
// exampleLineRe captures EXAMPLE: continuation lines from specialist output.
// Format: EXAMPLE: path/to/file.go:LINE — reason
var exampleLineRe = regexp.MustCompile(
`(?i)^\s*EXAMPLE:\s*([\w./\\-]+?):(\d+)\s*[—–\-]\s*(.+)$`,
)
// splitBodyAndFix splits a legacy single-line body that contains both
// description and fix suggestion separated by "— FIX:".
func splitBodyAndFix(body string) (description, fix string) {
upper := strings.ToUpper(body)
for _, sep := range []string{" — FIX: ", " – FIX: ", " - FIX: "} {
idx := strings.Index(upper, sep)
if idx >= 0 {
return strings.TrimSpace(body[:idx]), strings.TrimSpace(body[idx+len(sep):])
}
}
return body, ""
}
// parseSpecialistFindings extracts findings from a specialist's raw output.
// Supports both multi-line block format (header + WHAT/WHY/FIX/... fields)
// and legacy single-line format. Non-matching lines are ignored. EXAMPLE:
// continuation lines are attached to the immediately preceding finding.
func parseSpecialistFindings(text string, role Role) []specialistFinding {
lines := strings.Split(text, "\n")
out := make([]specialistFinding, 0, len(lines))
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" {
continue
}
// Try EXAMPLE: continuation line first — attach to last finding.
if em := exampleLineRe.FindStringSubmatch(line); em != nil {
if len(out) > 0 {
exLine, atoiErr := strconv.Atoi(em[2])
if atoiErr != nil {
continue
}
out[len(out)-1].examples = append(out[len(out)-1].examples, Example{
File: em[1],
Line: exLine,
Note: strings.TrimSpace(em[3]),
})
}
continue
}
// Try structured field continuation (WHAT/WHY/FIX/BEFORE/AFTER/RULES).
if fm := findingFieldRe.FindStringSubmatch(line); fm != nil {
if len(out) > 0 {
last := &out[len(out)-1]
val := strings.TrimSpace(fm[2])
switch strings.ToUpper(fm[1]) {
case "WHAT":
last.body = val
case "WHY":
last.why = val
case "FIX":
last.fix = val
case "BEFORE":
last.before = val
case "AFTER":
last.after = val
case "RULES":
last.ruleNames = parseRuleNames(val)
}
}
continue
}
// Try multi-line header: file:LINE — SEVERITY (no body).
if hm := specialistFindingHeaderRe.FindStringSubmatch(line); hm != nil {
lineNo, err := strconv.Atoi(hm[2])
if err != nil {
continue
}
out = append(out, specialistFinding{
file: hm[1],
line: lineNo,
severity: strings.ToLower(hm[3]),
roles: []Role{role},
})
continue
}
// Fallback: legacy single-line format.
m := specialistFindingRe.FindStringSubmatch(line)
if m == nil {
continue
}
lineNo, err := strconv.Atoi(m[2])
if err != nil {
continue
}
desc, fixPart := splitBodyAndFix(strings.TrimSpace(m[4]))
out = append(out, specialistFinding{
file: m[1],
line: lineNo,
severity: strings.ToLower(m[3]),
body: desc,
fix: fixPart,
roles: []Role{role},
})
}
return out
}
// parseRuleNames splits a comma-separated list of rule names.
func parseRuleNames(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" {
out = append(out, p)
}
}
return out
}
// mergeField keeps the longer non-empty value.
func mergeField(dst *string, src string) {
if src == "" {
return
}
if *dst == "" || len(src) > len(*dst) {
*dst = src
}
}
// mergeRuleNames returns the union of two rule name slices.
func mergeRuleNames(dst, src []string) []string {
for _, s := range src {
found := false
for _, d := range dst {
if d == s {
found = true
break
}
}
if !found {
dst = append(dst, s)
}
}
return dst
}
// mergeExamples appends examples from src into dst, deduplicating by file+line.
func mergeExamples(dst, src []Example) []Example {
for _, s := range src {
found := false
for _, d := range dst {
if d.File == s.File && d.Line == s.Line {
found = true
break
}
}
if !found {
dst = append(dst, s)
}
}
return dst
}
// normalizeBody returns a key for deduplication: lowercase, trimmed, trailing
// punctuation stripped.
func normalizeBody(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.TrimRight(s, ".;,!?…")
return s
}
// isNoIssues reports whether text is a specialist "all clear" response.
// Empty text is NOT treated as "no issues" — it indicates a silent failure.
func isNoIssues(text string) bool {
return strings.EqualFold(text, "LGTM") ||
strings.EqualFold(text, "NO_ISSUES")
}
// mergeSpecialistFindings collects findings from all specialist reviewers,
// deduplicates (same file + same description → merge roles and group lines),
// sorts by severity then file, and renders as a markdown plan. Returns "" if
// no strict lines were parsed (caller should fall back to raw concatenation).
func mergeSpecialistFindings(results []reviewResult, lang string) string {
var all []specialistFinding
var unparsed []specialistFinding
for i := range results {
if results[i].err != nil {
continue
}
parsed := parseSpecialistFindings(results[i].text, results[i].role)
if len(parsed) > 0 {
all = append(all, parsed...)
continue
}
txt := strings.TrimSpace(results[i].text)
if txt == "" || isNoIssues(txt) {
continue
}
for _, line := range strings.Split(results[i].text, "\n") {
line = strings.TrimSpace(strings.TrimLeft(line, "-*>0123456789.) "))
if line == "" || isNoIssues(line) {
continue
}
unparsed = append(unparsed, specialistFinding{
file: "(unstructured)",
severity: "minor",
body: line,
roles: []Role{results[i].role},
})
}
}
all = append(all, unparsed...)
if len(all) == 0 {
return ""
}
// --- Dedup phase B: merge same file:line:body from different specialists ---
type dedupKey struct {
file string
line int
body string
}
byExact := make(map[dedupKey]*specialistFinding)
var deduped []specialistFinding
for i := range all {
f := &all[i]
key := dedupKey{file: f.file, line: f.line, body: normalizeBody(f.body)}
if existing, ok := byExact[key]; ok {
// Merge roles, keep highest severity.
for _, r := range f.roles {
found := false
for _, er := range existing.roles {
if er == r {
found = true
break
}
}
if !found {
existing.roles = append(existing.roles, r)
}
}
if severityRank(f.severity) < severityRank(existing.severity) {
existing.severity = f.severity
}
// Merge structured fields: keep longer/non-empty.
mergeField(&existing.why, f.why)
mergeField(&existing.fix, f.fix)
mergeField(&existing.before, f.before)
mergeField(&existing.after, f.after)
existing.ruleNames = mergeRuleNames(existing.ruleNames, f.ruleNames)
// Merge examples from duplicate findings.
existing.examples = mergeExamples(existing.examples, f.examples)
} else {
clone := *f
byExact[key] = &clone
deduped = append(deduped, clone)
}
}
// Update deduped entries from map (roles may have been extended).
for i := range deduped {
key := dedupKey{file: deduped[i].file, line: deduped[i].line, body: normalizeBody(deduped[i].body)}
if updated, ok := byExact[key]; ok {
deduped[i] = *updated
}
}
// --- Dedup phase A: group same file:body across different lines ---
type groupKey struct {
file string
sev string
body string
}
type lineGroup struct {
finding specialistFinding
lines []int
}
byGroup := make(map[groupKey]*lineGroup)
var groupOrder []groupKey
for idx := range deduped {
f := &deduped[idx]
key := groupKey{file: f.file, sev: f.severity, body: normalizeBody(f.body)}
if g, ok := byGroup[key]; ok {
g.lines = append(g.lines, f.line)
// Merge roles.
for _, r := range f.roles {
found := false
for _, er := range g.finding.roles {
if er == r {
found = true
break
}
}
if !found {
g.finding.roles = append(g.finding.roles, r)
}
}
// Merge structured fields.
mergeField(&g.finding.why, f.why)
mergeField(&g.finding.fix, f.fix)
mergeField(&g.finding.before, f.before)
mergeField(&g.finding.after, f.after)
g.finding.ruleNames = mergeRuleNames(g.finding.ruleNames, f.ruleNames)
// Merge examples.
g.finding.examples = mergeExamples(g.finding.examples, f.examples)
} else {
g := &lineGroup{finding: *f, lines: []int{f.line}}
byGroup[key] = g
groupOrder = append(groupOrder, key)
}
}
// Build final grouped findings.
grouped := make([]specialistFinding, 0, len(groupOrder))
for _, key := range groupOrder {
g := byGroup[key]
f := g.finding
f.line = g.lines[0] // first line for sorting
// Store all lines comma-separated in a helper field embedded in body.
if len(g.lines) > 1 {
sort.Ints(g.lines)
lineStrs := make([]string, len(g.lines))
for i, l := range g.lines {
lineStrs[i] = strconv.Itoa(l)
}
f.body = f.body + planLabel(lang, "lines") + strings.Join(lineStrs, ", ") + ")"
}
grouped = append(grouped, f)
}
// Sort: severity, then file, then first line.
sort.SliceStable(grouped, func(i, j int) bool {
if si, sj := severityRank(grouped[i].severity), severityRank(grouped[j].severity); si != sj {
return si < sj
}
if grouped[i].file != grouped[j].file {
return grouped[i].file < grouped[j].file
}
return grouped[i].line < grouped[j].line
})
var b strings.Builder
// --- Section 1: Tasks ---
b.WriteString(planLabel(lang, "tasks2"))
for i := range grouped {
f := &grouped[i]
var roleStrs []string
for _, r := range f.roles {
roleStrs = append(roleStrs, string(r))
}
roles := strings.Join(roleStrs, ", ")
fmt.Fprintf(&b, "### %d. %s:%d [%s]\n\n", i+1, f.file, f.line, f.severity)
if f.body != "" {
fmt.Fprintf(&b, "- **What:** %s\n", f.body)
}
if f.why != "" {
fmt.Fprintf(&b, "- **Why:** %s\n", f.why)
}
if f.fix != "" {
fmt.Fprintf(&b, "- **Fix:** %s\n", f.fix)
}
if f.before != "" {
fmt.Fprintf(&b, "- **Before:** %s\n", f.before)
}
if f.after != "" {
fmt.Fprintf(&b, "- **After:** %s\n", f.after)
}
if len(f.ruleNames) > 0 {
fmt.Fprintf(&b, "- **Rules:** %s\n", strings.Join(f.ruleNames, ", "))
}
for _, ex := range f.examples {
fmt.Fprintf(&b, "- **Example:** %s:%d — %s\n", ex.File, ex.Line, ex.Note)
}
fmt.Fprintf(&b, "- *Reviewers: %s*\n\n", roles)
}
// --- Section 2: Affected files ---
affectedFiles := collectAffectedFiles(grouped)
if len(affectedFiles) > 0 {
b.WriteString(planLabel(lang, "affected2"))
for _, af := range affectedFiles {
fmt.Fprintf(&b, "- %s\n", af)
}
b.WriteByte('\n')
}
// --- Section 3: Verification ---
b.WriteString(planLabel(lang, "verification2"))
fmt.Fprintf(&b, "- [ ] %s (`make build`)\n", planLabel(lang, "build"))
fmt.Fprintf(&b, "- [ ] %s (`make lint`)\n", planLabel(lang, "lint"))
fmt.Fprintf(&b, "- [ ] %s (`make test-unit`)\n", planLabel(lang, "test"))
return strings.TrimRight(b.String(), "\n")
}
// collectAffectedFiles extracts unique file paths from grouped findings,
// sorted alphabetically. Skips "(unstructured)" placeholder entries.
func collectAffectedFiles(findings []specialistFinding) []string {
seen := make(map[string]struct{})
for i := range findings {
f := findings[i].file
if f == "" || f == "(unstructured)" {
continue
}
seen[f] = struct{}{}
}
out := make([]string, 0, len(seen))
for f := range seen {
out = append(out, f)
}
sort.Strings(out)
return out
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"path/filepath"
"regexp"
"strings"
"github.com/raoptimus/kodrun/internal/rules"
)
// stepSplitRe matches the leading numeric marker of a structured plan step
// like "1. ", "2) ". Used by splitPlanSteps to break a plan into per-step
// substrings for fan-out RAG queries.
var stepSplitRe = regexp.MustCompile(`(?m)^\d+[.\)]\s`)
// splitPlanSteps splits a structured plan into individual steps. Plans are
// typically numbered lists (1. ... 2. ...) or paragraph-separated blocks; the
// numbered form is preferred and the paragraph fallback covers free-form text.
func splitPlanSteps(plan string) []string {
indices := stepSplitRe.FindAllStringIndex(plan, -1)
if len(indices) >= minRegexMatches {
steps := make([]string, 0, len(indices))
for i, idx := range indices {
var end int
if i+1 < len(indices) {
end = indices[i+1][0]
} else {
end = len(plan)
}
step := strings.TrimSpace(plan[idx[0]:end])
if len(step) >= minStepTextLen {
steps = append(steps, step)
}
}
if len(steps) >= minRegexMatches {
return steps
}
}
paragraphs := strings.Split(plan, "\n\n")
var steps []string
for _, p := range paragraphs {
p = strings.TrimSpace(p)
if len(p) >= minStepTextLen {
steps = append(steps, p)
}
}
return steps
}
// truncateTask shortens s to maxLen runes, appending "..." when truncated.
// Used when emitting human-readable phase labels for long task texts.
func truncateTask(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen] + "..."
}
// isConnectionError returns true when the error indicates that the LLM backend
// (ollama) is unreachable — connection refused, DNS failure, or dial timeout.
func isConnectionError(err error) bool {
if err == nil {
return false
}
msg := err.Error()
return strings.Contains(msg, "connection refused") ||
strings.Contains(msg, "no such host") ||
strings.Contains(msg, "dial tcp")
}
// extractReplanReason pulls the reason text from a REPLAN sentinel emitted by
// the executor (line of the form "REPLAN: <reason>"). Returns the original
// text when no sentinel is present.
func extractReplanReason(text string) string {
idx := strings.Index(text, "REPLAN:")
if idx < 0 {
return text
}
tail := text[idx+len("REPLAN:"):]
if nl := strings.IndexByte(tail, '\n'); nl >= 0 {
tail = tail[:nl]
}
return strings.TrimSpace(tail)
}
// collectRuleNames extracts all rule names (file basename without .md) from
// the given rules.Loader. Returns nil if the loader is nil or empty.
func collectRuleNames(l *rules.Loader) []string {
if l == nil {
return nil
}
all := l.AllRules()
if len(all) == 0 {
return nil
}
out := make([]string, 0, len(all))
for _, r := range all {
name := strings.TrimSuffix(filepath.Base(r.Path), ".md")
if name != "" {
out = append(out, name)
}
}
return out
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"regexp"
"strings"
"time"
"github.com/pkg/errors"
)
// runPlanner runs the planning phase: invokes the planner sub-agent,
// optionally normalises review output via the extractor, and surfaces
// quality / path validation warnings.
func (o *Orchestrator) runPlanner(ctx context.Context, task string) (string, error) {
var plan string
var err error
if o.prefetchCode {
plan, err = o.runPlannerPrefetch(ctx, task)
} else {
plan, err = o.runPlannerWithTools(ctx, task)
}
if err != nil {
return "", err
}
// The extractor normalizes review/analysis output into structured JSON
// findings (severity, what, fix). For implementation tasks the planner
// output is already an actionable plan; the structurer (called later by
// runExecutor) handles JSON conversion and file grouping.
if o.review {
plan, err = o.extractPlan(ctx, plan)
if err != nil {
return "", err
}
}
// Log quality issues as warnings (informational only, not a gate).
if issues := validatePlanQuality(plan); len(issues) > 0 {
o.emit(&Event{Type: EventAgent, Message: "Plan quality notes: " + strings.Join(issues, "; ")})
}
// Validate that the plan references real files.
if invalid := o.validatePlanPaths(plan); len(invalid) > 0 {
o.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("Warning: plan references %d non-existent file(s): %s", len(invalid), strings.Join(invalid, ", "))})
}
return plan, nil
}
// extractPlan runs the extractor agent to normalize raw analysis into a structured plan.
// Always called — this is the second phase of the two-phase planner→extractor architecture.
func (o *Orchestrator) extractPlan(ctx context.Context, rawPlan string) (string, error) {
o.emit(&Event{Type: EventGroupStart, Message: "Extract(plan)"})
defer o.emit(&Event{Type: EventGroupEnd})
extracted, err := o.runExtractor(ctx, rawPlan)
if err != nil {
return rawPlan, errors.WithMessage(err, "extractor")
}
if extracted == "" {
return rawPlan, nil
}
// Local models often return JSON even when the prompt asks for markdown
// (e.g. when format=json is forced at the API level). Re-render to a
// readable form so the user does not see raw JSON.
return RenderExtractorOutput(extracted, o.language), nil
}
// runPlannerPrefetch reads all project files programmatically and injects them into the prompt.
func (o *Orchestrator) runPlannerPrefetch(ctx context.Context, task string) (string, error) {
codeContext := o.collectProjectFiles()
ag := o.newAgent(RolePlanner, o.maxPlanIter)
prompt := systemPromptForRole(RolePlanner, o.language, o.progLang(), o.ruleCatalog, ag.reg.NamesFiltered(ag.readOnlyTools()), o.hasSnippets, o.hasRAG)
ag.InitWithPrompt(prompt)
ragContext := o.ragPrefetch(ctx, task)
userMsg := fmt.Sprintf("[Source code of the project — already read for you]\n%s\n[Task]\n%s", codeContext, task)
if ragContext != "" {
userMsg = ragContext + "\n" + userMsg
}
o.lastPlannerTask = userMsg
if err := ag.Send(ctx, userMsg); err != nil && !errors.Is(err, ErrMaxIterations) {
return "", err
}
plan := ag.LastPlan()
if plan == "" {
return "", errors.New("planner produced empty plan")
}
return plan, nil
}
// runPlannerWithTools runs the planner relying on the model to call read-only tools.
func (o *Orchestrator) runPlannerWithTools(ctx context.Context, task string) (string, error) {
plan, toolCalls, err := o.runPlannerOnce(ctx, task)
if err != nil {
return "", err
}
// If planner didn't use any tools, retry once with a stronger hint.
if toolCalls == 0 && plan == "" {
o.emit(&Event{Type: EventAgent, Message: "Planner did not read any files. Retrying with reinforced prompt..."})
reinforced := "IMPORTANT: You MUST call list_dir(\".\") first, then read_file on each .go file. Do NOT generate a plan without reading files.\n\n" + task
plan, _, err = o.runPlannerOnce(ctx, reinforced)
if err != nil {
return "", err
}
}
if toolCalls == 0 && plan != "" {
o.emit(&Event{Type: EventAgent, Message: "Warning: planner did not read source files — plan may contain hallucinations"})
}
return plan, nil
}
// collectProjectFiles reads all .go files and go.mod from the project directory.
// Results are cached for the duration of the orchestrator run.
func (o *Orchestrator) collectProjectFiles() string {
if o.cachedProjectFiles != "" {
return o.cachedProjectFiles
}
result := o.doCollectProjectFiles()
o.cachedProjectFiles = result
return result
}
func (o *Orchestrator) doCollectProjectFiles() string {
var buf strings.Builder
var files []string
if err := filepath.WalkDir(o.workDir, func(path string, d os.DirEntry, walkErr error) error {
if walkErr != nil {
return filepath.SkipDir
}
if d.IsDir() {
name := d.Name()
if strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules" || name == "bin" {
return filepath.SkipDir
}
return nil
}
rel, relErr := filepath.Rel(o.workDir, path)
if relErr != nil {
return filepath.SkipDir
}
if strings.HasSuffix(rel, ".go") || rel == "go.mod" {
files = append(files, rel)
}
return nil
}); err != nil {
return ""
}
for _, rel := range files {
data, err := os.ReadFile(filepath.Join(o.workDir, rel))
if err != nil {
continue
}
o.emit(&Event{Type: EventTool, Tool: "read_file", Message: rel, Success: true})
lines := strings.Split(string(data), "\n")
fmt.Fprintf(&buf, "=== %s ===\n", rel)
for i, line := range lines {
fmt.Fprintf(&buf, "%d\t%s\n", i+1, line)
}
buf.WriteByte('\n')
}
return buf.String()
}
func (o *Orchestrator) runPlannerOnce(ctx context.Context, task string) (plan string, toolCalls int, err error) {
ag := o.newAgent(RolePlanner, o.maxPlanIter)
prompt := systemPromptForRole(RolePlanner, o.language, o.progLang(), o.ruleCatalog, ag.reg.NamesFiltered(ag.readOnlyTools()), o.hasSnippets, o.hasRAG)
ag.InitWithPrompt(prompt)
enrichedTask := task
if ragContext := o.ragPrefetchForReview(ctx, task); ragContext != "" {
enrichedTask = ragContext + "\n" + task
}
// When the task mentions specific file paths, tell the planner to read
// them directly instead of scanning the entire project tree.
if hint := buildFileHint(enrichedTask); hint != "" {
enrichedTask = hint + "\n" + enrichedTask
}
o.lastPlannerTask = enrichedTask
if err := ag.Send(ctx, enrichedTask); err != nil && !errors.Is(err, ErrMaxIterations) {
return "", 0, err
}
return ag.LastPlan(), ag.Stats().ToolCalls, nil
}
// buildFileHint extracts source file paths mentioned in the task and returns
// an instruction telling the planner to read those files first. Returns ""
// if no paths are found.
func buildFileHint(task string) string {
matches := sourceFilePathRe.FindAllStringSubmatch(task, -1)
if len(matches) == 0 {
return ""
}
seen := make(map[string]bool)
files := make([]string, 0, len(matches))
for _, m := range matches {
p := m[1]
if seen[p] {
continue
}
seen[p] = true
files = append(files, p)
}
if len(files) == 0 {
return ""
}
return fmt.Sprintf(
"MANDATORY: The task references specific file(s): %s\n"+
"Your FIRST tool call(s) MUST be read_file for these files. Do NOT call list_dir or find_files before reading them.\n"+
"After reading these files, only read additional files that are directly imported or referenced by the code you found.",
strings.Join(files, ", "),
)
}
// validatePlanPaths extracts source file paths from the plan and checks they exist.
// Returns list of non-existent paths.
var sourceFilePathRe = regexp.MustCompile(`\b([\w./-]+\.(?:go|py|ts|tsx|js|jsx))(?::\d+)?`)
func (o *Orchestrator) validatePlanPaths(plan string) []string {
matches := sourceFilePathRe.FindAllStringSubmatch(plan, -1)
if len(matches) == 0 {
return nil
}
seen := make(map[string]bool)
var invalid []string
for _, m := range matches {
p := m[1]
if seen[p] {
continue
}
seen[p] = true
abs := p
if !filepath.IsAbs(abs) {
abs = filepath.Join(o.workDir, abs)
}
if _, err := os.Stat(abs); err != nil {
invalid = append(invalid, p)
}
}
return invalid
}
// validatePlanQuality checks if the plan has concrete findings vs vague suggestions.
func validatePlanQuality(plan string) []string {
var issues []string
// Check for placeholder line numbers like ":line" or ":line)"
if strings.Contains(plan, ":line") {
issues = append(issues, "contains placeholder ':line' instead of real line numbers")
}
// Vague phrases that indicate observations rather than concrete action items.
// These phrases suggest "something should change" without specifying HOW.
vaguePhrases := []string{
// Russian vague patterns
"Проверить", "Убедиться", "проверить", "убедиться",
"должна быть", "должно быть", "должен быть",
"может быть заменен", "может быть замене",
"можно добавить", "можно улучшить", "можно заменить",
"следует добавить", "следует улучшить", "следует заменить",
"необходимо добавить", "необходимо улучшить",
"более строг", "более корректн", "более безопасн",
"не нужна", "не нужен", "не нужно",
"должно содержать", "должна содержать",
"рекомендуется", "желательно",
"стоит рассмотреть", "стоит добавить",
// English vague patterns
"Check ", "Verify ", "Ensure ",
"check if", "verify that", "ensure that",
"should be", "could be", "might be",
"consider ", "recommended", "advisable",
"more strict", "more robust", "more secure",
"is not needed", "is unnecessary",
"should contain", "should include",
}
// Count vague lines among ALL actionable lines (numbered + bulleted).
lines := strings.Split(plan, "\n")
var actionLines, vagueLines int
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if len(trimmed) < minStructuredSteps {
continue
}
// Detect actionable lines: numbered (1. / 1)), bulleted (- ), or bold (**).
isAction := false
if trimmed[0] >= '0' && trimmed[0] <= '9' {
isAction = true
} else if strings.HasPrefix(trimmed, "- ") || strings.HasPrefix(trimmed, "* ") {
isAction = true
}
if !isAction {
continue
}
actionLines++
for _, phrase := range vaguePhrases {
if strings.Contains(trimmed, phrase) {
vagueLines++
break
}
}
}
if actionLines > 0 && vagueLines*2 > actionLines {
issues = append(issues, fmt.Sprintf("%d of %d steps are vague suggestions instead of concrete action items", vagueLines, actionLines))
}
return issues
}
func (o *Orchestrator) runPlannerRevision(ctx context.Context, plan, feedback string) (string, error) {
ag := o.newAgent(RolePlanner, o.maxPlanIter)
prompt := systemPromptForRole(RolePlanner, o.language, o.progLang(), o.ruleCatalog, ag.reg.NamesFiltered(ag.readOnlyTools()), o.hasSnippets, o.hasRAG)
ag.InitWithPrompt(prompt)
var task string
if o.lastPlannerTask != "" {
task = fmt.Sprintf(
"Revise the following plan based on user feedback.\n\n"+
"Original task:\n%s\n\n"+
"Current plan:\n%s\n\n"+
"User feedback:\n%s\n\n"+
"Provide an updated plan that addresses the feedback while staying true to the original task.",
o.lastPlannerTask, plan, feedback,
)
} else {
task = fmt.Sprintf("Revise the following plan based on user feedback.\n\nCurrent plan:\n%s\n\nUser feedback:\n%s\n\nProvide an updated plan.", plan, feedback)
}
if ragContext := o.ragPrefetch(ctx, feedback); ragContext != "" {
task = ragContext + "\n" + task
}
if err := ag.Send(ctx, task); err != nil && !errors.Is(err, ErrMaxIterations) {
return "", err
}
revised := ag.LastPlan()
if revised == "" {
return "", nil
}
// Two-phase: normalize revised plan through extractor.
return o.extractPlan(ctx, revised)
}
// structurePlan converts a markdown plan into a strict JSON Plan{Steps[]} via
// the structurer sub-agent. Returns nil on parse failure so callers can
// gracefully fall back to PlanFromMarkdown.
//
// The structurer always runs with format=json and temperature=0 (same profile
// as the extractor) to maximise the chance of valid JSON from local models.
func (o *Orchestrator) structurePlan(ctx context.Context, markdownPlan string) *Plan {
if markdownPlan == "" {
return nil
}
task := "Convert the following plan into the JSON schema described in your instructions:\n\n" + markdownPlan
// Retry once on transient errors (e.g. Ollama context eviction after
// heavy specialist work).
const maxAttempts = 2
for attempt := range maxAttempts {
if ctx.Err() != nil {
return nil
}
ag := o.newAgent(RoleStructurer, structurerMaxIter)
prompt := systemPromptForRole(RoleStructurer, o.language, o.progLang(), o.ruleCatalog, nil)
ag.InitWithPrompt(prompt)
if err := ag.Send(ctx, task); err != nil && !errors.Is(err, ErrMaxIterations) {
o.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("structurer error (attempt %d/%d): %s", attempt+1, maxAttempts, err.Error())})
if attempt < maxAttempts-1 {
time.Sleep(time.Second)
}
continue
}
raw := strings.TrimSpace(ag.LastAssistantMessage())
if raw == "" {
continue
}
plan, err := parseStructuredPlan(raw)
if err != nil {
o.emit(&Event{Type: EventAgent, Message: "structurer JSON parse failed: " + err.Error()})
continue
}
plan.Raw = markdownPlan
return plan
}
return nil
}
// runExtractor takes raw analysis/review text and converts it to a structured plan
// using a separate agent with its own context. This solves the problem of models
// producing "thinking" text instead of actionable plans.
func (o *Orchestrator) runExtractor(ctx context.Context, rawAnalysis string) (string, error) {
ag := o.newAgent(RoleExtractor, extractorMaxIter)
prompt := systemPromptForRole(RoleExtractor, o.language, o.progLang(), o.ruleCatalog, nil)
ag.InitWithPrompt(prompt)
task := fmt.Sprintf("Extract a clear, actionable plan from the following analysis:\n\n%s", rawAnalysis)
if err := ag.Send(ctx, task); err != nil && !errors.Is(err, ErrMaxIterations) {
return "", err
}
plan := ag.LastPlan()
if plan == "" {
// Extractor may produce LGTM or structured text without plan markers.
return strings.TrimSpace(rawAnalysis), nil
}
return plan, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"fmt"
"os"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/snippets"
"github.com/raoptimus/kodrun/internal/tools"
)
const (
// depSignaturesMaxBytes caps the dependency signatures block per file
// to avoid blowing the model context window.
depSignaturesMaxBytes = 32_000 // ~8k tokens
// reviewRAGTopK is the number of RAG results per file query.
reviewRAGTopK = 3
)
// RunCodeReview implements the orchestrator-driven code review pipeline.
// Pre-loads all context (file contents, RAG snippets, dependency signatures)
// and sends a simple analysis prompt — no tool-calling required.
func (o *Orchestrator) RunCodeReview(ctx context.Context, files []string, allSnippets []snippets.Snippet) error {
if len(files) == 0 {
o.emit(&Event{Type: EventAgent, Message: "No source files to review."})
o.emit(&Event{Type: EventDone, Message: "Code review completed"})
return nil
}
o.ensureLanguageDetected()
reviewStart := time.Now()
cache := tools.NewResultCache()
o.reg.WithCache(cache)
defer func() {
o.reg.WithCache(nil)
o.emitCacheStats(cache)
}()
o.emitPhase("reviewing")
// Phase 0: Preparation.
if o.verbose {
o.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("▸ Preparing context for %d files...", len(files))})
}
modulePath := readModulePath(o.workDir)
depStructures := prefetchPackageStructures(ctx, o.reg, o.workDir, files, modulePath)
ragMap := buildPerFileRAGMap(ctx, o.ragIndex, files, reviewRAGTopK, o.progLang())
reviewCache := NewReviewCache(o.workDir)
if o.verbose {
o.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("▸ Prefetched %d package structures, %d RAG blocks", len(depStructures), len(ragMap))})
}
// Phase 1: Per-file review (parallel).
const reviewGroupID = "code-review"
o.emit(&Event{Type: EventGroupStart, GroupID: reviewGroupID, Message: "CodeReview(per-file)"})
maxPar := o.maxParallelTasks
if maxPar < 1 {
maxPar = 1
}
results := make([]reviewResult, len(files))
sem := make(chan struct{}, maxPar)
var wg sync.WaitGroup
reviewCtx, reviewCancel := context.WithCancel(ctx)
defer reviewCancel()
for i, f := range files {
if reviewCtx.Err() != nil {
break
}
wg.Add(1)
sem <- struct{}{}
go func(i int, filePath string) {
defer wg.Done()
defer func() { <-sem }()
o.emit(&Event{
Type: EventGroupTitleUpdate,
GroupID: reviewGroupID,
Message: fmt.Sprintf("Reviewing(%s) [%d/%d]", filePath, i+1, len(files)),
})
fileCtx := reviewCtx
if o.specialistTimeout > 0 {
var cancel context.CancelFunc
fileCtx, cancel = context.WithTimeout(reviewCtx, o.specialistTimeout)
defer cancel()
}
specStart := time.Now()
r := o.reviewSingleFile(fileCtx, filePath, ragMap[filePath], modulePath, depStructures, reviewCache, reviewGroupID)
r.duration = time.Since(specStart)
results[i] = r
if r.err != nil && isConnectionError(r.err) {
o.emit(&Event{Type: EventError, Message: "Ollama unreachable — aborting remaining reviews."})
reviewCancel()
}
}(i, f)
}
wg.Wait()
// Phase 2: Architecture review (single call).
if o.verbose {
o.emit(&Event{Type: EventAgent, Message: "▸ Running architecture review..."})
}
archResult := o.reviewArchitecture(ctx, depStructures, allSnippets, reviewGroupID)
o.emit(&Event{Type: EventGroupEnd, GroupID: reviewGroupID})
// Timing.
if o.verbose {
for i := range results {
r := &results[i]
status := perfStatusOK
if r.err != nil {
status = perfStatusErr
} else if strings.TrimSpace(r.text) == "" {
status = "empty"
}
o.emit(&Event{Type: EventAgent, Message: fmt.Sprintf(
"[perf] file=%s wall=%s status=%s", files[i], r.duration.Truncate(time.Millisecond), status)})
}
o.emit(&Event{Type: EventAgent, Message: fmt.Sprintf("[perf] review_total=%s files=%d", time.Since(reviewStart).Truncate(time.Millisecond), len(files))})
}
// Phase 3: Aggregate results.
allResults := make([]reviewResult, 0, len(results)+1)
allResults = append(allResults, results...)
allResults = append(allResults, archResult)
sections := make([]string, 0, len(allResults))
var failedCount, skippedCount int
for i := range allResults {
r := &allResults[i]
if r.err != nil {
failedCount++
continue
}
text := strings.TrimSpace(r.text)
if text == "" {
failedCount++
continue
}
if isNoIssues(text) {
if r.toolCalls == 0 && r.role != RoleCodeReviewer && r.role != RoleArchReviewer {
skippedCount++
}
continue
}
label := string(r.role)
if r.role == RoleCodeReviewer && i < len(files) {
label = files[i]
}
sections = append(sections, fmt.Sprintf("## [%s]\n\n%s", strings.ToUpper(label), text))
}
if len(sections) == 0 {
switch {
case skippedCount > 0:
o.emit(&Event{Type: EventAgent, Message: fmt.Sprintf(
"⚠ %d reviewer(s) responded without reading files — review is unreliable.", skippedCount)})
case failedCount > 0:
o.emit(&Event{Type: EventAgent, Message: fmt.Sprintf(
"⚠ %d reviewer(s) failed, but no issues found by the rest — treating as LGTM.", failedCount)})
default:
o.emit(&Event{Type: EventAgent, Message: "LGTM — no issues found."})
}
o.emit(&Event{Type: EventModeChange, Message: "plan"})
o.emit(&Event{Type: EventDone, Message: "Code review completed"})
return nil
}
if o.verbose {
o.emit(&Event{Type: EventAgent, Message: "▸ Merging reviewer findings..."})
}
finalPlan := mergeSpecialistFindings(allResults, o.language)
if finalPlan == "" {
o.emit(&Event{Type: EventAgent, Message: "⚠ No strict finding lines parsed — showing raw reports."})
finalPlan = strings.Join(sections, "\n\n---\n\n")
}
if o.verbose {
o.emit(&Event{Type: EventAgent, Message: "▸ Extracting structured plan..."})
}
extracted, err := o.runExtractor(ctx, finalPlan)
if err != nil {
o.emit(&Event{Type: EventError, Message: fmt.Sprintf("Extractor failed, using raw merge: %v", err)})
} else {
finalPlan = RenderExtractorOutput(extracted, o.language)
}
return o.confirmAndExecute(ctx, finalPlan, "Code review completed")
}
// reviewSingleFile reviews a single source file with pre-loaded context.
func (o *Orchestrator) reviewSingleFile(
ctx context.Context,
filePath, ragBlock, modulePath string,
depStructures map[string]string,
cache *ReviewCache,
groupID string,
) reviewResult {
// Read the file.
res, err := o.reg.Execute(ctx, "read_file", map[string]any{"path": filePath})
if err != nil {
return reviewResult{role: RoleCodeReviewer, err: fmt.Errorf("read %s: %w", filePath, err)}
}
o.emit(&Event{Type: EventTool, Tool: "read_file", Message: filePath, GroupID: groupID, Success: true})
fileContent := res.Output
// Build dependency signatures.
depSigs := depSignaturesForFile(o.workDir, filePath, modulePath, depStructures, depSignaturesMaxBytes)
// Check cache.
info, statErr := os.Stat(filePath)
if statErr != nil {
// Try relative to workDir.
info, statErr = os.Stat(o.workDir + "/" + filePath)
}
var modTime time.Time
if statErr == nil {
modTime = info.ModTime()
}
cacheKey := ReviewCacheKey(filePath, modTime, ragBlock, depSigs)
if findings, ok := cache.Get(cacheKey); ok {
o.emit(&Event{Type: EventTool, Tool: "read_file", Message: filePath, GroupID: groupID, CacheHit: true})
return reviewResult{role: RoleCodeReviewer, text: findings}
}
// Build prompt and run LLM.
prompt := buildPerFileReviewPrompt(filePath, fileContent, ragBlock, depSigs)
ag := o.newAgent(RoleCodeReviewer, o.maxRevIter)
ag.SetTaskLabel("")
ag.SetGroupID(groupID)
sysPrompt := systemPromptForRole(RoleCodeReviewer, o.language, o.progLang(), o.ruleCatalog, nil, o.hasSnippets, o.hasRAG)
ag.InitWithPrompt(sysPrompt)
if err := ag.Send(ctx, prompt); err != nil && !errors.Is(err, ErrMaxIterations) {
return reviewResult{role: RoleCodeReviewer, err: err, stats: ag.Stats()}
}
text := ag.LastPlan()
// Save to cache.
if err := cache.Put(cacheKey, &ReviewCacheEntry{
Key: cacheKey,
FilePath: filePath,
ModTime: modTime,
Findings: text,
CreatedAt: time.Now(),
}); err != nil {
o.emit(&Event{Type: EventError, Message: fmt.Sprintf("review cache write: %v", err)})
}
return reviewResult{
role: RoleCodeReviewer,
text: text,
stats: ag.Stats(),
toolCalls: ag.ToolCallCount(),
}
}
// reviewArchitecture runs a project-wide architecture review.
func (o *Orchestrator) reviewArchitecture(ctx context.Context, depStructures map[string]string, allSnippets []snippets.Snippet, groupID string) reviewResult {
// Build project structure from all prefetched packages.
var structBuf strings.Builder
for pkg, structure := range depStructures {
fmt.Fprintf(&structBuf, "### %s\n%s\n", pkg, structure)
}
// Collect pinned architecture/overview snippets.
var archSnippetsBuf strings.Builder
for i := range allSnippets {
if hasPinnedTag(allSnippets[i].Tags) {
fmt.Fprintf(&archSnippetsBuf, "### %s\n%s\n\n", allSnippets[i].Name, allSnippets[i].Content)
}
}
prompt := buildArchReviewPrompt(structBuf.String(), archSnippetsBuf.String())
archCtx := ctx
if o.specialistTimeout > 0 {
var cancel context.CancelFunc
archCtx, cancel = context.WithTimeout(ctx, o.specialistTimeout)
defer cancel()
}
ag := o.newAgent(RoleArchReviewer, o.maxRevIter)
ag.SetTaskLabel("")
ag.SetGroupID(groupID)
sysPrompt := systemPromptForRole(RoleArchReviewer, o.language, o.progLang(), o.ruleCatalog, nil, o.hasSnippets, o.hasRAG)
ag.InitWithPrompt(sysPrompt)
if err := ag.Send(archCtx, prompt); err != nil && !errors.Is(err, ErrMaxIterations) {
return reviewResult{role: RoleArchReviewer, err: err, stats: ag.Stats()}
}
return reviewResult{
role: RoleArchReviewer,
text: ag.LastPlan(),
stats: ag.Stats(),
toolCalls: ag.ToolCallCount(),
}
}
// hasPinnedTag checks if any of the snippet tags match architecture/overview tags.
func hasPinnedTag(tags []string) bool {
pinned := map[string]bool{
"architecture": true,
"overview": true,
"structure": true,
}
for _, t := range tags {
if pinned[strings.ToLower(t)] {
return true
}
}
return false
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"fmt"
"io"
)
// PlainOutput writes agent events to an io.Writer as plain text.
type PlainOutput struct {
w io.Writer
}
// NewPlainOutput creates a plain text output handler.
func NewPlainOutput(w io.Writer) *PlainOutput {
return &PlainOutput{w: w}
}
// Handle processes an agent event and writes it to the output.
func (p *PlainOutput) Handle(e *Event) {
switch e.Type {
case EventAgent:
fmt.Fprintf(p.w, "%s\n", e.Message)
case EventTool:
status := "✓"
if !e.Success {
status = "✗"
}
if e.Tool != "" {
displayName := ToolDisplayName(e.Tool)
if e.Message != "" && e.Message != "executing..." {
fmt.Fprintf(p.w, "%s %s(%s)\n", status, displayName, e.Message)
} else {
fmt.Fprintf(p.w, "%s %s\n", status, displayName)
}
}
case EventFix:
fmt.Fprintf(p.w, "[fix] %s\n", e.Message)
case EventError:
fmt.Fprintf(p.w, "[error] %s: %s\n", e.Tool, e.Message)
case EventTokens:
fmt.Fprintf(p.w, "[tokens] prompt: %d, eval: %d\n", e.PromptTokens, e.EvalTokens)
case EventCompact:
fmt.Fprintf(p.w, "[compact] %s\n", e.Message)
case EventGroupTitleUpdate:
fmt.Fprintf(p.w, "%s\n", e.Message)
case EventDone:
// Intentionally left blank for clean output
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"fmt"
"sync"
)
// ConfirmAction represents the user's decision on a tool confirmation.
type ConfirmAction int
const (
ConfirmDeny ConfirmAction = iota // Deny this operation
ConfirmAllowOnce // Allow once
ConfirmAllowSession // Allow this fingerprint for the rest of the session
ConfirmAugment // User provides text constraint; agent must rebuild the tool call
)
// ConfirmResult carries the user's decision and optional augment text.
type ConfirmResult struct {
Action ConfirmAction
Augment string // Non-empty only when Action == ConfirmAugment
}
// ConfirmPayload describes a tool call awaiting user confirmation.
// It carries everything the UI needs to render an informative confirmation card:
// the tool name, the relevant arguments (already filtered for display), an
// optional preview (unified diff for edit/write, command body for bash, etc.),
// and the danger flag for visually-flagged operations.
type ConfirmPayload struct {
Tool string
Args map[string]string // ordered key→value pairs for display
ArgKeys []string // preserve insertion order across map iterations
Preview string // unified diff or other multi-line preview
Danger bool
}
// Detail returns a short single-line summary of the payload, used as a
// fingerprint-friendly description and as a fallback for compact UIs.
func (p ConfirmPayload) Detail() string {
switch p.Tool {
case toolNameMoveFile:
return p.Args["from"] + " → " + p.Args["to"]
case toolNameBash:
return p.Args["command"]
}
if v, ok := p.Args["path"]; ok {
return v
}
for _, k := range p.ArgKeys {
return p.Args[k]
}
return p.Tool
}
// ConfirmFunc asks user to confirm a destructive operation.
// Returns ConfirmResult with the user's choice.
type ConfirmFunc func(payload ConfirmPayload) ConfirmResult
// PlanConfirmAction represents the user's decision on a plan execution.
type PlanConfirmAction int
const (
PlanDeny PlanConfirmAction = iota // Cancel execution
PlanAutoAccept // Execute plan, auto-accept all edits
PlanManualApprove // Execute plan, ask confirmation for each edit
PlanAugment // User provides feedback to revise the plan
)
// PlanConfirmResult carries the user's decision on plan execution.
type PlanConfirmResult struct {
Action PlanConfirmAction
Augment string // Non-empty only when Action == PlanAugment
}
// PlanConfirmFunc asks user to confirm plan execution.
type PlanConfirmFunc func(plan string) PlanConfirmResult
// StepConfirmAction represents the user's decision on a single step.
type StepConfirmAction int
const (
StepAllow StepConfirmAction = iota // Execute this step
StepSkip // Skip this step, continue with next
StepDenyAll // Cancel all remaining steps
)
// StepConfirmFunc asks user to confirm execution of a single plan step.
// The description contains step title, files, action, and rationale.
type StepConfirmFunc func(description string) StepConfirmAction
// Fingerprint computes a deterministic key for a tool call.
func Fingerprint(tool string, args map[string]any) string {
switch tool {
case toolNameBash:
cmd := stringFromMap(args, "command")
if len(cmd) > maxFingerprintLen {
cmd = cmd[:maxFingerprintLen]
}
return fmt.Sprintf("bash:%s", cmd)
case toolNameMoveFile:
from := stringFromMap(args, "from")
to := stringFromMap(args, "to")
return fmt.Sprintf("move_file:%s->%s", from, to)
default:
// Per-tool fingerprint: ConfirmAllowSession должен покрывать команду
// целиком, а не отдельный path. Иначе каждый новый файл вызывает
// повторный диалог подтверждения.
_ = args
return tool
}
}
// PermissionManager tracks session-level permissions for tool calls.
type PermissionManager struct {
mu sync.RWMutex
allowed map[string]bool
}
// NewPermissionManager creates a new PermissionManager.
func NewPermissionManager() *PermissionManager {
return &PermissionManager{allowed: make(map[string]bool)}
}
// IsAllowed returns true if this fingerprint was granted session permission.
func (pm *PermissionManager) IsAllowed(fingerprint string) bool {
pm.mu.RLock()
defer pm.mu.RUnlock()
return pm.allowed[fingerprint]
}
// AllowSession grants session-wide permission for this fingerprint.
func (pm *PermissionManager) AllowSession(fingerprint string) {
pm.mu.Lock()
defer pm.mu.Unlock()
pm.allowed[fingerprint] = true
}
// Reset clears all session permissions.
func (pm *PermissionManager) Reset() {
pm.mu.Lock()
defer pm.mu.Unlock()
pm.allowed = make(map[string]bool)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"encoding/json"
"fmt"
"regexp"
"sort"
"strings"
)
const codeFenceLen = 3 // length of "```" code fence marker
// Plan is the structured representation of an executable plan produced by the
// extractor role. It is the contract between the planner/extractor pair and
// the executor: each Step is a self-contained unit of work the executor
// applies, and Files lists the only paths the executor is allowed to read for
// that step.
//
// The structured form is the long-term direction. Today the orchestrator still
// passes the markdown plan to runExecutor, but Plan.Files is already used to
// build the read-path whitelist that prevents the executor from drifting into
// open-ended exploration.
type Plan struct {
Context string `json:"context,omitempty"`
Steps []Step `json:"steps,omitempty"`
// Raw is the original markdown text the planner produced. It is preserved
// so the executor still receives a human-readable plan even when the
// extractor only produced a partial JSON skeleton.
Raw string `json:"-"`
}
// Example points to existing project code that demonstrates the correct
// pattern for a step. The actual code is loaded lazily from disk at execution
// time (see snippet.go) so the JSON plan stays compact and snippets are
// always fresh even when earlier steps modify the referenced file.
type Example struct {
File string `json:"file"`
Line int `json:"line,omitempty"`
Note string `json:"note,omitempty"`
}
// Step is one actionable unit inside a Plan.
type Step struct {
ID int `json:"id"`
Title string `json:"title"`
Context string `json:"context,omitempty"`
Files []string `json:"files,omitempty"`
Action string `json:"action,omitempty"`
Rationale string `json:"rationale,omitempty"`
RuleNames []string `json:"rule_names,omitempty"`
DependsOn []int `json:"depends_on,omitempty"`
Examples []Example `json:"examples,omitempty"`
}
// AffectedFiles returns the union of all Files referenced by every step,
// deduplicated and sorted. This is the whitelist of paths the executor may
// read while applying the plan.
func (p *Plan) AffectedFiles() []string {
if p == nil {
return nil
}
seen := make(map[string]struct{})
for i := range p.Steps {
for _, f := range p.Steps[i].Files {
if f == "" {
continue
}
seen[f] = struct{}{}
}
}
out := make([]string, 0, len(seen))
for f := range seen {
out = append(out, f)
}
sort.Strings(out)
return out
}
// parseStructuredPlan extracts a Plan from raw structurer output. The
// structurer is instructed to emit a single JSON object, but local models
// occasionally wrap it in markdown fences or add a leading prose sentence.
// This function tolerates both forms.
func parseStructuredPlan(raw string) (*Plan, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("empty input")
}
// Strip markdown code fences if present.
if strings.HasPrefix(raw, "```") {
if end := strings.LastIndex(raw, "```"); end > codeFenceLen {
raw = raw[strings.Index(raw, "\n")+1 : end]
}
}
// Find the outermost JSON object.
start := strings.Index(raw, "{")
end := strings.LastIndex(raw, "}")
if start < 0 || end < 0 || end <= start {
return nil, fmt.Errorf("no JSON object found")
}
jsonText := raw[start : end+1]
var plan Plan
if err := json.Unmarshal([]byte(jsonText), &plan); err != nil {
return nil, fmt.Errorf("unmarshal: %w", err)
}
if len(plan.Steps) == 0 {
return nil, fmt.Errorf("plan has no steps")
}
// Assign sequential IDs if the model omitted them.
for i := range plan.Steps {
if plan.Steps[i].ID == 0 {
plan.Steps[i].ID = i + 1
}
}
return &plan, nil
}
// planPathRe matches file paths in markdown plans. It catches typical
// extensions used in this project (.go, .md, .yaml, .json, .py, .ts, .js)
// optionally suffixed with :line.
var planPathRe = regexp.MustCompile(`\b([\w./-]+\.(?:go|md|yaml|yml|json|py|ts|tsx|js|jsx|sh|sql|toml))\b`)
// RenderExtractorOutput converts the extractor's raw response into a
// human-readable markdown plan. Local models often ignore the markdown
// instruction and return JSON anyway (especially when format=json is forced
// at the API level). To keep the user-facing output clean, this function
// detects JSON shaped like the extractor schema and re-renders it as
// markdown. If the input is not JSON it is returned unchanged.
//
// Recognised JSON shapes (in priority order):
//
// 1. {"context": "...", "steps": [{"id":1,"title":"...","files":[...],"rationale":"..."}, ...]}
// — the canonical structurer schema.
// 2. {"context": "...", "plan": [...]} where each item is a string or an
// object with title/description/text — the shape qwen3-coder tends to
// emit when forced into JSON.
// 3. Any object with a "context" field — render the context and dump
// remaining fields as a fallback.
func RenderExtractorOutput(raw, lang string) string {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return raw
}
// Strip markdown code fences if present.
if strings.HasPrefix(trimmed, "```") {
if end := strings.LastIndex(trimmed, "```"); end > codeFenceLen {
if nl := strings.Index(trimmed, "\n"); nl >= 0 && nl < end {
trimmed = strings.TrimSpace(trimmed[nl+1 : end])
}
}
}
start := strings.Index(trimmed, "{")
end := strings.LastIndex(trimmed, "}")
if start < 0 || end < 0 || end <= start {
return raw
}
jsonText := trimmed[start : end+1]
var generic map[string]any
if err := json.Unmarshal([]byte(jsonText), &generic); err != nil {
return raw
}
var b strings.Builder
if ctx, ok := generic["context"].(string); ok && strings.TrimSpace(ctx) != "" {
b.WriteString("## Context\n")
b.WriteString(strings.TrimSpace(ctx))
b.WriteString("\n\n")
}
wrote := false
// Shape 1: canonical "steps".
if stepsRaw, ok := generic["steps"].([]any); ok && len(stepsRaw) > 0 {
b.WriteString(planLabel(lang, "tasks"))
for i, s := range stepsRaw {
renderStep(&b, i+1, s)
}
wrote = true
}
// Shape 2: free-form "plan".
if !wrote {
if planRaw, ok := generic["plan"].([]any); ok && len(planRaw) > 0 {
b.WriteString(planLabel(lang, "tasks"))
for i, item := range planRaw {
renderStep(&b, i+1, item)
}
wrote = true
}
}
if !wrote {
// No recognisable plan body — bail out and show the original text so
// at least nothing is lost.
return raw
}
// Affected files section.
if filesRaw, ok := generic["affected_files"].([]any); ok && len(filesRaw) > 0 {
b.WriteString(planLabel(lang, "affected"))
for _, f := range filesRaw {
if s, ok := f.(string); ok && s != "" {
fmt.Fprintf(&b, "- %s\n", s)
}
}
}
// Verification section.
if verRaw, ok := generic["verification"].([]any); ok && len(verRaw) > 0 {
b.WriteString(planLabel(lang, "verification"))
for _, v := range verRaw {
if s, ok := v.(string); ok && s != "" {
fmt.Fprintf(&b, "- [ ] %s\n", s)
}
}
}
return strings.TrimRight(b.String(), "\n") + "\n"
}
// renderStep renders a single plan item as a markdown block. The item can be
// a string (legacy format), a structured finding object (file/line/severity/what/...),
// or a structurer step object (title/files/rationale).
func renderStep(b *strings.Builder, idx int, item any) {
switch v := item.(type) {
case string:
line := strings.TrimSpace(v)
if hasLeadingNumber(line) {
b.WriteString(line)
} else {
fmt.Fprintf(b, "%d. %s", idx, line)
}
b.WriteByte('\n')
case map[string]any:
// Detect structured finding format (has "file" + "severity" + "what").
if file := firstString(v, "file"); file != "" {
if what := firstString(v, "what"); what != "" {
renderFindingStep(b, idx, v)
return
}
}
// Fallback: structurer step format (title/files/rationale).
title := firstString(v, "title", "task", "name", "description", "text")
fmt.Fprintf(b, "%d. %s", idx, title)
if files, ok := v["files"].([]any); ok && len(files) > 0 {
b.WriteString(" — files: ")
parts := make([]string, 0, len(files))
for _, f := range files {
if s, ok := f.(string); ok && s != "" {
parts = append(parts, s)
}
}
b.WriteString(strings.Join(parts, ", "))
}
if rationale := firstString(v, "rationale", "why", "reason"); rationale != "" {
b.WriteString("\n ")
b.WriteString(rationale)
}
b.WriteByte('\n')
default:
fmt.Fprintf(b, "%d. %v\n", idx, v)
}
}
// renderFindingStep renders a structured finding object as a multi-line block.
func renderFindingStep(b *strings.Builder, idx int, v map[string]any) {
file := firstString(v, "file")
severity := firstString(v, "severity")
lineNo := 0
if ln, ok := v["line"].(float64); ok {
lineNo = int(ln)
}
fmt.Fprintf(b, "\n### %d. %s:%d [%s]\n\n", idx, file, lineNo, severity)
if what := firstString(v, "what"); what != "" {
fmt.Fprintf(b, "- **What:** %s\n", what)
}
if ctx := firstString(v, "context"); ctx != "" {
fmt.Fprintf(b, "- **Context:** %s\n", ctx)
}
if why := firstString(v, "why"); why != "" {
fmt.Fprintf(b, "- **Why:** %s\n", why)
}
if fix := firstString(v, "fix"); fix != "" {
fmt.Fprintf(b, "- **Fix:** %s\n", fix)
}
if before := firstString(v, "before"); before != "" {
fmt.Fprintf(b, "- **Before:** %s\n", before)
}
if after := firstString(v, "after"); after != "" {
fmt.Fprintf(b, "- **After:** %s\n", after)
}
if rulesRaw, ok := v["rules"].([]any); ok && len(rulesRaw) > 0 {
parts := make([]string, 0, len(rulesRaw))
for _, r := range rulesRaw {
if s, ok := r.(string); ok && s != "" {
parts = append(parts, s)
}
}
if len(parts) > 0 {
fmt.Fprintf(b, "- **Rules:** %s\n", strings.Join(parts, ", "))
}
}
}
func firstString(m map[string]any, keys ...string) string {
for _, k := range keys {
if v, ok := m[k].(string); ok && strings.TrimSpace(v) != "" {
return strings.TrimSpace(v)
}
}
return ""
}
func hasLeadingNumber(s string) bool {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
return i > 0 && i < len(s) && (s[i] == '.' || s[i] == ')')
}
// PlanFromMarkdown is a best-effort fallback that extracts a flat Plan from
// the markdown produced by the planner when the extractor cannot return
// structured JSON. Each unique file mentioned in the plan becomes a single
// Step whose Files list contains that one path.
//
// The resulting Plan has empty Title/Action/Rationale on each Step but is
// sufficient to drive the executor whitelist (see AffectedFiles).
func PlanFromMarkdown(text string) *Plan {
if text == "" {
return &Plan{}
}
matches := planPathRe.FindAllStringSubmatch(text, -1)
seen := make(map[string]struct{})
files := make([]string, 0, len(matches))
for _, m := range matches {
p := strings.TrimSpace(m[1])
if p == "" {
continue
}
if _, ok := seen[p]; ok {
continue
}
seen[p] = struct{}{}
files = append(files, p)
}
plan := &Plan{Raw: text}
for i, f := range files {
plan.Steps = append(plan.Steps, Step{
ID: i + 1,
Title: f,
Files: []string{f},
})
}
return plan
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"fmt"
"os"
"path/filepath"
"strings"
)
// langName maps an ISO language code to its English name. Empty / unknown
// codes default to English.
func langName(code string) string {
switch code {
case "ru":
return "Russian"
case "en":
return langEnglish
case "de":
return "German"
case "fr":
return "French"
case "es":
return "Spanish"
case "zh":
return "Chinese"
case "ja":
return "Japanese"
case "":
return langEnglish
default:
return code
}
}
// buildSystemPrompt assembles the mode-specific system prompt for the agent's
// current state. Used by Send() at the start of each iteration to prime the
// LLM with role rules, available tools, and language directives.
func (a *Agent) buildSystemPrompt() string {
lang := langName(a.language)
pl := a.currentProgLang()
ltc := langToolsForLang(pl)
var b strings.Builder
if pl != "" {
fmt.Fprintf(&b, "You are KodRun, a %s programming assistant.\n", pl)
} else {
b.WriteString("You are KodRun, a programming assistant.\n")
}
fmt.Fprintf(&b, "IMPORTANT: ALL your responses MUST be in %s. This is mandatory.\n\n", lang)
if a.ruleCatalog != "" {
b.WriteString(a.ruleCatalog)
b.WriteString("\n")
}
switch a.mode {
case ModePlan:
b.WriteString("You are in PLAN mode (READ-ONLY).\n")
b.WriteString("You can ONLY analyze code and create plans. You CANNOT modify files.\n")
b.WriteString("You MUST NOT call any tools besides: " + strings.Join(a.reg.NamesFiltered(a.readOnlyTools()), ", ") + "\n\n")
b.WriteString("IMPORTANT — Questions vs Tasks:\n")
switch pl {
case progLangGo:
b.WriteString("- If the user asks a QUESTION (about Go, naming, conventions, architecture, etc.) — answer it DIRECTLY and concisely. Do NOT create a plan for questions.\n")
case progLangPython:
b.WriteString("- If the user asks a QUESTION (about Python naming, PEP conventions, architecture, etc.) — answer it DIRECTLY and concisely. Do NOT create a plan for questions.\n")
case progLangJSTS:
b.WriteString("- If the user asks a QUESTION (about TypeScript naming, conventions, architecture, etc.) — answer it DIRECTLY and concisely. Do NOT create a plan for questions.\n")
default:
b.WriteString("- If the user asks a QUESTION (about naming, conventions, architecture, etc.) — answer it DIRECTLY and concisely. Do NOT create a plan for questions.\n")
}
b.WriteString("- If the user gives a TASK (fix, refactor, add feature, etc.) — create a numbered plan.\n\n")
b.WriteString("STRICT RULES (for tasks):\n")
b.WriteString("- NEVER generate code blocks, patches, diffs, or file contents\n")
b.WriteString("- NEVER show code that should be written or changed\n")
b.WriteString("- NEVER call write_file, edit_file, delete_file, bash, or any write tool\n")
b.WriteString("- If asked to fix, edit, or write code — describe WHAT to change, not HOW in code\n")
b.WriteString("- Your plan must be a numbered list with text descriptions only\n")
b.WriteString("- Do NOT read binary files, build artifacts, or IDE config directories\n\n")
b.WriteString("Guidelines:\n")
switch pl {
case progLangGo:
b.WriteString("- Read and analyze *.go source files and project docs\n")
case progLangPython:
b.WriteString("- Read and analyze *.py source files and project docs\n")
case progLangJSTS:
b.WriteString("- Read and analyze *.ts, *.tsx, *.js, *.jsx source files and project docs\n")
default:
b.WriteString("- Read and analyze source files and project docs\n")
}
b.WriteString("- Identify files that need changes\n")
b.WriteString("- Propose a step-by-step plan\n")
b.WriteString("- Estimate complexity and risks\n")
b.WriteString("- Be concise and actionable\n")
b.WriteString("- Verification section MUST only include commands that match the actual project stack and task scope. Do NOT invent commands for tools, servers, linters or formatters not present in the project.\n")
switch pl {
case progLangGo:
b.WriteString("- Reference Effective Go, Go Code Review Comments, Go Common Mistakes and project conventions\n")
case progLangPython:
b.WriteString("- Reference PEP 8, PEP 20 and project conventions\n")
case progLangJSTS:
b.WriteString("- Reference TypeScript best practices and project conventions\n")
default:
// No language-specific best practices when language is unknown.
}
if a.hasRAG {
b.WriteString("\nIMPORTANT — Project rules and conventions (from RAG):\n")
fmt.Fprintf(&b, "The task context includes MANDATORY RULES marked [MANDATORY PROJECT RULES] and %s marked [%s].\n", ltc.standardsLabel, ltc.standardsLabel)
b.WriteString("These are NOT suggestions — they are REQUIREMENTS. Treat violations as bugs.\n")
b.WriteString("These include naming conventions, error handling, code structure, and all documented standards.\n")
b.WriteString("You MUST check code against ALL provided rules. Include violations in your plan.\n")
b.WriteString("You may call search_docs for additional targeted searches if needed.\n")
} else if a.hasSnippets {
b.WriteString("\nIMPORTANT — Documentation check (MANDATORY):\n")
b.WriteString("You MUST call snippets BEFORE creating the plan. This is not optional.\n")
switch pl {
case progLangGo:
b.WriteString("1. Call snippets(paths=[<list of all .go files you read>]) to get code conventions\n")
case progLangPython:
b.WriteString("1. Call snippets(paths=[<list of all .py files you read>]) to get code conventions\n")
default:
b.WriteString("1. Call snippets(paths=[<list of all source files you read>]) to get code conventions\n")
}
b.WriteString("2. Read and understand the found conventions\n")
b.WriteString("3. Only then create the plan, incorporating found conventions as requirements\n")
}
case ModeChat:
b.WriteString("You are in CHAT mode.\n")
b.WriteString("Answer questions, explain code, discuss architecture and design decisions.\n")
b.WriteString("You can read files for context using read-only tools: " + strings.Join(a.reg.NamesFiltered(a.readOnlyTools()), ", ") + "\n")
b.WriteString("Do NOT create numbered plans, do NOT write or edit files, do NOT call write tools.\n")
b.WriteString("Be concise and helpful.\n")
default:
b.WriteString("You are in EDIT mode. EDIT mode is for ACTING on the code, not for describing changes.\n\n")
b.WriteString("CRITICAL — Action vs description:\n")
b.WriteString("- If the user's input is a TASK to change code (fix, refactor, create, edit, move, restructure, apply plan, implement, ...) you MUST start your response with tool calls (write_file, edit_file, bash, read_file as needed). Do NOT output a markdown plan, do NOT write \"ANALYSIS\" / \"АНАЛИЗ\" or \"IMPLEMENTATION PLAN\" / \"ПЛАН ИСПРАВЛЕНИЙ\" sections, do NOT explain what you are about to do — just call the tools.\n")
b.WriteString("- A textual response without tool calls is allowed ONLY when the user asked a pure question (no action verb). When in doubt, assume it is a task and call tools.\n")
b.WriteString("- If you need to read a file before editing it, call read_file as the first tool — that still counts as \"starting with a tool\".\n")
b.WriteString("- If the user pasted a numbered plan, your job is to EXECUTE it, not to rewrite it back. Skip directly to the first edit_file/write_file. Do not produce an \"Implementation plan\" / \"План исправлений\" of your own.\n")
b.WriteString("- PLAN mode is the place for descriptions. EDIT mode is for actions. Stay in your lane.\n\n")
b.WriteString("Available tools: " + strings.Join(a.reg.Names(), ", ") + "\n\n")
b.WriteString("Guidelines:\n")
switch pl {
case progLangGo:
b.WriteString("- Write idiomatic Go code following Effective Go, Go Code Review Comments, Go Common Mistakes. Use Go 1.25+.\n")
b.WriteString("- Handle errors properly\n")
case progLangPython:
b.WriteString("- Write idiomatic Python code following PEP 8 and project conventions.\n")
b.WriteString("- Handle errors properly\n")
case progLangJSTS:
b.WriteString("- Write idiomatic TypeScript/JavaScript code following project conventions.\n")
b.WriteString("- Handle errors properly\n")
default:
// No language-specific guidelines when language is unknown.
}
b.WriteString("- Use edit_file for targeted changes, write_file for new files\n")
b.WriteString("- Be concise in responses\n")
b.WriteString("- Do NOT repeat or quote file contents in your responses. Reference files by path only.\n")
if a.hasRAG {
b.WriteString("\nIMPORTANT — Project rules and conventions (from RAG):\n")
fmt.Fprintf(&b, "The task context includes MANDATORY RULES marked [MANDATORY PROJECT RULES] and %s marked [%s].\n", ltc.standardsLabel, ltc.standardsLabel)
b.WriteString("These are REQUIREMENTS, not suggestions. Apply them to every line you write.\n")
b.WriteString("These include naming conventions, error handling, code structure, and all documented standards.\n")
b.WriteString("You may call search_docs for additional targeted searches if needed.\n")
} else if a.hasSnippets {
b.WriteString("\nIMPORTANT — Documentation check (MANDATORY):\n")
b.WriteString("You MUST call snippets BEFORE writing or modifying any file. This is not optional.\n")
b.WriteString("1. Call snippets(paths=[<file_paths>]) to get code conventions\n")
b.WriteString("2. Read and understand the found conventions\n")
b.WriteString("3. Only then write/edit code, following ALL found conventions (naming, structure, patterns, error handling)\n")
b.WriteString("4. If no snippets match, proceed without conventions\n")
}
if ltc.buildTool != "" || ltc.lintTool != "" || ltc.testTool != "" {
b.WriteString("\nAfter completing EVERY task you MUST run this verification sequence:\n")
step := 1
if ltc.buildTool != "" {
fmt.Fprintf(&b, "%d. Run %s to verify compilation. If errors — fix them and re-run.\n", step, ltc.buildTool)
step++
}
if ltc.lintTool != "" {
fmt.Fprintf(&b, "%d. Run %s to check code quality. If errors — fix them and re-run.\n", step, ltc.lintTool)
step++
}
if ltc.testTool != "" {
fmt.Fprintf(&b, "%d. Run %s to verify correctness. If errors — fix them and re-run.\n", step, ltc.testTool)
step++
}
fmt.Fprintf(&b, "%d. Update AGENTS.md if you changed architecture, added/removed files, or modified public APIs.\n", step)
b.WriteString(" Use read_file to read AGENTS.md first, then edit_file to update only the relevant sections.\n")
b.WriteString(" Do NOT rewrite the entire file — only update what changed.\n")
}
}
// Repeat language directive at the end for reinforcement (important for local models).
if lang != langEnglish {
fmt.Fprintf(&b, "\nREMINDER: You MUST respond in %s. Never switch to any other language.\n", lang)
}
return b.String()
}
// buildProjectContext returns project-level context (AGENTS.md, go.mod) that
// is prepended to the conversation so the model has anchor information about
// the workspace.
func (a *Agent) buildProjectContext() string {
var b strings.Builder
agentsMD := filepath.Join(a.workDir, "AGENTS.md")
if data, err := os.ReadFile(agentsMD); err == nil {
b.WriteString("Project documentation (AGENTS.md):\n")
b.Write(data)
b.WriteString("\n\n")
}
goMod := filepath.Join(a.workDir, "go.mod")
if data, err := os.ReadFile(goMod); err == nil {
b.WriteString("go.mod:\n```\n")
b.Write(data)
b.WriteString("\n```\n\n")
}
return b.String()
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"bytes"
"context"
"fmt"
"os/exec"
"path/filepath"
"sort"
"strings"
"github.com/raoptimus/kodrun/internal/rag"
)
const (
minGitStatusLineLen = 4 // minimum git status --short line length
prefetchCategoryTests = "tests"
prefetchCategoryGodoc = "godoc"
prefetchCategoryRule = "rule"
prefetchCategoryStandard = "standard"
prefetchCategorySnippet = "snippet"
)
// detectEntityTypeFromPath returns the rule name (matching .kodrun/rules/<name>.md)
// for a given Go file path, or "" if no rule name from ruleNames matches.
// Detection is based on filename suffixes/substrings, not on file contents.
// ruleNames is the dynamic list of available rule names (from rules.Loader).
func detectEntityTypeFromPath(path string, ruleNames []string) string {
if path == "" || len(ruleNames) == 0 {
return ""
}
base := strings.ToLower(filepath.Base(path))
base = strings.TrimSuffix(base, ".go")
// _test.go always wins if "tests" rule exists.
if strings.HasSuffix(base, "_test") {
for _, n := range ruleNames {
if n == prefetchCategoryTests {
return prefetchCategoryTests
}
}
}
// Sort by length desc so more specific names match first.
sorted := make([]string, len(ruleNames))
copy(sorted, ruleNames)
sort.Slice(sorted, func(i, j int) bool { return len(sorted[i]) > len(sorted[j]) })
for _, name := range sorted {
if name == "" || name == prefetchCategoryTests {
continue
}
ln := strings.ToLower(name)
if strings.HasSuffix(base, "_"+ln) || base == ln || strings.Contains(base, ln) {
return name
}
}
return ""
}
// gitChangedGoFiles returns the list of changed (modified/added/untracked) .go files
// in the working directory according to `git status --porcelain=v1`. Empty on any error.
func gitChangedGoFiles(ctx context.Context, workDir string) []string {
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain=v1")
cmd.Dir = workDir
var out bytes.Buffer
cmd.Stdout = &out
if err := cmd.Run(); err != nil {
return nil
}
var files []string
for line := range strings.SplitSeq(out.String(), "\n") {
if len(line) < minGitStatusLineLen {
continue
}
path := strings.TrimSpace(line[3:])
// Handle rename: "old -> new"
if idx := strings.Index(path, " -> "); idx >= 0 {
path = path[idx+4:]
}
if strings.HasSuffix(path, ".go") {
files = append(files, path)
}
}
return files
}
// entityTypesFromPaths returns the unique set of rule names detected
// across the given file paths, given a dynamic list of available rule names.
func entityTypesFromPaths(paths, ruleNames []string) []string {
seen := make(map[string]bool)
out := make([]string, 0, len(paths))
for _, p := range paths {
t := detectEntityTypeFromPath(p, ruleNames)
if t == "" || seen[t] {
continue
}
seen[t] = true
out = append(out, t)
}
return out
}
// chunkCategory classifies a RAG chunk by its source path.
// example_*.go files (from .kodrun/docs/) are treated as snippet templates,
// not as plain rule docs.
func chunkCategory(filePath string) string {
base := filepath.Base(filePath)
switch {
case strings.HasPrefix(filePath, "godoc://"):
return prefetchCategoryGodoc
case strings.HasPrefix(filePath, "rules://"):
return prefetchCategoryRule
case strings.HasPrefix(filePath, "embedded://"):
return prefetchCategoryStandard
case strings.HasPrefix(base, "example_"):
return prefetchCategorySnippet
case strings.Contains(filePath, "/snippets/") || strings.HasSuffix(filePath, ".snippet"):
return prefetchCategorySnippet
case strings.Contains(filePath, "/docs/") || strings.Contains(filePath, "/rules/"):
return prefetchCategoryRule
default:
return "code"
}
}
// formatRAGResults formats RAG search results into a string block
// suitable for injection into a user message.
// Results are grouped by category: mandatory rules first, then standards,
// snippets, and code examples — so the model clearly sees what is required vs informational.
func formatRAGResults(results []rag.SearchResult) string {
type categorized struct {
category string
result rag.SearchResult
}
var rules, standards, snippets, godocs []categorized
for _, r := range results {
// Conservative in-place compression to squeeze more signal
// into the same injection budget.
r.Chunk.Content = rag.CompressChunk(r.Chunk.FilePath, r.Chunk.Content)
cat := chunkCategory(r.Chunk.FilePath)
c := categorized{category: cat, result: r}
switch cat {
case prefetchCategoryRule:
rules = append(rules, c)
case prefetchCategoryStandard:
standards = append(standards, c)
case prefetchCategorySnippet:
snippets = append(snippets, c)
case prefetchCategoryGodoc:
godocs = append(godocs, c)
default:
// "code" chunks should no longer reach this formatter: RAG only
// indexes conventions (rules/snippets/docs/embedded). If a code
// chunk slips in (e.g. a stale leftover from an older index on
// disk), drop it — never inject project source code through RAG,
// reviewers must read it live via read_file.
}
}
var b strings.Builder
idx := 1
if len(rules) > 0 {
b.WriteString("[MANDATORY PROJECT RULES — you MUST follow these rules in ALL code you write, review, or plan]\n")
b.WriteString("[These are NOT suggestions. Violations of these rules are BUGS that must be fixed.]\n\n")
for _, c := range rules {
r := c.result
fmt.Fprintf(&b, "--- RULE %d (%.2f) %s:%d-%d ---\n%s\n\n",
idx, r.Score, r.Chunk.FilePath, r.Chunk.StartLine, r.Chunk.EndLine, r.Chunk.Content)
idx++
}
}
if len(standards) > 0 {
b.WriteString("[GO STANDARDS — idiomatic Go practices you MUST apply]\n\n")
for _, c := range standards {
r := c.result
fmt.Fprintf(&b, "--- STANDARD %d (%.2f) %s:%d-%d ---\n%s\n\n",
idx, r.Score, r.Chunk.FilePath, r.Chunk.StartLine, r.Chunk.EndLine, r.Chunk.Content)
idx++
}
}
if len(snippets) > 0 {
b.WriteString("[CODE TEMPLATES — MANDATORY patterns you MUST follow when code matches these patterns]\n\n")
for _, c := range snippets {
r := c.result
fmt.Fprintf(&b, "--- TEMPLATE %d (%.2f) %s:%d-%d ---\n%s\n\n",
idx, r.Score, r.Chunk.FilePath, r.Chunk.StartLine, r.Chunk.EndLine, r.Chunk.Content)
idx++
}
}
if len(godocs) > 0 {
b.WriteString("[GO DOCUMENTATION — relevant Go package docs]\n\n")
for _, c := range godocs {
r := c.result
fmt.Fprintf(&b, "--- DOC %d (%.2f) %s:%d-%d ---\n%s\n\n",
idx, r.Score, r.Chunk.FilePath, r.Chunk.StartLine, r.Chunk.EndLine, r.Chunk.Content)
idx++
}
}
return b.String()
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"os"
"path/filepath"
"time"
)
const reviewCacheDir = ".kodrun/cache/review"
// ReviewCacheEntry stores a cached per-file review result.
type ReviewCacheEntry struct {
Key string `json:"key"`
FilePath string `json:"file_path"`
ModTime time.Time `json:"mod_time"`
Findings string `json:"findings"`
CreatedAt time.Time `json:"created_at"`
}
// ReviewCache persists per-file review results to disk so unchanged files
// are not re-reviewed on subsequent runs.
type ReviewCache struct {
dir string
}
// NewReviewCache creates a review cache rooted at workDir/.kodrun/cache/review.
func NewReviewCache(workDir string) *ReviewCache {
return &ReviewCache{dir: filepath.Join(workDir, reviewCacheDir)}
}
// Get returns cached findings for the given key.
func (c *ReviewCache) Get(key string) (string, bool) {
data, err := os.ReadFile(filepath.Join(c.dir, key+".json"))
if err != nil {
return "", false
}
var entry ReviewCacheEntry
if err := json.Unmarshal(data, &entry); err != nil {
return "", false
}
return entry.Findings, true
}
// Put stores findings under the given key.
const (
reviewCacheDirPerm = 0o750
reviewCacheFilePerm = 0o600
)
func (c *ReviewCache) Put(key string, entry *ReviewCacheEntry) error {
if err := os.MkdirAll(c.dir, reviewCacheDirPerm); err != nil {
return err
}
data, err := json.Marshal(entry)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(c.dir, key+".json"), data, reviewCacheFilePerm)
}
// ReviewCacheKey computes a deterministic cache key from file path, modification
// time, RAG context, and dependency signatures. Any change invalidates the cache.
func ReviewCacheKey(filePath string, modTime time.Time, ragBlock, depSigs string) string {
h := sha256.New()
h.Write([]byte(filePath))
h.Write([]byte(modTime.Format(time.RFC3339Nano)))
h.Write([]byte(ragBlock))
h.Write([]byte(depSigs))
return hex.EncodeToString(h.Sum(nil))[:16]
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"bufio"
"context"
"fmt"
"go/parser"
"go/token"
"os"
"path/filepath"
"strings"
"github.com/raoptimus/kodrun/internal/rag"
"github.com/raoptimus/kodrun/internal/tools"
)
// readModulePath reads the module path from go.mod in workDir.
func readModulePath(workDir string) string {
f, err := os.Open(filepath.Join(workDir, "go.mod"))
if err != nil {
return ""
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if strings.HasPrefix(line, "module ") {
return strings.TrimSpace(strings.TrimPrefix(line, "module"))
}
}
return ""
}
// extractLocalImports parses Go imports from filePath and returns
// directories of local (same-module) packages relative to workDir.
func extractLocalImports(workDir, filePath, modulePath string) ([]string, error) {
if modulePath == "" {
return nil, nil
}
absPath := filePath
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(workDir, absPath)
}
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, absPath, nil, parser.ImportsOnly)
if err != nil {
return nil, fmt.Errorf("parse imports %s: %w", filePath, err)
}
dirs := make([]string, 0, len(f.Imports))
for _, imp := range f.Imports {
path := strings.Trim(imp.Path.Value, `"`)
if !strings.HasPrefix(path, modulePath) {
continue
}
// Convert import path to relative directory.
rel := strings.TrimPrefix(path, modulePath)
rel = strings.TrimPrefix(rel, "/")
if rel == "" {
continue
}
dirs = append(dirs, rel)
}
return dirs, nil
}
// prefetchPackageStructures collects go_structure output for all local packages
// imported by the given files. Returns map[pkgDir]->structureOutput.
func prefetchPackageStructures(ctx context.Context, reg *tools.Registry, workDir string, files []string, modulePath string) map[string]string {
// Collect unique package dirs.
pkgSet := make(map[string]bool)
for _, f := range files {
dirs, err := extractLocalImports(workDir, f, modulePath)
if err != nil {
continue
}
for _, d := range dirs {
pkgSet[d] = true
}
}
result := make(map[string]string, len(pkgSet))
for pkgDir := range pkgSet {
res, err := reg.Execute(ctx, "go_structure", map[string]any{
"path": pkgDir,
"exported_only": true,
})
if err != nil {
continue
}
result[pkgDir] = res.Output
}
return result
}
// buildPerFileRAGMap runs RAG search for each file and returns
// map[filePath]->formatted RAG block. lang is the detected project language
// used to include embedded language standards (e.g. go_common_mistakes).
func buildPerFileRAGMap(ctx context.Context, ragIndex tools.RAGSearcher, files []string, topK int, lang string) map[string]string {
if ragIndex == nil || topK <= 0 {
return nil
}
result := make(map[string]string, len(files))
queryDone := make(map[string][]rag.SearchResult)
for _, f := range files {
base := filepath.Base(f)
dir := filepath.Dir(f)
query := base
if dir != "." {
query = filepath.Base(dir) + " " + base
}
if cached, ok := queryDone[query]; ok {
if len(cached) > 0 {
result[f] = formatRAGResults(cached)
}
continue
}
results, err := ragIndex.Search(ctx, query, topK)
if err != nil {
queryDone[query] = nil
continue
}
queryDone[query] = results
if len(results) > 0 {
result[f] = formatRAGResults(results)
}
}
// Append embedded language-standard results to every file's block.
var embeddedResults []rag.SearchResult
for _, name := range rag.EmbeddedDocNames(lang) {
results, err := ragIndex.Search(ctx, name, ragRuleTopK)
if err != nil {
continue
}
embeddedResults = append(embeddedResults, results...)
}
if len(embeddedResults) > 0 {
embeddedBlock := formatRAGResults(embeddedResults)
for _, f := range files {
if existing, ok := result[f]; ok {
result[f] = existing + embeddedBlock
} else {
result[f] = embeddedBlock
}
}
}
return result
}
// depSignaturesForFile collects go_structure output for packages imported
// by the given file, capped at maxBytes.
func depSignaturesForFile(workDir, filePath, modulePath string, structures map[string]string, maxBytes int) string {
dirs, err := extractLocalImports(workDir, filePath, modulePath)
if err != nil || len(dirs) == 0 {
return ""
}
var b strings.Builder
for _, d := range dirs {
s, ok := structures[d]
if !ok || s == "" {
continue
}
entry := fmt.Sprintf("### %s\n%s\n", d, s)
if maxBytes > 0 && b.Len()+len(entry) > maxBytes {
break
}
b.WriteString(entry)
}
return b.String()
}
// buildPerFileReviewPrompt assembles the user message for a per-file code review.
func buildPerFileReviewPrompt(filePath, fileContent, ragBlock, depSignatures string) string {
var b strings.Builder
if ragBlock != "" {
b.WriteString("## Project conventions\n")
b.WriteString(ragBlock)
b.WriteString("\n\n")
}
b.WriteString("## File under review: ")
b.WriteString(filePath)
b.WriteString("\n```\n")
b.WriteString(fileContent)
b.WriteString("\n```\n\n")
if depSignatures != "" {
b.WriteString("## Dependency signatures (local packages)\n")
b.WriteString(depSignatures)
b.WriteByte('\n')
}
b.WriteString("## Reminders\n")
b.WriteString("- Line numbers come from the file content above.\n")
b.WriteString("- Format: multi-line block starting with `path:LINE — SEVERITY`, then WHAT/WHY/FIX/BEFORE/AFTER/RULES fields\n")
b.WriteString("- If no issues found, output exactly: NO_ISSUES\n")
return b.String()
}
// buildArchReviewPrompt assembles the user message for the architecture reviewer.
func buildArchReviewPrompt(projectStructure, archSnippets string) string {
var b strings.Builder
if archSnippets != "" {
b.WriteString("## Project conventions\n")
b.WriteString(archSnippets)
b.WriteString("\n\n")
}
b.WriteString("## Project structure (all packages)\n")
b.WriteString(projectStructure)
b.WriteString("\n\n")
b.WriteString("## Reminders\n")
b.WriteString("- Format: multi-line block starting with `path/to/package — SEVERITY`, then WHAT/WHY/FIX/RULES fields\n")
b.WriteString("- If no issues found, output exactly: NO_ISSUES\n")
return b.String()
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"fmt"
"strings"
)
// progLang constants used in system prompt generation.
const (
progLangGo = "go"
progLangPython = "python"
progLangJSTS = "jsts"
)
// Role identifies a sub-agent's purpose in the orchestrator pipeline.
type Role string
const (
RolePlanner Role = "planner"
RoleExecutor Role = "executor"
RoleReviewer Role = "reviewer"
RoleExtractor Role = "extractor"
RoleStructurer Role = "structurer"
RoleVerifier Role = "verifier"
RoleResponseClassifier Role = "response_classifier"
// Code review roles. The orchestrator pre-loads all context (file content,
// RAG snippets, dependency signatures) so these roles do not require
// tool-calling — they only analyse.
RoleCodeReviewer Role = "code_reviewer"
RoleArchReviewer Role = "arch_reviewer"
)
// taskLabelForRole returns a short human-readable label describing what a
// sub-agent of the given role is doing. Used by Agent.Send() to emit a more
// informative status line than the generic "Processing task...".
func taskLabelForRole(role Role) string {
switch role {
case RolePlanner:
return "Planning..."
case RoleExecutor:
return "Executing plan..."
case RoleReviewer:
return "Reviewing changes..."
case RoleExtractor:
return "Extracting structured plan..."
case RoleStructurer:
return "Converting plan to JSON..."
case RoleResponseClassifier:
return "Classifying response..."
case RoleCodeReviewer:
return "Reviewing file..."
case RoleArchReviewer:
return "Reviewing architecture..."
case RoleVerifier:
return "Verifying plan completion..."
}
return "Processing task..."
}
// systemPromptForRole generates a role-specific system prompt for a sub-agent.
// progLang is the detected project programming language (e.g. "go", "python", "jsts", or "").
// Optional bool flags: hasSnippets, hasRAG.
func systemPromptForRole(role Role, lang, progLang, ruleCatalog string, toolNames []string, flags ...bool) string {
snippetsEnabled := len(flags) > 0 && flags[0]
ragEnabled := len(flags) > 1 && flags[1]
var b strings.Builder
if progLang != "" {
fmt.Fprintf(&b, "You are KodRun, a %s programming assistant.\n", progLang)
} else {
b.WriteString("You are KodRun, a programming assistant.\n")
}
ln := langName(lang)
if ln != langEnglish {
fmt.Fprintf(&b, "IMPORTANT: ALL your responses MUST be in %s. This is mandatory.\n", ln)
}
b.WriteByte('\n')
if ruleCatalog != "" {
b.WriteString(ruleCatalog)
b.WriteString("\n")
}
// Every role gets the full list of available tools in its system prompt.
// Some local models (qwen3-coder and similar) do not reliably infer tool
// availability from schema alone — without this explicit textual mention
// they silently skip calling tools like read_file. Historically only
// Executor/StepExecutor had this line; all other roles (planner, reviewer,
// specialist reviewers) would go quiet because they "did not know" read_file
// existed.
if len(toolNames) > 0 {
b.WriteString("Available tools: " + strings.Join(toolNames, ", ") + "\n\n")
}
switch role {
case RolePlanner:
ltc := langToolsForLang(progLang)
b.WriteString("You are the PLANNER agent.\n")
b.WriteString("Your job is to analyze the task and create a detailed, actionable plan.\n\n")
b.WriteString("CRITICAL WORKFLOW:\n")
b.WriteString("- If the task text contains file paths (e.g. `path/to/file.go:42`), your VERY FIRST tool calls MUST be read_file on those exact files. Do NOT call list_dir, find_files, or any other tool before reading the referenced files.\n")
b.WriteString("- Only after reading the referenced files, read additional files if the code references imports or types you need to understand.\n")
b.WriteString("- After reading the task, BEGIN your plan with a 'CONSTRAINTS:' section listing ALL explicit prohibitions and constraints found in the task text. These override any other consideration. Every subsequent step, technology choice, and verification command MUST comply with these constraints.\n\n")
b.WriteString("Guidelines:\n")
if ltc.structureTool != "" {
fmt.Fprintf(&b, "- Use `%s(path)` to quickly see file/package outline (types, functions, constants with line numbers) before reading full files with read_file — this saves context\n", ltc.structureTool)
}
b.WriteString("- Read and analyze the relevant code using read-only tools\n")
b.WriteString("- Identify all files that need changes\n")
b.WriteString("- Create a numbered step-by-step plan. Each step MUST use this format:\n")
b.WriteString(" N. <title>\n")
b.WriteString(" file: <path>\n")
b.WriteString(" context: <all details the executor needs to complete this step independently: data structures, field names, formats, requirements, constraints. The executor cannot see the original task>\n")
b.WriteString(" rationale: <why this step is needed>\n")
b.WriteString("- Do NOT write code or generate code blocks\n")
b.WriteString("- Be concise and actionable\n")
fmt.Fprintf(&b, "- %s\n", ltc.bestPractices)
b.WriteString("- When you find existing code that demonstrates the correct pattern for a change, note it as: EXAMPLE: path/to/file.go:LINE — short reason. Place EXAMPLE lines immediately after the step they belong to.\n")
b.WriteString("- Verification section MUST only include commands that match the actual project stack and task scope. Do NOT invent commands for tools/servers not present in the project.\n")
fmt.Fprintf(&b, "- Always respond in %s\n", langName(lang))
if ragEnabled {
b.WriteString("\nIMPORTANT — Project rules and conventions (from RAG):\n")
fmt.Fprintf(&b, "The task context includes MANDATORY RULES marked [MANDATORY PROJECT RULES] and %s marked [%s].\n", ltc.standardsLabel, ltc.standardsLabel)
b.WriteString("These are NOT suggestions — they are REQUIREMENTS. Treat violations as bugs.\n")
b.WriteString("Examples: naming conventions, error wrapping, proper use of language idioms, etc.\n")
b.WriteString("You MUST check every code change against ALL provided rules. Include violations in your plan.\n")
b.WriteString("You may call search_docs for additional targeted searches if needed.\n")
} else if snippetsEnabled {
b.WriteString("\nIMPORTANT — Code conventions check:\n")
b.WriteString("BEFORE creating the plan, you MUST call snippets(paths=[<list of all source files you read>])\n")
b.WriteString("to get the project's code conventions. Include violations of these conventions in your plan.\n")
}
case RoleExecutor:
ltc := langToolsForLang(progLang)
b.WriteString("You are the EXECUTOR agent.\n")
b.WriteString("Your job is to IMPLEMENT an approved plan by writing/editing code.\n\n")
b.WriteString("CRITICAL RULES:\n")
b.WriteString("- The source code is PROVIDED in the task. Do NOT call read_file, list_dir, find_files, or grep.\n")
b.WriteString("- Start IMMEDIATELY with edit_file/write_file. No analysis, no re-reading.\n")
b.WriteString("- Do NOT re-analyze or re-review the code. The plan is already approved.\n")
b.WriteString("- Do NOT write checklists like 'STEP 1: VERIFY...' or 'STEP 2: ANALYZE...'. Just apply the changes.\n")
b.WriteString("- The set of files you may touch is fixed by the approved plan. Reading any file outside that set will be REFUSED by the harness.\n")
b.WriteString("- If — and only if — the plan is genuinely missing context you need, output a single line beginning with `REPLAN:` followed by a short reason, then stop. Do NOT silently work around missing context.\n")
b.WriteString("- A response without ANY tool calls is a FAILURE in this role. If you have nothing to do, output `REPLAN: <reason>` instead of writing prose. Markdown plans/analyses are NEVER a valid output here.\n\n")
b.WriteString("Guidelines:\n")
b.WriteString("- Follow the plan exactly, step by step\n")
b.WriteString("- Use edit_file for small changes, write_file for new files\n")
b.WriteString("- After each step, confirm what was done\n")
if ltc.buildTool != "" {
fmt.Fprintf(&b, "- Run %s after changes to verify compilation\n", ltc.buildTool)
}
if ltc.lintTool != "" {
fmt.Fprintf(&b, "- Run %s to check style\n", ltc.lintTool)
}
if ltc.testTool != "" {
fmt.Fprintf(&b, "- Run %s to verify correctness\n", ltc.testTool)
}
if ltc.buildTool != "" {
fmt.Fprintf(&b, "- When %s fails, fix the COMPILATION ERROR (wrong import, typo, missing function) — do NOT revert your changes. Reverting the plan changes is NEVER acceptable. If you cannot fix the error after 3 attempts, output `REPLAN: <reason>`.\n", ltc.buildTool)
}
fmt.Fprintf(&b, "- Always respond in %s\n", langName(lang))
if ragEnabled {
b.WriteString("\nIMPORTANT — Project rules and conventions (from RAG):\n")
fmt.Fprintf(&b, "The task context includes MANDATORY RULES marked [MANDATORY PROJECT RULES] and %s marked [%s].\n", ltc.standardsLabel, ltc.standardsLabel)
b.WriteString("These are REQUIREMENTS, not suggestions. Apply them to every line you write.\n")
b.WriteString("Examples: naming conventions, error wrapping, proper use of language idioms.\n")
b.WriteString("You may call search_docs for additional targeted searches if needed.\n")
} else if snippetsEnabled {
b.WriteString("\nIMPORTANT — Code conventions:\n")
b.WriteString("BEFORE editing a file, call snippets(paths=[<file_path>]) to get conventions for that file.\n")
b.WriteString("Follow ALL matched conventions (naming, structure, patterns, error handling).\n")
}
case RoleReviewer:
ltc := langToolsForLang(progLang)
b.WriteString("You are the REVIEWER agent.\n")
b.WriteString("Your job is to review changes made by the executor.\n\n")
if ragEnabled {
b.WriteString("IMPORTANT — Project rules and conventions (from RAG):\n")
fmt.Fprintf(&b, "The task context includes MANDATORY RULES marked [MANDATORY PROJECT RULES], %s marked [%s], and CODE TEMPLATES marked [CODE TEMPLATES].\n", ltc.standardsLabel, ltc.standardsLabel)
b.WriteString("ALL of these are REQUIREMENTS. Flag every violation as a bug with file:line reference.\n")
b.WriteString("CODE TEMPLATES define how specific patterns (validation, error handling, constructors, etc.) MUST be implemented in this project.\n")
b.WriteString("If changed code does something that a CODE TEMPLATE covers, the code MUST follow that template. Deviations are bugs.\n")
b.WriteString("You MUST call search_docs for each changed file to check if there are additional code templates or conventions that apply.\n\n")
} else if snippetsEnabled {
b.WriteString("IMPORTANT — Documentation check (MANDATORY):\n")
b.WriteString("BEFORE reviewing code, call snippets(paths=[<list of changed files>])\n")
b.WriteString("to get the project's code conventions. Use found conventions as the review criteria.\n\n")
}
b.WriteString("Guidelines:\n")
b.WriteString("- Read the changed files using read-only tools\n")
b.WriteString("- Check for bugs, logic errors, and edge cases\n")
fmt.Fprintf(&b, "- Check for %s best practices and project conventions\n", ltc.displayName)
b.WriteString("- Check for security issues\n")
b.WriteString("- Verify error handling\n")
b.WriteString("- If changes look good, say so clearly\n")
b.WriteString("- If there are issues, list them with specific file:line references\n")
b.WriteString("- Be concise — only flag real issues, not style preferences\n")
fmt.Fprintf(&b, "- Always respond in %s\n", langName(lang))
case RoleExtractor:
fmt.Fprintf(&b, "CRITICAL: All natural-language text inside the JSON MUST be written in %s. Never switch to any other language.\n\n", langName(lang))
b.WriteString("You are the PLAN EXTRACTOR agent.\n")
b.WriteString("Your job is to convert raw planner output into a strict JSON plan.\n\n")
b.WriteString("Input may be one of two kinds:\n")
b.WriteString("1. IMPLEMENTATION PLAN — numbered steps describing files to create/edit/delete (action items, not issues).\n")
b.WriteString("2. REVIEW / ANALYSIS — scattered review comments, findings, and suggestions.\n")
b.WriteString("You MUST handle BOTH kinds. An implementation plan with concrete steps is NOT \"no issues found\" — convert each step into a plan item.\n\n")
b.WriteString("You MUST output ONLY a single JSON object matching this schema (no markdown fences, no prose, no trailing text):\n\n")
b.WriteString("{\n")
b.WriteString(" \"context\": \"<detailed description of the goal preserving ALL specifics: data structures, field names, formats, requirements, constraints>\",\n")
b.WriteString(" \"plan\": [\n")
b.WriteString(" {\n")
b.WriteString(" \"file\": \"<relative/path/to/file.ext>\",\n")
b.WriteString(" \"line\": <integer, 0 for new files>,\n")
b.WriteString(" \"severity\": \"blocker\" | \"major\" | \"minor\" | \"task\",\n")
b.WriteString(" \"what\": \"<short description of what to do>\",\n")
b.WriteString(" \"context\": \"<all details the executor needs to complete this item independently: data structures, formats, requirements, constraints. The executor cannot see the original task>\",\n")
b.WriteString(" \"why\": \"<why this is needed>\",\n")
b.WriteString(" \"fix\": \"<concrete action or fix>\",\n")
b.WriteString(" \"before\": \"<existing code snippet (optional, reviews only)>\",\n")
b.WriteString(" \"after\": \"<corrected code snippet (optional, reviews only)>\",\n")
b.WriteString(" \"rules\": [\"<rule name>\", ...]\n")
b.WriteString(" }\n")
b.WriteString(" ],\n")
b.WriteString(" \"affected_files\": [\"<relative/path/to/file.ext>\", ...],\n")
b.WriteString(" \"verification\": [\"<verification step, e.g. 'make build', 'make lint', 'make test-unit'>\", ...]\n")
b.WriteString("}\n\n")
b.WriteString("STRICT RULES:\n")
b.WriteString("- Output ONLY the JSON object. No markdown, no code fences, no comments, no explanations before/after.\n")
b.WriteString("- Every plan item MUST have `file`, `line`, `severity`, `what`, and `fix` fields.\n")
b.WriteString("- `why`, `before`, `after`, `rules` are optional — include only when relevant.\n")
b.WriteString("- `before`/`after` should be short inline code (single expression or statement).\n")
b.WriteString("- SEVERITY: for review findings use `blocker`/`major`/`minor`. For implementation steps use `task`.\n")
b.WriteString("- Describe WHAT must be done concisely. Describe WHY separately.\n")
b.WriteString("- Do NOT add items not present in the original plan. Do NOT invent work.\n")
b.WriteString("- Deduplicate: if the same file appears in multiple steps with related actions, emit it once.\n")
b.WriteString("- Group by file: if multiple actions affect the same file, emit ONE plan item per file. Combine all actions for that file in the `what` field, separated by semicolons.\n")
b.WriteString("- Sort `plan` by file path, then by line number.\n")
b.WriteString("- `affected_files` — list ALL unique file paths mentioned in `plan` items.\n")
b.WriteString("- `verification` — list concrete shell commands to verify correctness (e.g. `make build`, `make lint`, `make test-unit`). Include at least build and lint. ONLY include commands that match the actual project stack — do NOT invent commands for tools/servers not present in the project.\n")
b.WriteString("- Output `{\"context\": \"LGTM — no issues found\", \"plan\": []}` ONLY when the input explicitly states there is nothing to do. An implementation plan with steps is NEVER \"no issues\".\n")
if lang == progLangGo {
b.WriteString("- DROP findings that contradict idiomatic Go conventions (Effective Go, Go Code Review Comments). For example, redundant nil/zero-value checks after error handling are false positives — the (T, error) contract guarantees T is valid when err == nil.\n")
}
fmt.Fprintf(&b, "- The `context` field and all text fields MUST be written in %s.\n", langName(lang))
b.WriteString("\nReturn ONLY the JSON object.\n")
case RoleStructurer:
b.WriteString("You are the PLAN STRUCTURER agent.\n")
b.WriteString("Your only job is to convert a human-readable plan into a strict JSON document.\n\n")
b.WriteString("You MUST output ONLY a single JSON object, no markdown fences, no prose, no explanation.\n\n")
b.WriteString("Schema:\n")
b.WriteString("{\n")
b.WriteString(" \"context\": \"<comprehensive summary: project purpose, tech stack, architecture, key requirements, constraints AND explicit prohibitions from the task. Multiple paragraphs are OK — the executor sees ONLY this context, not the original task>\",\n")
b.WriteString(" \"steps\": [\n")
b.WriteString(" {\n")
b.WriteString(" \"id\": <integer, starting at 1>,\n")
b.WriteString(" \"title\": \"<short imperative sentence>\",\n")
b.WriteString(" \"context\": \"<ALL details from the task needed to complete THIS step: data structures, field names, formats, types, constraints, visual/behavioral requirements, related components. Minimum 2-3 sentences even for simple steps. The executor sees ONLY this context — not the original task>\",\n")
b.WriteString(" \"files\": [\"<relative file path>\", ...],\n")
b.WriteString(" \"action\": \"edit\" | \"create\" | \"delete\" | \"run\",\n")
b.WriteString(" \"rationale\": \"<why this step is needed>\",\n")
b.WriteString(" \"depends_on\": [<id>, ...],\n")
b.WriteString(" \"rule_names\": [\"<rule name referenced in the plan>\", ...],\n")
b.WriteString(" \"examples\": [{\"file\": \"<path>\", \"line\": <N>, \"note\": \"<why this is the correct pattern>\"}]\n")
b.WriteString(" }\n")
b.WriteString(" ]\n")
b.WriteString("}\n\n")
b.WriteString("Rules:\n")
b.WriteString("- One step per atomic change. If the plan touches three files in three independent ways, emit three steps.\n")
b.WriteString("- `files` is the EXACT list of files that step modifies. Do not include files only mentioned for context.\n")
b.WriteString("- `depends_on` lists step ids that must complete first. Use [] for steps that can run in parallel with others.\n")
b.WriteString("- Two steps that touch the same file MUST be ordered with depends_on; never let two parallel steps fight over the same file.\n")
b.WriteString("- `context` is MANDATORY and must be DETAILED. Include ALL specifics from the original task that the executor needs for this step: field names, data types, formats, validation rules, business logic, visual/behavioral requirements. The executor cannot see the original task — this is its only source of requirements. Even simple steps need project-level context. BAD: 'Create .gitignore file'. GOOD: 'Create .gitignore for a Python/HTMX project using Flask: exclude __pycache__/, .env, .venv/, node_modules/, *.pyc, .DS_Store. The project serves HTMX partials from templates/ and static files from static/.'.\n")
b.WriteString("- `rule_names` is optional. Include rule names mentioned in the plan text for this step (e.g. \"service\", \"validator\", \"tests\"). Do not fabricate rule names.\n")
b.WriteString("- `examples` is an optional array. Include when the original plan has EXAMPLE: lines. Each example points to existing code demonstrating the correct pattern. Do not fabricate examples.\n")
b.WriteString("- Preserve the order and intent of the original plan exactly. Do not invent new work.\n")
b.WriteString("- Output ONLY the JSON object. No comments, no markdown, no trailing text.\n")
b.WriteString("- The plan may contain a \"## Verification\" or \"## Post-execution verification\" section with shell commands (e.g. `make build`). Do NOT convert these into steps — they are post-execution checks handled by the verifier, not executor tasks.\n")
case RoleVerifier:
ltc := langToolsForLang(progLang)
b.WriteString("You are the VERIFIER agent.\n")
b.WriteString("Your job is to check whether an approved plan was FULLY and CORRECTLY implemented.\n\n")
b.WriteString("You receive:\n")
b.WriteString("1. The ORIGINAL TASK that was requested\n")
b.WriteString("2. The APPROVED PLAN with numbered steps\n")
b.WriteString("3. The EXECUTION STATS (files changed, lines added/removed)\n\n")
b.WriteString("CRITICAL WORKFLOW:\n")
b.WriteString("- The source code is PROVIDED in the task. Do NOT call read_file, list_dir, find_files, or grep.\n")
b.WriteString("- For each step in the approved plan, check the provided source code\n")
b.WriteString("- Check whether the change described in the step was actually applied\n")
b.WriteString("- Check whether the change was applied correctly (not just present, but correct)\n")
b.WriteString("- If the plan included verification steps (build, lint, test) that FAILED during execution, flag them as incomplete\n\n")
b.WriteString("OUTPUT FORMAT:\n")
b.WriteString("- If ALL steps were implemented correctly, respond with exactly: VERIFIED\n")
b.WriteString("- If any steps are missing or incorrect, output ONLY the incomplete/incorrect items as a numbered plan:\n")
b.WriteString(" N. <what is missing or wrong>\n")
b.WriteString(" file: <path>\n")
b.WriteString(" context: <all details needed to fix it>\n")
b.WriteString(" rationale: <what the plan said vs what was actually done>\n\n")
b.WriteString("STRICT RULES:\n")
b.WriteString("- Do NOT invent new work. Only flag items that were in the approved plan but not implemented.\n")
b.WriteString("- Do NOT flag style issues, improvements, or optimizations not in the plan.\n")
b.WriteString("- Do NOT flag items that were completed correctly.\n")
b.WriteString("- Be concise and actionable.\n")
if ltc.buildTool != "" {
fmt.Fprintf(&b, "- ALWAYS run %s to verify compilation. If it fails, flag the build as incomplete — do NOT try to fix it yourself.\n", ltc.buildTool)
}
fmt.Fprintf(&b, "- Always respond in %s\n", langName(lang))
if ragEnabled {
b.WriteString("\nIMPORTANT — Project rules and conventions (from RAG):\n")
fmt.Fprintf(&b, "The task context includes MANDATORY RULES marked [MANDATORY PROJECT RULES] and %s marked [%s].\n", ltc.standardsLabel, ltc.standardsLabel)
b.WriteString("Check that implemented code follows these rules. Flag violations only if the plan required compliance.\n")
b.WriteString("You may call search_docs for additional targeted searches if needed.\n")
} else if snippetsEnabled {
b.WriteString("\nIMPORTANT — Code conventions check:\n")
b.WriteString("Call snippets(paths=[<list of changed files>]) to get the project's code conventions.\n")
b.WriteString("Check that implemented code follows these conventions. Flag violations only if the plan required compliance.\n")
}
case RoleCodeReviewer:
b.WriteString(codeReviewerPrompt(lang))
case RoleArchReviewer:
b.WriteString(archReviewerPrompt(lang))
case RoleResponseClassifier:
b.WriteString("You are the RESPONSE CLASSIFIER agent.\n")
b.WriteString("You receive a USER_INPUT (what the user asked) and an AGENT_RESPONSE (what another agent already produced).\n")
b.WriteString("Your only job is to classify the AGENT_RESPONSE and decide whether the user must take a follow-up action.\n\n")
b.WriteString("You MUST output ONLY a single JSON object, with no surrounding prose, no markdown fences, no comments.\n")
b.WriteString("Schema:\n")
b.WriteString("{\n")
b.WriteString(" \"kind\": \"plan\" | \"question_answer\" | \"clarification_request\" | \"status\" | \"other\",\n")
b.WriteString(" \"needs_user_action\": true | false,\n")
b.WriteString(" \"suggested_action\": \"approve_plan\" | \"answer_question\" | \"none\",\n")
b.WriteString(" \"cta_text\": \"<short call-to-action sentence in the user's language, or empty string>\"\n")
b.WriteString("}\n\n")
b.WriteString("Rules for classification:\n")
b.WriteString("- kind=\"plan\" when AGENT_RESPONSE contains a numbered or bulleted list of concrete code/file changes the agent intends to make. Set needs_user_action=true and suggested_action=\"approve_plan\".\n")
b.WriteString("- kind=\"question_answer\" when AGENT_RESPONSE only explains, describes, or answers a question without proposing changes. Set needs_user_action=false and suggested_action=\"none\".\n")
b.WriteString("- kind=\"clarification_request\" when AGENT_RESPONSE asks the user for more information. Set needs_user_action=true and suggested_action=\"answer_question\".\n")
b.WriteString("- kind=\"status\" for short progress/acknowledgement messages. Set needs_user_action=false.\n")
b.WriteString("- kind=\"other\" for anything that does not fit. Set needs_user_action=false.\n")
b.WriteString("- cta_text MUST be empty unless suggested_action=\"approve_plan\". For approve_plan, write a single short sentence inviting the user to confirm starting the implementation.\n")
fmt.Fprintf(&b, "- The cta_text MUST be in %s.\n", langName(lang))
b.WriteString("\nReturn ONLY the JSON object. No explanation. No markdown.\n")
}
return b.String()
}
// langToolHints holds language-specific tool names and labels for system prompts.
type langToolHints struct {
structureTool string // e.g. "go_structure"; "" when unavailable
buildTool string // e.g. "go_build", "tsc", "python_run"
lintTool string // e.g. "go_lint", "eslint", "ruff"
testTool string // e.g. "go_test", "npm_test", "pytest"
standardsLabel string // e.g. "GO STANDARDS", "JS/TS STANDARDS"
bestPractices string // e.g. "Use idiomatic Go, Go best practices..."
displayName string // e.g. "Go", "Python", "JavaScript/TypeScript"
}
func langToolsForLang(progLang string) langToolHints {
switch progLang {
case progLangGo:
return langToolHints{
structureTool: "go_structure",
buildTool: "go_build",
lintTool: "go_lint",
testTool: "go_test",
standardsLabel: "GO STANDARDS",
bestPractices: "Use idiomatic Go, Go best practices and project conventions",
displayName: "Go",
}
case progLangPython:
return langToolHints{
buildTool: "python_run",
lintTool: "ruff",
testTool: "pytest",
standardsLabel: "PYTHON STANDARDS",
bestPractices: "Use idiomatic Python, PEP 8, and project conventions",
displayName: "Python",
}
case progLangJSTS:
return langToolHints{
buildTool: "tsc",
lintTool: "eslint",
testTool: "npm_test",
standardsLabel: "JS/TS STANDARDS",
bestPractices: "Use idiomatic JavaScript/TypeScript and project conventions",
displayName: "JavaScript/TypeScript",
}
default:
return langToolHints{
standardsLabel: "PROJECT STANDARDS",
bestPractices: "Use idiomatic code, best practices and project conventions",
displayName: "language",
}
}
}
// reviewChecks returns named sets of review checks used by code and
// architecture reviewer prompts.
func reviewChecks(lang string) map[string][]string {
ln := langName(lang)
return map[string][]string{
"rules": {
"Violations of rules under .kodrun/rules/* and any pre-fetched [MANDATORY PROJECT RULES].",
"Naming conventions for the project's language: identifiers, getters, error types, interfaces, files.",
"Error reporting follows the project's chosen helper/style (wrapping, sentinels, error types).",
"Public API shape matches project conventions (exported vs internal, argument order, return values).",
"Package/module-level conventions (comment style, file layout, documentation).",
},
"idiomatic": {
fmt.Sprintf("Idiomatic %s: standard patterns, early returns, proper use of language primitives.", ln),
"Prefer language/standard-library features over hand-rolled equivalents.",
"Appropriate use of abstractions (interfaces, traits, protocols, generics) — not over- nor under-used.",
"Avoid anti-patterns: unnecessary concurrency, reflection/meta-programming when not needed, premature generalisation.",
fmt.Sprintf("Code that would make an experienced %s reviewer pause or rewrite.", ln),
},
"best_practice": {
"Errors are checked, enriched with context, and never silently swallowed.",
"Resource handling: deterministic cleanup, context/cancellation propagation, no goroutine/task/channel leaks.",
"Performance footguns: unbounded allocations in hot paths, O(n^2) where O(n) suffices, unnecessary copies.",
"Concurrency correctness: data races, missing synchronisation, incorrect use of primitives.",
"Logging hygiene: no secrets, appropriate levels, structured fields where applicable.",
},
"security": {
"Input validation at every trust boundary (CLI args, HTTP/RPC handlers, file paths from users).",
"No secrets in code, logs, or error messages.",
"Injection risks: SQL, shell/command, template, header, log — only safe APIs / parameterised queries.",
"Path traversal: reject `..` and absolute paths that escape the working directory.",
"Cryptography: stdlib only, no homegrown crypto, secure random source, no broken algorithms (MD5/SHA1 for security, DES, etc.).",
"Authentication / authorisation checks are present where required and cannot be bypassed.",
},
"structure": {
"New files are placed in the correct package/module/layer per project layout.",
"Package responsibilities stay cohesive; no unrelated concerns bundled together.",
"Dependency directions respect documented layers (no cycles, no inward→outward imports).",
"Public vs internal symbols: exported surface kept minimal; internal boundaries respected.",
"No duplication of existing utilities — flag cases where an existing helper should be reused.",
},
"architecture": {
"Changes fit the documented architecture (project rules, architecture/overview RAG chunks).",
"Forbidden cross-layer dependencies, skipping abstractions, leaking implementation details upward.",
"Missing or mis-placed components (e.g. business logic in transport layer, transport in domain).",
"Impact on system-wide concerns: transactions, error propagation across layers, extensibility points.",
"Whether the change introduces architectural drift that should be discussed before merging.",
},
}
}
// codeReviewerPrompt returns the system prompt body for the per-file code
// reviewer. All context is pre-loaded by the orchestrator — no tool-calling.
func codeReviewerPrompt(lang string) string {
var b strings.Builder
ln := langName(lang)
b.WriteString("You are a STRICT CODE REVIEWER.\n")
b.WriteString("The file content, project conventions, and dependency signatures are provided in the task.\n")
b.WriteString("You do NOT need to call any tools — everything you need is already included.\n\n")
b.WriteString("IMPORTANT — Technology scope:\n")
b.WriteString("If a convention or template mentions a technology, framework, or library that is NOT present in the file's imports or dependency signatures, SKIP that convention entirely. Only apply conventions for technologies the code actually uses.\n\n")
b.WriteString("Focus areas (ALL apply — check every one):\n")
checks := reviewChecks(lang)
for _, key := range []string{"rules", "idiomatic", "best_practice", "security"} {
for _, c := range checks[key] {
b.WriteString("- ")
b.WriteString(c)
b.WriteByte('\n')
}
}
b.WriteByte('\n')
b.WriteString("Output format (STRICT):\n")
b.WriteString("- For every real issue use a multi-line block:\n")
b.WriteString("```\n")
b.WriteString("path/to/file:LINE — SEVERITY\n")
b.WriteString("WHAT: short description of the problem\n")
b.WriteString("WHY: why this must be fixed\n")
b.WriteString("FIX: concrete fix suggestion\n")
b.WriteString("BEFORE: `existing code`\n")
b.WriteString("AFTER: `corrected code`\n")
b.WriteString("RULES: comma-separated rule names\n")
b.WriteString("```\n")
b.WriteString("- WHAT and FIX are mandatory. WHY, BEFORE, AFTER, RULES are optional — include only when relevant.\n")
b.WriteString("- BEFORE/AFTER should be short inline code (single expression or statement), not multi-line blocks.\n")
b.WriteString("- RULES must reference ONLY rule names that appear in the 'Available project rules' list above. If no matching rule exists, omit the RULES line entirely. NEVER invent rule names.\n")
b.WriteString("- SEVERITY ∈ {blocker, major, minor}. No prose outside these blocks.\n")
b.WriteString("- If you find nothing, output exactly: `NO_ISSUES`.\n")
b.WriteString("- Be strict: flag only real issues, not style preferences or speculation.\n")
b.WriteString("- Do NOT produce Summary / Verdict / Cross-cutting sections.\n")
fmt.Fprintf(&b, "- Write descriptions in %s.\n", ln)
return b.String()
}
// archReviewerPrompt returns the system prompt body for the project-wide
// architecture reviewer. Receives go_structure output for all packages — no
// file contents, no tool-calling.
func archReviewerPrompt(lang string) string {
var b strings.Builder
ln := langName(lang)
b.WriteString("You are an ARCHITECTURE REVIEWER.\n")
b.WriteString("You receive the structural outline (types, functions, interfaces) of every package in the project.\n")
b.WriteString("You do NOT need to call any tools — the full project structure is provided.\n\n")
b.WriteString("IMPORTANT — Technology scope:\n")
b.WriteString("If a convention or template mentions a technology, framework, or library that is NOT present in the project's imports, SKIP that convention entirely. Only apply conventions for technologies the project actually uses.\n\n")
b.WriteString("Focus areas:\n")
checks := reviewChecks(lang)
for _, key := range []string{"structure", "architecture"} {
for _, c := range checks[key] {
b.WriteString("- ")
b.WriteString(c)
b.WriteByte('\n')
}
}
b.WriteByte('\n')
b.WriteString("Output format (STRICT):\n")
b.WriteString("- For every real issue use a multi-line block:\n")
b.WriteString("```\n")
b.WriteString("path/to/package — SEVERITY\n")
b.WriteString("WHAT: short description of the problem\n")
b.WriteString("WHY: why this must be fixed\n")
b.WriteString("FIX: concrete suggestion\n")
b.WriteString("RULES: comma-separated rule names\n")
b.WriteString("```\n")
b.WriteString("- WHAT and FIX are mandatory. WHY, RULES are optional — include only when relevant.\n")
b.WriteString("- RULES must reference ONLY rule names that appear in the 'Available project rules' list above. If no matching rule exists, omit the RULES line entirely. NEVER invent rule names.\n")
b.WriteString("- SEVERITY ∈ {blocker, major, minor}. No prose outside these blocks.\n")
b.WriteString("- If you find nothing, output exactly: `NO_ISSUES`.\n")
b.WriteString("- Focus on structural and architectural issues only — not code-level bugs.\n")
b.WriteString("- Do NOT produce Summary / Verdict / Cross-cutting sections.\n")
fmt.Fprintf(&b, "- Write descriptions in %s.\n", ln)
return b.String()
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
)
const sessionFilePermission = 0o600
// Session represents a saved conversation session.
type Session struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Model string `json:"model"`
Mode string `json:"mode"`
Messages []llm.Message `json:"messages"`
Plan string `json:"plan,omitempty"`
Stats SessionStats `json:"stats"`
WorkDir string `json:"work_dir"`
}
// SessionSummary is a lightweight view of a session for listing.
type SessionSummary struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Model string `json:"model"`
Mode string `json:"mode"`
MessageCount int `json:"message_count"`
}
const (
sessionIDRandomBytes = 3 // bytes of randomness in session ID
sessionDirPermission = 0o755
)
// NewSessionID generates a short unique ID like "20260404-abc123".
func NewSessionID() string {
ts := time.Now().Format("20060102")
b := make([]byte, sessionIDRandomBytes)
if _, err := rand.Read(b); err != nil {
// Fallback to timestamp-based suffix if crypto/rand fails.
return ts + "-" + time.Now().Format("150405")
}
return ts + "-" + hex.EncodeToString(b)
}
// SaveSession writes the session to sessionsDir/{id}.json.
func SaveSession(sessionsDir string, session *Session) error {
if err := os.MkdirAll(sessionsDir, sessionDirPermission); err != nil {
return errors.WithMessage(err, "create sessions directory")
}
session.UpdatedAt = time.Now()
data, err := json.MarshalIndent(session, "", " ")
if err != nil {
return errors.WithMessage(err, "marshal session")
}
path := filepath.Join(sessionsDir, session.ID+".json")
if err := os.WriteFile(path, data, sessionFilePermission); err != nil {
return errors.WithMessage(err, "write session file")
}
return nil
}
// LoadSession reads a session by ID from sessionsDir.
func LoadSession(sessionsDir, id string) (*Session, error) {
path := filepath.Join(sessionsDir, id+".json")
data, err := os.ReadFile(path)
if err != nil {
return nil, errors.WithMessage(err, "read session file")
}
var s Session
if err := json.Unmarshal(data, &s); err != nil {
return nil, errors.WithMessage(err, "unmarshal session")
}
return &s, nil
}
// ListSessions returns summaries of all saved sessions, sorted by UpdatedAt descending.
func ListSessions(sessionsDir string) ([]*SessionSummary, error) {
entries, err := os.ReadDir(sessionsDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, errors.WithMessage(err, "read sessions directory")
}
summaries := make([]*SessionSummary, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") {
continue
}
id := strings.TrimSuffix(entry.Name(), ".json")
s, err := LoadSession(sessionsDir, id)
if err != nil {
slog.Warn("kodrun: skipping unreadable session", "id", id, "error", err)
continue
}
summaries = append(summaries, &SessionSummary{
ID: s.ID,
CreatedAt: s.CreatedAt,
UpdatedAt: s.UpdatedAt,
Model: s.Model,
Mode: s.Mode,
MessageCount: len(s.Messages),
})
}
sort.Slice(summaries, func(i, j int) bool {
return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
})
return summaries, nil
}
// LatestSession loads the most recently updated session from sessionsDir.
func LatestSession(sessionsDir string) (*Session, error) {
summaries, err := ListSessions(sessionsDir)
if err != nil {
return nil, err
}
if len(summaries) == 0 {
return nil, errors.New("no sessions found")
}
return LoadSession(sessionsDir, summaries[0].ID)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import "time"
// sessionStore owns the on-disk persistence settings for the agent. When dir
// is empty auto-save is disabled. The id is generated lazily by SetDir or
// supplied explicitly by SetID when restoring a previous session.
type sessionStore struct {
dir string
id string
}
// SetDir enables auto-save and stores sessions in dir. Generates a fresh ID
// when none was set, so subsequent saves overwrite the same file.
func (s *sessionStore) SetDir(dir string) {
s.dir = dir
if s.id == "" {
s.id = NewSessionID()
}
}
// SetID overrides the session ID, used when resuming a saved session.
func (s *sessionStore) SetID(id string) {
s.id = id
}
// ID returns the current session ID. Empty when no session has been started.
func (s *sessionStore) ID() string {
return s.id
}
// Dir returns the directory used for session files. Empty when auto-save is
// disabled.
func (s *sessionStore) Dir() string {
return s.dir
}
// save persists the supplied session under the configured directory and ID.
// Returns nil when auto-save is disabled. Preserves CreatedAt from any
// existing on-disk session, falling back to time.Now for fresh sessions.
func (s *sessionStore) save(session *Session) error {
if s.dir == "" {
return nil
}
session.ID = s.id
if existing, err := LoadSession(s.dir, s.id); err == nil {
session.CreatedAt = existing.CreatedAt
} else {
session.CreatedAt = time.Now()
}
return SaveSession(s.dir, session)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
defaultSnippetMaxLines = 15
defaultBudgetLines = 60
snippetContext = 3 // lines before the target line
)
// extractSnippet reads a region of a file and returns a formatted code block
// with line numbers. When ex.Line is zero the first maxLines of the file are
// returned. On any I/O error an empty string is returned (non-fatal).
func extractSnippet(workDir string, ex Example, maxLines int) string {
if maxLines <= 0 {
maxLines = defaultSnippetMaxLines
}
path := filepath.Join(workDir, ex.File)
f, err := os.Open(path)
if err != nil {
return ""
}
defer f.Close()
startLine := 1
if ex.Line > 0 {
startLine = ex.Line - snippetContext
if startLine < 1 {
startLine = 1
}
}
endLine := startLine + maxLines - 1
var b strings.Builder
scanner := bufio.NewScanner(f)
lineNum := 0
collected := 0
for scanner.Scan() {
lineNum++
if lineNum < startLine {
continue
}
if lineNum > endLine {
break
}
fmt.Fprintf(&b, "%d | %s\n", lineNum, scanner.Text())
collected++
}
if collected == 0 {
return ""
}
note := ex.Note
if note == "" {
note = "reference"
}
header := fmt.Sprintf("--- %s:%d (%s) ---\n", ex.File, ex.Line, note)
return header + b.String() + "---\n"
}
// formatStepExamples collects snippets for all examples in a step, respecting
// a total line budget. Returns an empty string when no snippets could be
// resolved.
func formatStepExamples(workDir string, examples []Example, budgetLines int) string {
if len(examples) == 0 {
return ""
}
if budgetLines <= 0 {
budgetLines = defaultBudgetLines
}
parts := make([]string, 0, len(examples))
totalLines := 0
for _, ex := range examples {
remaining := budgetLines - totalLines
if remaining <= 0 {
break
}
maxLines := defaultSnippetMaxLines
if maxLines > remaining {
maxLines = remaining
}
snippet := extractSnippet(workDir, ex, maxLines)
if snippet == "" {
continue
}
lines := strings.Count(snippet, "\n")
totalLines += lines
parts = append(parts, snippet)
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "\n")
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/rules"
)
// runStep executes one Step of a structured Plan via a fresh sub-agent.
//
// Each sub-agent has its own clean message history (no cross-talk between
// parallel steps), is locked to the step's Files via the read-path whitelist
// (Block 1), and shares the orchestrator's tool result cache so repeated
// reads are free across all sub-agents (Block 2).
//
// The returned error is non-nil only on a hard failure of the chat loop;
// REPLAN responses from the sub-agent are surfaced via EventReplan and
// reported as a normal completion to the DAG runner.
func (o *Orchestrator) runStep(ctx context.Context, step *Step, planContext string, confirmFn ConfirmFunc) (SessionStats, error) {
ag := o.newAgent(RoleExecutor, o.maxExecIter)
prompt := systemPromptForRole(RoleExecutor, o.language, o.progLang(), o.ruleCatalog, ag.reg.Names(), o.hasSnippets, o.hasRAG)
ag.InitWithPrompt(prompt)
ag.SetConfirmFunc(confirmFn)
// Lock the sub-agent to its declared files. The whitelist guard in
// agent.go refuses any read outside this set.
if len(step.Files) > 0 {
ag.SetAllowedReadPaths(step.Files)
}
// Build a compact per-step task payload. The sub-agent receives the
// plan-level goal so it understands the broader context, plus the
// source code of its declared files so it can edit without read_file.
var b strings.Builder
if planContext != "" {
fmt.Fprintf(&b, "## Goal\n%s\n\n", planContext)
}
fmt.Fprintf(&b, "## Step %d: %s\n\n", step.ID, step.Title)
if step.Context != "" {
fmt.Fprintf(&b, "Context: %s\n", step.Context)
}
if step.Action != "" {
fmt.Fprintf(&b, "Action: %s\n", step.Action)
}
if step.Rationale != "" {
fmt.Fprintf(&b, "Rationale: %s\n", step.Rationale)
}
if len(step.Files) > 0 {
fmt.Fprintf(&b, "Files: %s\n", strings.Join(step.Files, ", "))
}
b.WriteString("\nApply the change and stop. Do not touch any file outside the Files list.\n")
// Pre-read source code of step files so the executor can edit
// immediately without wasting iterations on read_file calls.
if code := collectStepFiles(o.workDir, step.Files); code != "" {
b.WriteString("\n## Source Code (already read — do NOT call read_file)\n")
b.WriteString(code)
}
// Resolve reference examples from disk and inject into the prompt.
if len(step.Examples) > 0 {
snippets := formatStepExamples(o.workDir, step.Examples, defaultBudgetLines)
if snippets != "" {
b.WriteString("\n## Examples\nCode from this project demonstrating the correct pattern:\n\n")
b.WriteString(snippets)
}
}
// Per-step RAG bundle: prefer the bundle pre-computed by runPlanDAG so
// parallel sub-agents share one set of embedding calls instead of N. The
// live fallback covers non-DAG callers.
var ragBundle string
if o.stepRAGBundles != nil {
ragBundle = o.stepRAGBundles[step.ID]
} else {
ragBundle = o.perStepRAG(ctx, step)
}
if ragBundle != "" {
b.WriteString("\n")
b.WriteString(ragBundle)
}
if err := ag.Send(ctx, b.String()); err != nil && !errors.Is(err, ErrMaxIterations) {
return ag.Stats(), err
}
stats := ag.Stats()
// Surface REPLAN if the sub-agent asked for one.
last := ag.LastAssistantMessage()
if strings.Contains(last, "REPLAN:") {
o.emit(&Event{Type: EventReplan, Message: extractReplanReason(last)})
return stats, nil
}
// A step that finished with zero tool calls and no REPLAN is a model
// failure: the executor wrote prose instead of applying the change.
// Surface it loudly so the orchestrator/CI does not treat the step as
// successful when nothing actually happened on disk.
if stats.ToolCalls == 0 {
o.emit(&Event{Type: EventError, Message: fmt.Sprintf("Step %d (%s) finished with zero tool calls — model returned text instead of applying changes. Treating as failed.", step.ID, step.Title)})
return stats, errors.Errorf("step %d: no tool calls executed", step.ID)
}
return stats, nil
}
// collectStepFiles reads the source code of step files from disk and returns
// a formatted string suitable for injection into the executor prompt. Files
// that do not exist (e.g. action:"create") are silently skipped.
func collectStepFiles(workDir string, files []string) string {
var buf strings.Builder
for _, rel := range files {
abs := filepath.Join(workDir, rel)
data, err := os.ReadFile(abs)
if err != nil {
continue
}
lines := strings.Split(string(data), "\n")
fmt.Fprintf(&buf, "=== %s ===\n", rel)
for i, line := range lines {
fmt.Fprintf(&buf, "%d\t%s\n", i+1, line)
}
buf.WriteByte('\n')
}
return buf.String()
}
// perStepRAG builds a compact per-step RAG payload combining declared rule
// names and a focused search on the step title + rationale. Returns "" when
// nothing relevant is available.
func (o *Orchestrator) perStepRAG(ctx context.Context, step *Step) string {
var parts []string
if o.rulesLoader != nil {
for _, name := range step.RuleNames {
if content, err := o.rulesLoader.GetRuleContent(ctx, name, rules.ScopeAll); err == nil && content != "" {
parts = append(parts, fmt.Sprintf("--- RULE %s ---\n%s\n", name, content))
}
}
}
if o.hasRAG && o.ragIndex != nil {
query := strings.TrimSpace(step.Title + " " + step.Rationale)
if query != "" {
results, err := o.ragIndex.Search(ctx, query, perStepRAGTopK)
if err == nil && len(results) > 0 {
parts = append(parts, formatRAGResults(results))
}
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "\n")
}
// Compile-time guard so unused-import linters do not strip the ollama import
// when this file is the only consumer.
var _ = llm.Message{}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"fmt"
"maps"
"os"
"path/filepath"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/tools"
)
// toolExecutor runs tool calls on behalf of *Agent. It currently keeps a
// back-reference to its owning agent so methods can read mutable state
// (mode, disabled-tool sets, pool, etc.); later stages narrow this surface.
type toolExecutor struct {
reg *tools.Registry
permMgr *PermissionManager
workDir string
agent *Agent
}
// newToolExecutor builds a toolExecutor wired to the supplied registry and
// permission manager. The agent back-ref is attached separately via attach
// once the *Agent has been fully constructed.
func newToolExecutor(reg *tools.Registry, permMgr *PermissionManager, workDir string) *toolExecutor {
return &toolExecutor{
reg: reg,
permMgr: permMgr,
workDir: workDir,
}
}
// attach connects the executor to its owning agent. Must be called once after
// New() so methods can read live agent state.
func (te *toolExecutor) attach(a *Agent) {
te.agent = a
}
// readOnlyTools returns the executor's view of read-only tool names. Mirrors
// (a *Agent).readOnlyTools so future migrations can drop the back-reference.
func (te *toolExecutor) readOnlyTools() map[string]bool {
return readOnlyToolsFor(te.reg)
}
// toolDefsForMode returns the tool definitions to advertise for the agent's
// current mode, honouring toolsDisabled and the per-call disabled set.
func (te *toolExecutor) toolDefsForMode() []llm.ToolDef {
a := te.agent
if a.toolsDisabled {
return nil
}
var defs []llm.ToolDef
if a.mode == ModeEdit {
defs = te.reg.ToolDefs()
} else {
allowed := te.readOnlyTools()
if len(a.extraReadOnlyTools) > 0 {
merged := make(map[string]bool, len(allowed)+len(a.extraReadOnlyTools))
maps.Copy(merged, allowed)
maps.Copy(merged, a.extraReadOnlyTools)
defs = te.reg.ToolDefsFiltered(merged)
} else {
defs = te.reg.ToolDefsFiltered(allowed)
}
}
if len(a.disabledTools) == 0 {
return defs
}
filtered := defs[:0]
for i := range defs {
if !a.disabledTools[defs[i].Function.Name] {
filtered = append(filtered, defs[i])
}
}
return filtered
}
// canRunParallel reports whether toolName may run inside the worker pool.
// Only registered read-only tools that are not blocked qualify.
func (te *toolExecutor) canRunParallel(toolName string) bool {
a := te.agent
if a.pool == nil || cap(a.pool.sem) <= 1 {
return false
}
return (te.readOnlyTools()[toolName] || a.extraReadOnlyTools[toolName]) && !te.isToolBlocked(toolName)
}
// isToolBlocked reports whether the named tool is forbidden in the current
// agent mode (PLAN/CHAT block writes) or has been explicitly disabled.
func (te *toolExecutor) isToolBlocked(tool string) bool {
a := te.agent
if a.toolsDisabled {
return true
}
if a.disabledTools[tool] {
return true
}
if a.mode == ModeEdit {
return false
}
return !te.readOnlyTools()[tool] && !a.extraReadOnlyTools[tool]
}
// needsConfirm reports whether the named tool requires user confirmation
// before running. Built-in destructive tools always need confirm; the agent
// can opt additional tools in via extraConfirmTools.
func (te *toolExecutor) needsConfirm(tool string) bool {
switch tool {
case toolNameWriteFile, toolNameEditFile, toolNameDeleteFile, toolNameMoveFile, toolNameBash, toolNameGitCommit, toolNameWebFetch:
return true
}
return te.agent.extraConfirmTools[tool]
}
// buildConfirmPayload assembles the ConfirmPayload presented to the user for
// destructive tool calls. For edit/write tools it tries to read the current
// file content and produce a unified diff preview so the user can see the
// change before approving.
func (te *toolExecutor) buildConfirmPayload(ctx context.Context, name string, args map[string]any) ConfirmPayload {
p := ConfirmPayload{Tool: name, Args: map[string]string{}}
put := func(k, v string) {
p.Args[k] = v
p.ArgKeys = append(p.ArgKeys, k)
}
switch name {
case toolNameEditFile:
path := stringFromMap(args, "path")
oldStr := stringFromMap(args, "old_str")
newStr := stringFromMap(args, "new_str")
put("path", path)
if resolved, err := tools.SafePath(ctx, te.workDir, path); err == nil {
if data, err := os.ReadFile(resolved); err == nil {
content := string(data)
if strings.Contains(content, oldStr) {
newContent := strings.Replace(content, oldStr, newStr, 1)
p.Preview = tools.SimpleDiff(content, newContent, path, diffPreviewMaxLines)
}
}
}
if p.Preview == "" {
p.Preview = fallbackDiffPreview(oldStr, newStr, diffPreviewMaxLines)
}
case toolNameWriteFile:
path := stringFromMap(args, "path")
content := stringFromMap(args, "content")
put("path", path)
var oldContent string
existed := false
if resolved, err := tools.SafePath(ctx, te.workDir, path); err == nil {
if data, err := os.ReadFile(resolved); err == nil {
oldContent = string(data)
existed = true
}
}
if existed {
p.Preview = tools.SimpleDiff(oldContent, content, path, diffPreviewMaxLines)
} else {
p.Preview = previewNewFile(content, previewNewFileLines)
}
case toolNameDeleteFile:
path := stringFromMap(args, "path")
put("path", path)
case toolNameMoveFile:
from := stringFromMap(args, "from")
to := stringFromMap(args, "to")
put("from", from)
put("to", to)
case toolNameBash:
cmd := stringFromMap(args, "command")
put("command", cmd)
p.Danger = tools.IsDangerousCommand(cmd)
case toolNameGitCommit:
if msg, ok := args["message"].(string); ok {
put("message", msg)
}
case toolNameWebFetch:
if u := stringFromMap(args, "url"); u != "" {
put("url", u)
}
default:
for k, v := range args {
put(k, fmt.Sprintf("%v", v))
}
}
return p
}
// pathAllowed reports whether path is permitted by the agent's
// allowedReadPaths whitelist. An empty whitelist means unrestricted; a nil
// path or one outside the whitelist (by exact match or basename) is rejected.
func (te *toolExecutor) pathAllowed(path string) bool {
a := te.agent
if a.allowedReadPaths == nil {
return true
}
if path == "" {
return false
}
if _, ok := a.allowedReadPaths[path]; ok {
return true
}
base := filepath.Base(path)
if _, ok := a.allowedReadPaths[base]; ok {
return true
}
return false
}
// guardReadWhitelist returns a synthetic refusal result if the tool call is a
// read-only file tool targeting a path outside the current allowed-read
// whitelist. Returns nil when the call is permitted.
func (te *toolExecutor) guardReadWhitelist(tc llm.ToolCall) *tools.ToolResult {
if te.agent.allowedReadPaths == nil {
return nil
}
switch tc.Function.Name {
case toolNameReadFile, toolNameListDir, toolNameFindFiles, toolNameGrep:
default:
return nil
}
var p string
if v, ok := tc.Function.Arguments["path"].(string); ok {
p = v
} else if v, ok := tc.Function.Arguments["root"].(string); ok {
p = v
}
if p == "" {
return nil
}
if te.pathAllowed(p) {
return nil
}
msg := fmt.Sprintf(
"refused: %q is not in the executor whitelist for this plan. "+
"You may only read files listed in the approved plan. "+
"If you need additional context, output `REPLAN: <reason>` and stop.",
p,
)
return &tools.ToolResult{Output: msg}
}
// guardDuplicateRead returns a synthetic short result if read_file is called
// on a path the model has already read in the current Send() invocation.
// Returns nil otherwise. The synthetic result points the model back to the
// prior tool result in history, saving tokens spent re-emitting full file
// contents and discouraging the read-loop failure mode.
func (te *toolExecutor) guardDuplicateRead(tc llm.ToolCall) *tools.ToolResult {
if tc.Function.Name != toolNameReadFile {
return nil
}
p, ok := tc.Function.Arguments["path"].(string)
if !ok || p == "" {
return nil
}
if _, seen := te.agent.readPathsSession[p]; !seen {
return nil
}
msg := fmt.Sprintf(
"skipped: %q was already read earlier in this turn. "+
"Reuse the prior tool result from the conversation above. "+
"If the file was modified since, call edit_file/write_file first.",
p,
)
return &tools.ToolResult{Output: msg, Meta: map[string]any{"dedup_hit": true}}
}
// recordReadPath remembers a successful read_file path for dedup tracking.
func (te *toolExecutor) recordReadPath(tc llm.ToolCall) {
if tc.Function.Name != toolNameReadFile {
return
}
if p, ok := tc.Function.Arguments["path"].(string); ok && p != "" {
te.agent.readPathsSession[p] = struct{}{}
}
}
// clearReadPathOnWrite drops the dedup record for a path touched by a write
// tool, so a re-read after modification is allowed to fetch fresh content.
func (te *toolExecutor) clearReadPathOnWrite(tc llm.ToolCall) {
if te.readOnlyTools()[tc.Function.Name] {
return
}
if p, ok := tc.Function.Arguments["path"].(string); ok && p != "" {
delete(te.agent.readPathsSession, p)
}
}
// runResult is returned by toolExecutor.Run to drive Send()'s outer chat loop.
//
// AllBlocked — every sequential call was refused due to mode (PLAN/CHAT).
// HadParallel — at least one read-only call ran via the worker pool.
// Augmented — user rejected a call with a follow-up constraint; Send()
// must re-issue the prompt instead of advancing.
type runResult struct {
AllBlocked bool
HadParallel bool
Augmented bool
}
// Run executes one tool-call iteration: splits parallel/sequential, runs both
// groups, applies mode/confirm gating, and reports a runResult so Send() can
// advance the chat loop accordingly.
func (te *toolExecutor) Run(ctx context.Context, calls []llm.ToolCall) runResult {
a := te.agent
var parallelCalls, sequentialCalls []llm.ToolCall
for _, tc := range calls {
if te.canRunParallel(tc.Function.Name) {
parallelCalls = append(parallelCalls, tc)
} else {
sequentialCalls = append(sequentialCalls, tc)
}
}
res := runResult{
AllBlocked: len(sequentialCalls) > 0,
HadParallel: len(parallelCalls) > 0,
}
if res.HadParallel {
a.toolCallCount += len(parallelCalls)
te.executeParallel(ctx, parallelCalls)
}
for _, tc := range sequentialCalls {
if te.isToolBlocked(tc.Function.Name) {
a.history = append(a.history, llm.Message{
Role: "tool",
Content: fmt.Sprintf(
"Tool %q is NOT available in %s mode. Do NOT call write tools. Use ONLY: %s",
tc.Function.Name, a.mode.String(),
strings.Join(a.reg.NamesFiltered(a.readOnlyTools()), ", "),
),
ToolCallID: tc.ID,
})
a.emit(&Event{
Type: EventTool,
Tool: tc.Function.Name,
Message: fmt.Sprintf("blocked in %s mode", a.mode.String()),
Success: false,
})
continue
}
res.AllBlocked = false
detail := toolDetail(tc.Function.Name, tc.Function.Arguments)
a.emit(&Event{Type: EventTool, Tool: tc.Function.Name, Message: detail})
if te.needsConfirm(tc.Function.Name) && a.confirmFn != nil {
fp := Fingerprint(tc.Function.Name, tc.Function.Arguments)
if !te.permMgr.IsAllowed(fp) {
payload := te.buildConfirmPayload(ctx, tc.Function.Name, tc.Function.Arguments)
cr := a.confirmFn(payload)
switch cr.Action {
case ConfirmDeny:
a.history = append(a.history, llm.Message{
Role: "tool",
Content: "Operation denied by user",
ToolCallID: tc.ID,
})
a.emit(&Event{Type: EventTool, Tool: tc.Function.Name, Message: "denied by user", Success: false})
continue
case ConfirmAllowOnce:
case ConfirmAllowSession:
te.permMgr.AllowSession(fp)
case ConfirmAugment:
a.history = append(a.history, llm.Message{
Role: "tool",
Content: fmt.Sprintf("User rejected this call and provided a constraint: %s\nPlease rebuild this %s call accordingly.", cr.Augment, tc.Function.Name),
ToolCallID: tc.ID,
})
a.emit(&Event{Type: EventTool, Tool: tc.Function.Name, Message: "augmented: " + cr.Augment, Success: false})
res.Augmented = true
}
if res.Augmented {
return res
}
}
}
a.toolCallCount++
te.executeSingle(ctx, tc)
}
return res
}
// executeParallel runs read-only tool calls concurrently via the worker pool
// and appends results to history in the original order.
func (te *toolExecutor) executeParallel(ctx context.Context, calls []llm.ToolCall) {
a := te.agent
tasks := make([]TaskFunc, len(calls))
guards := make([]*tools.ToolResult, len(calls))
roTools := te.readOnlyTools()
for i, tc := range calls {
guards[i] = te.guardReadWhitelist(tc)
if guards[i] == nil {
guards[i] = te.guardDuplicateRead(tc)
}
name := tc.Function.Name
args := tc.Function.Arguments
idx := i
if name == toolNameReadFile {
a.hasCalledReadFile = true
}
if !roTools[name] {
a.hasCalledWriteTool = true
}
tasks[i] = func(ctx context.Context) (*tools.ToolResult, error) {
if guards[idx] != nil {
return guards[idx], nil
}
return te.reg.Execute(ctx, name, args)
}
}
results := a.pool.Execute(ctx, tasks)
for i, tr := range results {
tc := calls[i]
if tr.Err != nil {
a.emit(&Event{Type: EventError, Tool: tc.Function.Name, Message: tr.Err.Error()})
a.history = append(a.history, llm.Message{
Role: "tool",
Content: "Error: " + tr.Err.Error(),
ToolCallID: tc.ID,
})
continue
}
if guards[i] == nil {
te.recordReadPath(tc)
}
te.emitToolResult(tc, tr.Result)
}
}
// executeSingle runs a single tool call and records the result.
func (te *toolExecutor) executeSingle(ctx context.Context, tc llm.ToolCall) {
a := te.agent
if guard := te.guardReadWhitelist(tc); guard != nil {
a.emit(&Event{Type: EventTool, Tool: tc.Function.Name, Message: "blocked by whitelist", Success: false})
te.emitToolResult(tc, guard)
return
}
if guard := te.guardDuplicateRead(tc); guard != nil {
a.emit(&Event{Type: EventTool, Tool: tc.Function.Name, Message: "skipped: duplicate read", Success: true})
te.emitToolResult(tc, guard)
return
}
if tc.Function.Name == toolNameReadFile {
a.hasCalledReadFile = true
}
if !te.readOnlyTools()[tc.Function.Name] {
a.hasCalledWriteTool = true
}
result, err := te.reg.Execute(ctx, tc.Function.Name, tc.Function.Arguments)
if err != nil {
a.emit(&Event{Type: EventError, Tool: tc.Function.Name, Message: err.Error()})
a.history = append(a.history, llm.Message{
Role: "tool",
Content: "Error: " + err.Error(),
ToolCallID: tc.ID,
})
return
}
te.recordReadPath(tc)
te.clearReadPathOnWrite(tc)
te.emitToolResult(tc, result)
}
// emitToolResult emits an event and appends the tool result to history.
func (te *toolExecutor) emitToolResult(tc llm.ToolCall, result *tools.ToolResult) {
a := te.agent
const success = true
msg := truncate(result.Output, truncatePreviewLen)
if te.readOnlyTools()[tc.Function.Name] {
detail := toolDetail(tc.Function.Name, tc.Function.Arguments)
if detail != "" {
msg = detail
}
}
if msg == "" && result.Meta != nil {
if fl, ok := result.Meta["file_list"].([]string); ok && len(fl) > 0 {
msg = strings.Join(fl, ", ")
if len(msg) > fileListMaxLen {
msg = msg[:fileListMaxLen] + "..."
}
}
}
fullOutput := result.Output
if len(fullOutput) > maxToolResultBytes {
fullOutput = fullOutput[:maxToolResultBytes] + "\n... [truncated]"
}
ev := Event{
Type: EventTool,
Tool: tc.Function.Name,
Message: msg,
Success: success,
FullOutput: fullOutput,
}
if result.Meta != nil {
if v, ok := result.Meta["cache_hit"].(bool); ok && v {
ev.CacheHit = true
}
if v, ok := result.Meta["action"].(string); ok {
ev.FileAction = v
}
if v, ok := result.Meta["added"].(int); ok {
ev.LinesAdded = v
}
if v, ok := result.Meta["removed"].(int); ok {
ev.LinesRemoved = v
}
if v, ok := result.Meta["diff"].(string); ok {
ev.Diff = v
}
}
isCachedReadOnly := ev.CacheHit && te.readOnlyTools()[tc.Function.Name]
if !isCachedReadOnly {
a.emit(&ev)
}
a.stats.ToolCalls++
if ev.FileAction != "" {
filePath := stringFromMap(tc.Function.Arguments, "path")
a.stats.recordFileAction(ev.FileAction, filePath, ev.LinesAdded, ev.LinesRemoved)
}
resultContent := result.Output
if isCachedReadOnly {
resultContent = "[already read — see earlier tool result]"
} else if len(resultContent) > maxToolResultBytes {
resultContent = resultContent[:maxToolResultBytes] + "\n... [truncated, total " + fmt.Sprintf("%d", len(result.Output)) + " bytes]"
}
a.history = append(a.history, llm.Message{
Role: "tool",
Content: resultContent,
ToolCallID: tc.ID,
})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package agent
import (
"context"
"sync"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/tools"
)
// WorkerPool executes tasks concurrently with a bounded number of goroutines.
type WorkerPool struct {
sem chan struct{}
}
// NewWorkerPool creates a pool with the given concurrency limit.
// If maxWorkers <= 1, Execute runs tasks sequentially.
func NewWorkerPool(maxWorkers int) *WorkerPool {
if maxWorkers < 1 {
maxWorkers = 1
}
return &WorkerPool{
sem: make(chan struct{}, maxWorkers),
}
}
// TaskFunc is a function that executes a tool call and returns the result.
type TaskFunc func(ctx context.Context) (*tools.ToolResult, error)
// TaskResult holds the outcome of a single task, preserving its original index.
type TaskResult struct {
Index int
Result *tools.ToolResult
Err error
}
// Execute runs all tasks concurrently (up to pool limit) and returns results
// in the same order as the input tasks slice.
func (p *WorkerPool) Execute(ctx context.Context, tasks []TaskFunc) []TaskResult {
if len(tasks) == 0 {
return nil
}
results := make([]TaskResult, len(tasks))
// Sequential execution when pool size is 1.
if cap(p.sem) <= 1 {
for i, task := range tasks {
res, err := task(ctx)
results[i] = TaskResult{Index: i, Result: res, Err: err}
}
return results
}
var wg sync.WaitGroup
for i, task := range tasks {
// Try to acquire semaphore or bail on context cancel.
select {
case p.sem <- struct{}{}:
case <-ctx.Done():
for j := i; j < len(tasks); j++ {
results[j] = TaskResult{Index: j, Err: ctx.Err()}
}
wg.Wait()
return results
}
wg.Add(1)
// Pass i and task explicitly to the goroutine. While Go ≥1.22
// gives every iteration its own loop variable, explicit parameters
// document intent and stay correct under //go:build go1.21 or
// when this code is copy-pasted into a library with an older minimum.
go func(i int, task TaskFunc) {
defer func() {
if r := recover(); r != nil {
results[i] = TaskResult{Index: i, Err: errors.Errorf("task panic: %v", r)}
}
<-p.sem
wg.Done()
}()
res, err := task(ctx)
results[i] = TaskResult{Index: i, Result: res, Err: err}
}(i, task)
}
wg.Wait()
return results
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"fmt"
"strings"
"github.com/raoptimus/kodrun/internal/agent"
)
// actionCodeReview waits for any in-flight RAG indexing, collects the
// review diff (optionally scoped to a package) and dispatches into the
// parallel-specialist code review pipeline.
func actionCodeReview(taskCtx context.Context, d *TaskDeps, parts []string) {
if d.Indexing.Load() {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "Waiting for RAG indexing to finish before review..."})
WaitForRAGReady(taskCtx, d.Indexing, ragWaitTimeout)
}
diffArgs, packageScope := parseCodeReviewArgs(parts)
if d.Flags.Verbose {
if packageScope != "" {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("Collecting diff for code review (scope: %s)...", packageScope)})
} else {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "Collecting diff for code review..."})
}
}
diffOut, err := GitDiff(taskCtx, d.Flags.WorkDir, diffArgs)
if err != nil {
d.Emit(&agent.Event{Type: agent.EventError, Message: err.Error()})
d.Emit(&agent.Event{Type: agent.EventDone})
return
}
if packageScope != "" {
diffOut = FilterDiffByPackage(diffOut, packageScope)
}
if strings.TrimSpace(diffOut) == "" {
if packageScope != "" {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("No changes to review under %q.", packageScope)})
} else {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "No changes to review."})
}
d.Emit(&agent.Event{Type: agent.EventDone})
return
}
RunCodeReview(taskCtx, &CodeReviewParams{
WorkDir: d.Flags.WorkDir,
Cfg: d.Cfg,
Client: d.Client,
ChatProv: d.ChatProv,
Registry: d.Registry,
Agent: d.Agent,
Emit: d.Emit,
PlanConfirm: d.PlanConfirm,
StepConfirm: d.StepConfirm,
RuleCatalog: d.RuleCatalog,
RAGIndex: d.RAGIndex,
GodocIndexer: d.GodocIndexer,
LangState: d.LangState,
RulesLoader: d.RulesLoader,
SnippetsLoader: d.SnippetsLoader,
Flags: d.Flags,
})
}
// parseCodeReviewArgs peels `--package <path>` (or `--package=<path>`) out
// of the raw arg slice; remaining args are forwarded to git diff.
func parseCodeReviewArgs(parts []string) (diffArgs []string, packageScope string) {
if len(parts) <= 1 {
return nil, ""
}
rawArgs := strings.Fields(parts[1])
for i := 0; i < len(rawArgs); i++ {
a := rawArgs[i]
if a == "--package" && i+1 < len(rawArgs) {
packageScope = strings.TrimSuffix(rawArgs[i+1], "/")
i++
continue
}
if v, ok := strings.CutPrefix(a, "--package="); ok {
packageScope = strings.TrimSuffix(v, "/")
continue
}
diffArgs = append(diffArgs, a)
}
return diffArgs, packageScope
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"fmt"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/kodruninit"
)
// actionInit scaffolds the .kodrun/ tree and AGENTS.md by delegating to the
// kodruninit package.
func actionInit(taskCtx context.Context, d *TaskDeps) {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "Scanning project and generating AGENTS.md..."})
res, err := kodruninit.Run(taskCtx, d.Flags.WorkDir, d.Client, d.ChatProv.Model)
if err != nil {
d.Emit(&agent.Event{Type: agent.EventError, Message: err.Error()})
} else {
for _, path := range res.Created {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "created " + path})
}
d.Emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("Done: %d items created", len(res.Created))})
}
d.Emit(&agent.Event{Type: agent.EventDone})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"fmt"
"sort"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/tui"
)
// actionModel surfaces the model picker dialog, then applies the selection
// to the current agent and chat provider config.
func actionModel(taskCtx context.Context, d *TaskDeps) {
models, err := d.Client.Models(taskCtx)
if err != nil {
d.Emit(&agent.Event{Type: agent.EventError, Message: fmt.Sprintf("Failed to list models: %v", err)})
d.Emit(&agent.Event{Type: agent.EventDone})
return
}
names := make([]string, len(models))
for i, m := range models {
names[i] = m.Name
}
sort.Strings(names)
resultCh := make(chan string, 1)
d.ModelPickerCh <- tui.ModelPickerRequest{
Models: names,
Current: d.Agent.Model(),
Result: resultCh,
}
selected := <-resultCh
if selected != "" && selected != d.Agent.Model() {
d.Agent.SetModel(selected)
d.ChatProv.Model = selected
d.Emit(&agent.Event{Type: agent.EventModelChange, Message: selected})
d.Emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("Model switched to: %s", selected)})
}
d.Emit(&agent.Event{Type: agent.EventDone})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"sync/atomic"
"github.com/raoptimus/kodrun/internal/agent"
)
// runOrchestrate executes the explicit `/orchestrate` slash command. The
// optional task argument overrides the original input.
func runOrchestrate(taskCtx context.Context, d *TaskDeps, task string, parts []string) {
orchTask := task
if len(parts) > 1 {
orchTask = parts[1]
}
orch := NewOrchestrator(d.Client, d.ChatProv, d.Registry, d.Cfg, d.Emit, d.Agent.GetConfirmFunc(), d.PlanConfirm, d.StepConfirm, d.RuleCatalog, d.RAGIndex, d.GodocIndexer, d.LangState, d.RulesLoader, d.Flags)
if err := orch.Run(taskCtx, orchTask); err != nil && taskCtx.Err() == nil {
d.Emit(&agent.Event{Type: agent.EventError, Message: err.Error()})
d.Emit(&agent.Event{Type: agent.EventDone})
}
}
// runOrchestratorPlan handles the implicit "plan-mode + cfg.Agent.Orchestrator"
// path. EventDone is wrapped so we only emit it once even when the
// orchestrator already did.
func runOrchestratorPlan(taskCtx context.Context, d *TaskDeps, task string) {
var doneSent atomic.Bool
wrappedEmit := agent.EventHandler(func(e *agent.Event) {
if e.Type == agent.EventDone {
doneSent.Store(true)
}
d.Emit(e)
})
orch := NewOrchestrator(d.Client, d.ChatProv, d.Registry, d.Cfg, wrappedEmit, d.Agent.GetConfirmFunc(), d.PlanConfirm, d.StepConfirm, d.RuleCatalog, d.RAGIndex, d.GodocIndexer, d.LangState, d.RulesLoader, d.Flags)
if err := orch.Run(taskCtx, task); err != nil && taskCtx.Err() == nil {
d.Emit(&agent.Event{Type: agent.EventError, Message: err.Error()})
}
if !doneSent.Load() {
d.Emit(&agent.Event{Type: agent.EventDone})
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"fmt"
"path/filepath"
"strings"
"time"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/rag"
)
// actionReindex rebuilds the RAG common sub-index in the background. The
// in-progress flag is checked and atomically set to prevent overlapping
// reindex jobs.
func actionReindex(d *TaskDeps) {
switch {
case d.RAGIndex == nil:
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "RAG is disabled. Enable it in config with rag.enabled: true"})
d.Emit(&agent.Event{Type: agent.EventDone})
case !d.Indexing.CompareAndSwap(false, true):
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "RAG indexing already in progress"})
d.Emit(&agent.Event{Type: agent.EventDone})
default:
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "Reindexing..."})
SafeGo(d.BgWg, d.Events, func() {
defer d.Indexing.Store(false)
defer func() {
// Always clear the progress indicator on exit so the
// status bar disappears even on error paths.
d.Emit(&agent.Event{Type: agent.EventRAGProgress})
}()
RunReindex(d.Ctx, d.RAGIndex, d.RulesLoader, d.SnippetsLoader, d.LangState, d.Cfg, d.Emit)
d.Emit(&agent.Event{Type: agent.EventDone})
})
}
}
// actionRAGStatus reports the current RAG index size, embedding model and
// last-update timestamp.
func actionRAGStatus(d *TaskDeps) {
if d.RAGIndex == nil {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "RAG is disabled. Enable with rag.enabled: true"})
} else {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("RAG: %d entries, model: %s, updated: %s",
d.RAGIndex.Size(), d.Cfg.RAGProvider().Model, d.RAGIndex.Updated().Format(time.RFC3339))})
}
d.Emit(&agent.Event{Type: agent.EventDone})
}
// actionAddDoc chunks a single file and merges it into the RAG index in
// the background. Path can be relative to the workdir.
func actionAddDoc(d *TaskDeps, parts []string) {
if d.RAGIndex == nil {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "RAG is disabled. Enable it in config with rag.enabled: true"})
d.Emit(&agent.Event{Type: agent.EventDone})
return
}
var docPath string
if len(parts) > 1 {
docPath = strings.TrimSpace(parts[1])
}
if docPath == "" {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "Usage: /add_doc <file_path>"})
d.Emit(&agent.Event{Type: agent.EventDone})
return
}
if !filepath.IsAbs(docPath) {
docPath = filepath.Join(d.Flags.WorkDir, docPath)
}
d.Emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("Adding %s to RAG index...", docPath)})
SafeGo(d.BgWg, d.Events, func() {
chunks, err := rag.ChunkFile(docPath, d.Cfg.RAG.ChunkSize, d.Cfg.RAG.ChunkOverlap)
if err != nil {
d.Emit(&agent.Event{Type: agent.EventError, Message: fmt.Sprintf("read: %s", err)})
d.Emit(&agent.Event{Type: agent.EventDone})
return
}
if rel, e := filepath.Rel(d.Flags.WorkDir, docPath); e == nil {
for i := range chunks {
chunks[i].FilePath = rel
}
}
n, err := d.RAGIndex.Build(d.Ctx, chunks)
if err != nil {
d.Emit(&agent.Event{Type: agent.EventError, Message: fmt.Sprintf("index: %s", err)})
d.Emit(&agent.Event{Type: agent.EventDone})
return
}
if err := d.RAGIndex.Save(); err != nil {
d.Emit(&agent.Event{Type: agent.EventError, Message: fmt.Sprintf("save: %s", err)})
d.Emit(&agent.Event{Type: agent.EventDone})
return
}
d.Emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("Added %d chunks from %s (%d total)", n, filepath.Base(docPath), d.RAGIndex.Size())})
d.Emit(&agent.Event{Type: agent.EventDone})
})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"fmt"
"strings"
"github.com/raoptimus/kodrun/internal/agent"
)
// actionResume loads the most recent saved session into the agent. Missing
// or unreadable sessions surface as a friendly chat message rather than an
// error event.
func actionResume(d *TaskDeps) {
s, err := agent.LatestSession(d.SessionsDir)
if err != nil {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "No sessions to resume."})
} else {
d.Agent.LoadFromSession(s)
d.Emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("Resumed session %s (%d messages, %s mode)", s.ID, len(s.Messages), s.Mode)})
used, total := d.Agent.ContextUsage()
d.Emit(&agent.Event{Type: agent.EventTokens, ContextUsed: used, ContextTotal: total})
}
d.Emit(&agent.Event{Type: agent.EventDone})
}
// actionSessions prints a one-line summary for every saved session, sorted
// by recency.
func actionSessions(d *TaskDeps) {
summaries, err := agent.ListSessions(d.SessionsDir)
if err != nil || len(summaries) == 0 {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "No saved sessions."})
} else {
var sb strings.Builder
sb.WriteString("Saved sessions:\n")
for _, s := range summaries {
fmt.Fprintf(&sb, " %s %s %s %d msgs\n", s.ID, s.Model, s.Mode, s.MessageCount)
}
d.Emit(&agent.Event{Type: agent.EventAgent, Message: sb.String()})
}
d.Emit(&agent.Event{Type: agent.EventDone})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"strings"
"github.com/raoptimus/kodrun/internal/agent"
)
// actionCompact runs the agent's history-compaction routine. Optional free
// text after "/compact" is forwarded as user instructions.
func actionCompact(taskCtx context.Context, d *TaskDeps, parts []string) {
var instructions string
if len(parts) > 1 {
instructions = parts[1]
}
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "Compacting context..."})
if err := d.Agent.Compact(taskCtx, instructions); err != nil {
d.Emit(&agent.Event{Type: agent.EventError, Message: err.Error()})
}
d.Emit(&agent.Event{Type: agent.EventDone})
}
// actionEdit toggles to edit mode. If a plan was previously approved the
// agent re-enters edit-with-plan; otherwise we just switch the mode.
func actionEdit(d *TaskDeps) {
if d.Agent.LastPlan() != "" {
d.Agent.EnterEditWithPlan()
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "Loaded approved plan. Send any message to start execution."})
} else {
d.Agent.SetMode(agent.ModeEdit)
d.Agent.SetThink(false)
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "No plan available. Switched to edit mode."})
}
d.Emit(&agent.Event{Type: agent.EventDone})
}
// actionClear wipes both conversation history and any per-session permission
// grants and reports the resulting context-window usage.
func actionClear(d *TaskDeps) {
d.Agent.ClearHistory()
d.Agent.ClearSessionPermissions()
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "Context and permissions cleared"})
used, total := d.Agent.ContextUsage()
d.Emit(&agent.Event{Type: agent.EventTokens, ContextUsed: used, ContextTotal: total})
d.Emit(&agent.Event{Type: agent.EventDone})
}
// actionDiff prints `git diff` (optionally restricted by extra args) into
// the chat. Empty diff is announced explicitly so the user is not left
// staring at silence.
func actionDiff(d *TaskDeps, parts []string) {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "Computing diff..."})
var diffArgs []string
if len(parts) > 1 {
diffArgs = strings.Fields(parts[1])
}
diffOutput, err := GitDiff(d.Ctx, d.Flags.WorkDir, diffArgs)
switch {
case err != nil:
d.Emit(&agent.Event{Type: agent.EventError, Message: err.Error()})
case diffOutput == "":
d.Emit(&agent.Event{Type: agent.EventAgent, Message: "No uncommitted changes."})
default:
d.Emit(&agent.Event{Type: agent.EventAgent, Message: diffOutput})
}
d.Emit(&agent.Event{Type: agent.EventDone})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"io"
"os"
"os/signal"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/pkg/errors"
"github.com/urfave/cli/v3"
"golang.org/x/term"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/tui"
)
// pingTimeout caps how long we wait for the chat backend (Ollama et al.) to
// respond to a connectivity check at startup.
const pingTimeout = 5 * time.Second
// eventChanSize buffers UI events between background goroutines and the
// bubbletea program. Sized to avoid back-pressure during indexing bursts.
const eventChanSize = 100
// Run is the entry point invoked by cmd/kodrun's CLI Action. It wires
// configuration, agent setup, RAG indexing, the bubbletea TUI, and the
// task dispatcher together. Returns the error from agent.Run (one-shot
// or stdin paths) or RunTUI (interactive path).
func Run(ctx context.Context, cmd *cli.Command, f *Flags, version string) error {
ConfigureSlog(f.Verbose)
absWorkDir, err := filepath.Abs(f.WorkDir)
if err != nil {
return errors.WithMessage(err, "resolve work-dir")
}
f.WorkDir = absWorkDir
args := cmd.Args().Slice()
cfg, err := LoadConfig(ctx, f)
if err != nil {
return errors.WithMessage(err, "load config")
}
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
s := SetupAgent(ctx, &cfg, rules.ScopeCoding, f)
ag, reg, loader, snippetLoader := s.Agent, s.Registry, s.Loader, s.SnippetLoader
ragIndex, godocIndexer, langState := s.RAGIndex, s.GodocIndexer, s.LangState
mcpMgr, client := s.MCPManager, s.Client
if mcpMgr != nil {
defer mcpMgr.Close()
ag.AddConfirmTools(mcpMgr.ConfirmTools())
ag.AddReadOnlyTools(mcpMgr.ReadOnlyTools())
}
defaultMode := agent.ModePlan
switch cfg.Agent.DefaultMode {
case CmdNameEdit:
defaultMode = agent.ModeEdit
case agent.ModeChat.String():
defaultMode = agent.ModeChat
}
ag.SetMode(defaultMode)
ag.SetThink(cfg.Agent.Think)
ag.SetLanguage(cfg.Agent.Language)
ag.SetAutoCompact(cfg.Agent.AutoCompact)
ag.SetMaxToolWorkers(cfg.Agent.MaxToolWorkers)
ag.SetHasSnippets(cfg.Snippets.UseTool && !cfg.RAG.Enabled)
ag.SetHasRAG(cfg.RAG.Enabled)
ag.SetRAGIndex(ragIndex)
chatProv := cfg.ChatProvider()
pingCtx, pingCancel := context.WithTimeout(ctx, pingTimeout)
defer pingCancel()
if err := client.Ping(pingCtx); err != nil {
return errors.WithMessage(err, "cannot connect to Ollama\nMake sure 'ollama serve' is running")
}
ruleCatalog := loader.RuleCatalogString(ctx, rules.ScopeCoding, cfg.Rules.UseTool)
if len(args) > 0 {
ag.SetMode(agent.ModeEdit)
task := strings.Join(args, " ")
output := agent.NewPlainOutput(os.Stdout)
ag.SetEventHandler(output.Handle)
return ag.Run(ctx, task, ruleCatalog)
}
isTTY := term.IsTerminal(int(os.Stdin.Fd()))
if !isTTY || f.NoTUI {
ag.SetMode(agent.ModeEdit)
output := agent.NewPlainOutput(os.Stdout)
ag.SetEventHandler(output.Handle)
stdinData, err := io.ReadAll(os.Stdin)
if err != nil {
return errors.WithMessage(err, "read stdin")
}
task := strings.TrimSpace(string(stdinData))
if task == "" {
return errors.New("no task provided")
}
return ag.Run(ctx, task, ruleCatalog)
}
events := make(chan agent.Event, eventChanSize)
emit := func(e *agent.Event) {
select {
case events <- *e:
case <-ctx.Done():
}
}
ag.SetEventHandler(emit)
sessionsDir := filepath.Join(f.WorkDir, ".kodrun", "sessions")
ag.SetSessionDir(sessionsDir)
ag.Init(ruleCatalog)
var bgWg sync.WaitGroup
var indexing atomic.Bool
MigrateLegacyChunks(ragIndex, emit)
if ragIndex != nil {
indexing.Store(true)
SafeGo(&bgWg, events, func() {
defer indexing.Store(false)
defer func() {
emit(&agent.Event{Type: agent.EventRAGProgress})
}()
RunInitialIndex(ctx, ragIndex, loader, snippetLoader, langState, &cfg, emit)
})
}
confirmCh := make(chan tui.ConfirmRequest, 1)
ag.SetConfirmFunc(func(payload agent.ConfirmPayload) agent.ConfirmResult {
resultCh := make(chan agent.ConfirmResult, 1)
confirmCh <- tui.ConfirmRequest{Payload: payload, Result: resultCh}
return <-resultCh
})
planConfirmCh := make(chan tui.PlanConfirmRequest, 1)
planConfirmFn := func(plan string) agent.PlanConfirmResult {
resultCh := make(chan agent.PlanConfirmResult, 1)
planConfirmCh <- tui.PlanConfirmRequest{Plan: plan, Result: resultCh}
return <-resultCh
}
stepConfirmCh := make(chan tui.StepConfirmRequest, 1)
stepConfirmFn := func(description string) agent.StepConfirmAction {
resultCh := make(chan agent.StepConfirmAction, 1)
stepConfirmCh <- tui.StepConfirmRequest{Description: description, Result: resultCh}
return <-resultCh
}
modelPickerCh := make(chan tui.ModelPickerRequest, 1)
var (
taskCancelMu sync.Mutex
taskCancelFn context.CancelFunc
)
cancelTask := func() {
taskCancelMu.Lock()
if taskCancelFn != nil {
taskCancelFn()
}
taskCancelMu.Unlock()
}
taskDeps := &TaskDeps{
TaskBaseCtx: TaskBaseCtx{
Ctx: ctx,
Cfg: &cfg,
Flags: f,
SessionsDir: sessionsDir,
RuleCatalog: ruleCatalog,
},
TaskAgentDeps: TaskAgentDeps{
Client: client,
ChatProv: &chatProv,
Agent: ag,
Registry: reg,
RulesLoader: loader,
SnippetsLoader: snippetLoader,
LangState: langState,
RAGIndex: ragIndex,
GodocIndexer: godocIndexer,
},
TaskUISignals: TaskUISignals{
Emit: emit,
PlanConfirm: planConfirmFn,
StepConfirm: stepConfirmFn,
ModelPickerCh: modelPickerCh,
Events: events,
BgWg: &bgWg,
Indexing: &indexing,
},
}
taskFn := func(input string) {
taskCtx, taskCancel := context.WithCancel(ctx)
taskCancelMu.Lock()
taskCancelFn = taskCancel
taskCancelMu.Unlock()
defer func() {
taskCancel()
taskCancelMu.Lock()
taskCancelFn = nil
taskCancelMu.Unlock()
}()
RunTask(taskCtx, taskDeps, input)
}
return RunTUI(&TUIParams{
Agent: ag,
Cfg: &cfg,
ChatProv: &chatProv,
RulesLoader: loader,
MCPManager: mcpMgr,
Events: events,
ConfirmCh: confirmCh,
PlanConfirmCh: planConfirmCh,
StepConfirmCh: stepConfirmCh,
ModelPickerCh: modelPickerCh,
WorkDir: f.WorkDir,
Version: version,
DefaultMode: defaultMode,
TaskFn: taskFn,
CancelTask: cancelTask,
Cancel: cancel,
BgWg: &bgWg,
})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"fmt"
"path/filepath"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/config"
"github.com/raoptimus/kodrun/internal/projectlang"
"github.com/raoptimus/kodrun/internal/rag"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/snippets"
)
// ChunkConventions builds the convention chunks fed into the RAG common
// sub-index: snippets, rules, reference docs, and built-in language docs
// (e.g. Effective Go). Project source code is intentionally excluded — it
// changes too fast for chunked snapshots to stay current. The function
// short-circuits as soon as ctx is cancelled.
func ChunkConventions(
ctx context.Context,
loader *rules.Loader,
snipLoader *snippets.Loader,
langState *projectlang.State,
cfg *config.Config,
) []rag.Chunk {
snips := snipLoader.Snippets()
techStack := langState.EnsureTechDetected().Strings()
var chunks []rag.Chunk
for i := range snips {
if ctx.Err() != nil {
return chunks
}
chunks = append(chunks, rag.ChunkSnippets([]rag.SnippetInfo{{
Name: snips[i].Name,
Description: snips[i].Description,
Tags: snips[i].Tags,
Requires: snips[i].Requires,
Content: snips[i].Content,
SourcePath: snips[i].SourcePath,
}}, techStack)...)
}
for _, r := range loader.AllRules() {
if ctx.Err() != nil {
return chunks
}
chunks = append(chunks, rag.ChunkRules([]rag.RuleInfo{{
Name: filepath.Base(r.Path),
Content: r.Content,
Path: r.Path,
}}, cfg.RAG.ChunkSize, cfg.RAG.ChunkOverlap)...)
}
for path, content := range loader.ReferenceDocPaths() {
if ctx.Err() != nil {
return chunks
}
chunks = append(chunks, rag.ChunkRefDocs([]rag.RefDocInfo{{
Path: path,
Content: content,
}}, cfg.RAG.ChunkSize, cfg.RAG.ChunkOverlap)...)
}
chunks = append(chunks, rag.ChunkEmbeddedDocs(string(langState.Current()), cfg.RAG.ChunkSize, cfg.RAG.ChunkOverlap)...)
return chunks
}
// MigrateLegacyChunks resets the common index when it still holds chunks
// from a kodrun version that indexed source code. Idempotent: no-op when
// the index is empty or already convention-only.
func MigrateLegacyChunks(ragIndex rag.Backend, emit agent.EventHandler) {
if ragIndex == nil || !ragIndex.HasLegacyCodeChunks() {
return
}
emit(&agent.Event{Type: agent.EventAgent, Message: "RAG: dropping legacy code chunks from common index"})
if err := ragIndex.Reset(); err != nil {
emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("RAG reset failed: %s", err)})
}
}
// indexConventions runs BuildCommonWithProgress + Save with a progress
// emitter. Caller is responsible for messaging on the returned error.
func indexConventions(
ctx context.Context,
ragIndex rag.Backend,
chunks []rag.Chunk,
emit agent.EventHandler,
) (int, error) {
progressFn := func(label string, done, total int) {
emit(&agent.Event{
Type: agent.EventRAGProgress,
ProgressDone: done,
ProgressTotal: total,
ProgressLabel: label,
})
}
n, err := ragIndex.BuildCommonWithProgress(ctx, chunks, progressFn)
if err != nil {
return 0, err
}
if err := ragIndex.Save(); err != nil {
return 0, errors.WithMessage(err, "save")
}
return n, nil
}
// RunInitialIndex chunks conventions and builds the common sub-index at
// startup. Silent on a fully-cached run (n == 0) — the user gets no banner
// for an index that did not change.
func RunInitialIndex(
ctx context.Context,
ragIndex rag.Backend,
loader *rules.Loader,
snipLoader *snippets.Loader,
langState *projectlang.State,
cfg *config.Config,
emit agent.EventHandler,
) {
if ctx.Err() != nil {
return
}
chunks := ChunkConventions(ctx, loader, snipLoader, langState, cfg)
n, err := indexConventions(ctx, ragIndex, chunks, emit)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return
}
emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("RAG index (common): %s", err)})
return
}
if ctx.Err() != nil {
return
}
if n > 0 {
emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("RAG ready: %d new, %d total", n, ragIndex.Size())})
}
}
// RunReindex rebuilds the common sub-index from scratch on user request:
// reloads rules and snippets from disk, drops legacy per-language dirs,
// resets the index, and rebuilds the convention corpus. Reports both
// success and error via emit; the caller emits EventDone after return.
func RunReindex(
ctx context.Context,
ragIndex rag.Backend,
loader *rules.Loader,
snipLoader *snippets.Loader,
langState *projectlang.State,
cfg *config.Config,
emit agent.EventHandler,
) {
if err := loader.Load(ctx); err != nil {
emit(&agent.Event{Type: agent.EventError, Message: fmt.Sprintf("rules reload: %s", err)})
return
}
if err := snipLoader.Load(ctx); err != nil {
emit(&agent.Event{Type: agent.EventError, Message: fmt.Sprintf("snippets reload: %s", err)})
return
}
CleanupLegacyLangDirs(ragIndex.BasePath())
if err := ragIndex.Reset(); err != nil {
emit(&agent.Event{Type: agent.EventError, Message: fmt.Sprintf("reset: %s", err)})
return
}
chunks := ChunkConventions(ctx, loader, snipLoader, langState, cfg)
n, err := indexConventions(ctx, ragIndex, chunks, emit)
if err != nil {
emit(&agent.Event{Type: agent.EventError, Message: fmt.Sprintf("index: %s", err)})
return
}
emit(&agent.Event{Type: agent.EventAgent, Message: fmt.Sprintf("RAG reindex: %d new, %d total", n, ragIndex.Size())})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"log/slog"
"strings"
"time"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/config"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/projectlang"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/tools"
)
// ClassifierDeps groups everything HandleClassifierFlow needs to classify
// a plan response and, on approval, build an orchestrator to execute it.
type ClassifierDeps struct {
Agent *agent.Agent
Client llm.Client
ChatProv *config.ProviderConfig
Cfg *config.Config
Registry *tools.Registry
PlanConfirm agent.PlanConfirmFunc
StepConfirm agent.StepConfirmFunc
RuleCatalog string
RAGIndex tools.RAGSearcher
GodocIndexer tools.GoDocIndexer
LangState *projectlang.State
RulesLoader *rules.Loader
Flags *Flags
}
// HandleClassifierFlow runs the post-Send classifier path: it asks the
// thinking model whether the agent's plan-mode response is actually a plan
// the user might want to execute. On approve-plan verdict, it shows the
// plan-confirm dialog and, if accepted, kicks off the orchestrator's
// RunExecutor against the response text. Returns silently when the
// classifier is disabled by config (orchestrator on, non-plan mode) or the
// response is empty.
func HandleClassifierFlow(
ctx context.Context,
d *ClassifierDeps,
emit agent.EventHandler,
task string,
classifyTimeout time.Duration,
) {
if d.Cfg.Agent.Orchestrator || d.Agent.Mode() != agent.ModePlan {
return
}
responseText := d.Agent.LastPlan()
if strings.TrimSpace(responseText) == "" {
return
}
thinkProv := d.Cfg.ThinkingProvider()
thinkClient := d.Client
thinkModel := d.ChatProv.Model
if d.Cfg.Agent.ThinkingProvider != "" && d.Cfg.Agent.ThinkingProvider != d.Cfg.Agent.Provider {
thinkClient = NewLLMClient(&thinkProv)
thinkModel = thinkProv.Model
}
verdict, classifyErr := agent.ClassifyResponse(
ctx, thinkClient, thinkModel, d.Cfg.Agent.Language, task, responseText, classifyTimeout,
)
if classifyErr != nil {
slog.Debug("classifier failed", "err", classifyErr)
}
if verdict.SuggestedAction != agent.ClassifyActionApprovePlan {
return
}
if verdict.CTAText != "" && !strings.Contains(responseText, verdict.CTAText) {
emit(&agent.Event{Type: agent.EventAgent, Message: verdict.CTAText})
}
cr := d.PlanConfirm(responseText)
switch cr.Action {
case agent.PlanDeny:
emit(&agent.Event{Type: agent.EventAgent, Message: "Execution cancelled by user."})
return
case agent.PlanAugment:
emit(&agent.Event{Type: agent.EventAgent, Message: "Plan augmentation: send your refinement as a new message."})
return
case agent.PlanAutoAccept, agent.PlanManualApprove:
autoAccept := cr.Action == agent.PlanAutoAccept
var confirmFn agent.ConfirmFunc
if !autoAccept {
confirmFn = d.Agent.GetConfirmFunc()
}
emit(&agent.Event{Type: agent.EventAgent, Message: "▸ Executing approved plan..."})
orch := NewOrchestrator(d.Client, d.ChatProv, d.Registry, d.Cfg, emit, confirmFn, d.PlanConfirm, d.StepConfirm, d.RuleCatalog, d.RAGIndex, d.GodocIndexer, d.LangState, d.RulesLoader, d.Flags)
if err := orch.RunExecutor(ctx, responseText, confirmFn, autoAccept); err != nil && ctx.Err() == nil {
emit(&agent.Event{Type: agent.EventError, Message: err.Error()})
}
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"io/fs"
"log/slog"
"path/filepath"
"strings"
"sync/atomic"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/config"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/projectlang"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/snippets"
"github.com/raoptimus/kodrun/internal/tools"
)
// FilterDiffByPackage keeps only the file sections of a unified-diff whose
// `+++ b/<path>` line is rooted under the given package scope. Used by
// `/code-review --package <path>` to narrow review to a single package
// without re-running git. Empty diff or empty scope return the input as-is.
func FilterDiffByPackage(diff, scope string) string {
scope = strings.TrimSuffix(scope, "/")
if scope == "" || diff == "" {
return diff
}
prefix := scope + "/"
lines := strings.Split(diff, "\n")
var out []string
keep := false
for i := range lines {
line := lines[i]
if strings.HasPrefix(line, "diff --git ") {
path := ""
for j := i + 1; j < len(lines) && !strings.HasPrefix(lines[j], "diff --git "); j++ {
if p, ok := strings.CutPrefix(lines[j], "+++ "); ok {
p = strings.TrimPrefix(p, "b/")
path = strings.TrimSpace(p)
break
}
}
keep = path != "" && (path == scope || strings.HasPrefix(path, prefix))
}
if keep {
out = append(out, line)
}
}
return strings.Join(out, "\n")
}
// findAllSourceFiles walks workDir and returns relative paths to all source
// code files in the project, skipping hidden directories, vendor, etc.
func findAllSourceFiles(workDir string) []string {
var files []string
err := filepath.WalkDir(workDir, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return filepath.SkipDir
}
if d.IsDir() {
name := d.Name()
if strings.HasPrefix(name, ".") || name == "vendor" || name == "node_modules" || name == "testdata" {
return filepath.SkipDir
}
return nil
}
rel, relErr := filepath.Rel(workDir, p)
if relErr != nil {
return relErr
}
if isSourceCodePath(rel) {
files = append(files, rel)
}
return nil
})
if err != nil {
slog.Debug("findAllSourceFiles walk failed", "error", err)
}
return files
}
// isSourceCodePath reports whether path looks like a real source code file
// worth code-reviewing.
func isSourceCodePath(path string) bool {
if path == "" || path == PathDevNull {
return false
}
denyPrefixes := []string{
".kodrun/", ".claude/", ".github/", ".git/",
"vendor/", "node_modules/", "testdata/", "docs/", "doc/",
}
for _, p := range denyPrefixes {
if strings.HasPrefix(path, p) {
return false
}
}
denyNames := map[string]bool{
"go.sum": true,
"package-lock.json": true, "yarn.lock": true,
"pnpm-lock.yaml": true, "Cargo.lock": true, "poetry.lock": true,
"Pipfile.lock": true, "AGENTS.md": true, "README.md": true,
"CLAUDE.md": true,
}
base := path
if i := strings.LastIndex(base, "/"); i >= 0 {
base = base[i+1:]
}
if denyNames[base] {
return false
}
allowExts := []string{
".go", ".py", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs",
".rs", ".java", ".kt", ".kts", ".scala", ".rb", ".php",
".c", ".h", ".cc", ".cpp", ".hpp", ".m", ".mm", ".swift",
".cs", ".fs", ".vb", ".ex", ".exs", ".erl", ".hrl",
".lua", ".pl", ".pm", ".sh", ".bash", ".zsh", ".sql",
".proto",
}
for _, ext := range allowExts {
if strings.HasSuffix(path, ext) {
return true
}
}
return false
}
// CodeReviewParams aggregates the dependencies RunCodeReview needs.
type CodeReviewParams struct {
WorkDir string
Cfg *config.Config
Client llm.Client
ChatProv *config.ProviderConfig
Registry *tools.Registry
Agent *agent.Agent
Emit agent.EventHandler
PlanConfirm agent.PlanConfirmFunc
StepConfirm agent.StepConfirmFunc
RuleCatalog string
RAGIndex tools.RAGSearcher
GodocIndexer tools.GoDocIndexer
LangState *projectlang.State
RulesLoader *rules.Loader
SnippetsLoader *snippets.Loader
Flags *Flags
}
// RunCodeReview handles the orchestrator-driven code review pipeline.
func RunCodeReview(ctx context.Context, p *CodeReviewParams) {
sourceFiles := findAllSourceFiles(p.WorkDir)
if len(sourceFiles) == 0 {
p.Emit(&agent.Event{Type: agent.EventAgent, Message: "No source files found."})
p.Emit(&agent.Event{Type: agent.EventDone})
return
}
var doneSent atomic.Bool
wrappedEmit := agent.EventHandler(func(e *agent.Event) {
if e.Type == agent.EventDone {
doneSent.Store(true)
}
p.Emit(e)
})
orch := NewOrchestrator(p.Client, p.ChatProv, p.Registry, p.Cfg, wrappedEmit, p.Agent.GetConfirmFunc(), p.PlanConfirm, p.StepConfirm, p.RuleCatalog, p.RAGIndex, p.GodocIndexer, p.LangState, p.RulesLoader, p.Flags)
if err := orch.RunCodeReview(ctx, sourceFiles, p.SnippetsLoader.Snippets()); err != nil && ctx.Err() == nil {
p.Emit(&agent.Event{Type: agent.EventError, Message: err.Error()})
}
if !doneSent.Load() {
p.Emit(&agent.Event{Type: agent.EventDone})
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package commands
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"github.com/pkg/errors"
"github.com/urfave/cli/v3"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/cliapp"
"github.com/raoptimus/kodrun/internal/rules"
)
// Fix wires the `kodrun fix <file>` subcommand: reads a single file and
// asks the agent to repair it (bugs, style, errors), then verifies via
// go_build / go_test.
func Fix(f *cliapp.Flags) *cli.Command {
return &cli.Command{
Name: "fix",
Usage: "Fix issues in a specific file",
Action: func(ctx context.Context, cmd *cli.Command) error {
if cmd.NArg() != 1 {
return errors.New("fix requires exactly one file argument")
}
cfg, err := cliapp.LoadConfig(ctx, f)
if err != nil {
return err
}
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
s := cliapp.SetupAgent(ctx, &cfg, rules.ScopeFix, f)
ag, loader, mcpMgr := s.Agent, s.Loader, s.MCPManager
if mcpMgr != nil {
defer mcpMgr.Close()
}
output := agent.NewPlainOutput(os.Stdout)
ag.SetEventHandler(output.Handle)
ruleCatalog := loader.RuleCatalogString(ctx, rules.ScopeFix, cfg.Rules.UseTool)
task := fmt.Sprintf("Read file %s, find and fix all issues (bugs, style, errors). Run go_build and go_test after fixing.", cmd.Args().First())
return ag.Run(ctx, task, ruleCatalog)
},
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package commands
import (
"context"
"fmt"
"os"
"os/signal"
"strings"
"syscall"
"github.com/pkg/errors"
"github.com/urfave/cli/v3"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/cliapp"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/runner"
"github.com/raoptimus/kodrun/internal/tools"
)
// maxFixAttempts caps the auto-fix loop. Three retries is enough for
// straightforward compile/lint errors without spending unbounded LLM time
// on intractable cases.
const maxFixAttempts = 3
// Build wires the `kodrun build` subcommand which runs `go build` and
// optionally invokes the auto-fix loop on failure.
func Build(f *cliapp.Flags) *cli.Command {
return &cli.Command{
Name: "build",
Usage: "Run go build with auto-fix",
Action: func(ctx context.Context, cmd *cli.Command) error {
return runGoTool(ctx, tools.NameGoBuild, cmd.Args().Slice(), f)
},
}
}
// Test wires the `kodrun test` subcommand.
func Test(f *cliapp.Flags) *cli.Command {
return &cli.Command{
Name: "test",
Usage: "Run go test with auto-fix",
Action: func(ctx context.Context, cmd *cli.Command) error {
return runGoTool(ctx, tools.NameGoTest, cmd.Args().Slice(), f)
},
}
}
// Lint wires the `kodrun lint` subcommand.
func Lint(f *cliapp.Flags) *cli.Command {
return &cli.Command{
Name: "lint",
Usage: "Run golangci-lint with auto-fix",
Action: func(ctx context.Context, cmd *cli.Command) error {
return runGoTool(ctx, tools.NameGoLint, cmd.Args().Slice(), f)
},
}
}
// runGoTool executes a Go tool (build/test/lint) through the registered
// tools.Tool, then runs the auto-fix loop when the tool reported failure
// and AutoFix is enabled.
func runGoTool(ctx context.Context, toolName string, args []string, f *cliapp.Flags) error {
cfg, err := cliapp.LoadConfig(ctx, f)
if err != nil {
return err
}
ctx, cancel := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer cancel()
s := cliapp.SetupAgent(ctx, &cfg, rules.ScopeFix, f)
ag, reg, mcpMgr := s.Agent, s.Registry, s.MCPManager
if mcpMgr != nil {
defer mcpMgr.Close()
}
output := agent.NewPlainOutput(os.Stdout)
ag.SetEventHandler(output.Handle)
params := map[string]any{}
if len(args) > 0 {
params["packages"] = strings.Join(args, " ")
}
result, err := reg.Execute(ctx, toolName, params)
if err != nil {
return err
}
fmt.Println(result.Output)
exitCode, ok := result.Meta["exit_code"].(int)
if !ok {
exitCode = 0
}
success := exitCode == 0
if !success && cfg.Agent.AutoFix && !f.NoFix {
fmt.Println("\n[auto-fix] Attempting to fix errors...")
chatProv := cfg.ChatProvider()
client := cliapp.NewLLMClient(&chatProv)
fixer := runner.NewFixer(ctx, client, chatProv.Model, reg, maxFixAttempts)
fixed, err := fixer.Fix(ctx, toolName, result.Output, func(msg string) {
fmt.Println(msg)
})
if err != nil {
return errors.WithMessage(err, "auto-fix")
}
if fixed {
fmt.Println("[auto-fix] Errors fixed successfully!")
} else {
fmt.Println("[auto-fix] Could not fix all errors")
}
}
if !success {
cliapp.ExitWithCode(1)
}
return nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package commands
import (
"context"
"fmt"
"github.com/urfave/cli/v3"
"github.com/raoptimus/kodrun/internal/cliapp"
"github.com/raoptimus/kodrun/internal/kodruninit"
)
// Init wires the `kodrun init` subcommand which scaffolds .kodrun/ via
// LLM-assisted scanning of the project.
func Init(f *cliapp.Flags) *cli.Command {
return &cli.Command{
Name: "init",
Usage: "Create .kodrun/ starter structure",
Action: func(ctx context.Context, _ *cli.Command) error {
cfg, err := cliapp.LoadConfig(ctx, f)
if err != nil {
return err
}
chatProv := cfg.ChatProvider()
client := cliapp.NewLLMClient(&chatProv)
fmt.Println("Scanning project and generating AGENTS.md via LLM...")
res, err := kodruninit.Run(ctx, f.WorkDir, client, chatProv.Model)
if err != nil {
return err
}
for _, path := range res.Created {
fmt.Println(" created", path)
}
fmt.Printf("Done: %d items created\n", len(res.Created))
return nil
},
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import "github.com/urfave/cli/v3"
// Flags holds parsed top-level CLI flags. The struct lives in cliapp so
// every internal helper can take it by pointer without depending on the
// main package.
type Flags struct {
Model string
WorkDir string
OllamaURL string
NoTUI bool
NoFix bool
Config string
Verbose bool
}
// CLIFlags returns the urfave/cli flag descriptors bound to f. Defining
// the slice here keeps cmd/kodrun/main.go free of flag plumbing details.
func CLIFlags(f *Flags) []cli.Flag {
return []cli.Flag{
&cli.StringFlag{Name: "model", Sources: cli.EnvVars("KODRUN_MODEL"), Destination: &f.Model, Usage: "Ollama model (overrides config)"},
&cli.StringFlag{Name: "work-dir", Value: ".", Sources: cli.EnvVars("KODRUN_WORK_DIR"), Destination: &f.WorkDir, Usage: "Working directory"},
&cli.StringFlag{Name: "ollama-url", Sources: cli.EnvVars("KODRUN_OLLAMA_URL"), Destination: &f.OllamaURL, Usage: "Ollama API URL (overrides config)"},
&cli.BoolFlag{Name: "no-tui", Sources: cli.EnvVars("KODRUN_NO_TUI"), Destination: &f.NoTUI, Usage: "Plain stdout mode"},
&cli.BoolFlag{Name: "no-fix", Destination: &f.NoFix, Usage: "Disable auto-fix"},
&cli.StringFlag{Name: "config", Destination: &f.Config, Usage: "Config file path"},
&cli.BoolFlag{Name: "verbose", Destination: &f.Verbose, Usage: "Verbose output"},
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"bytes"
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/pkg/errors"
"golang.org/x/term"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/tui"
)
// Constants used by helpers and the runtime layer.
const (
CmdNameEdit = "edit"
CmdNameCodeReview = "code-review"
PathDevNull = "/dev/null"
pollInterval = 250 * time.Millisecond
builtinCmdCount = 3 // number of built-in commands added beyond user commands
)
// SafeGo launches a goroutine tracked by wg with panic recovery.
// On panic, it sends an error event to the events channel (non-blocking).
// The send itself is also panic-guarded: if events is closed by an early
// shutdown path, the secondary panic is swallowed instead of propagating.
func SafeGo(wg *sync.WaitGroup, events chan agent.Event, fn func()) {
wg.Add(1)
go func() {
defer wg.Done()
defer func() {
if r := recover(); r != nil {
SafeSendError(events, fmt.Sprintf("background panic: %v", r))
}
}()
fn()
}()
}
// SafeSendError performs a non-blocking send of an error event and
// recovers from a secondary panic if events has been closed by an
// early-shutdown path. It is a best-effort report — losing the message
// is preferable to crashing the whole process.
func SafeSendError(events chan agent.Event, msg string) {
defer func() {
if r := recover(); r != nil {
slog.Warn("kodrun: dropped error event on closed channel", "msg", msg, "recover", r)
}
}()
select {
case events <- agent.Event{Type: agent.EventError, Message: msg}:
default:
}
}
// RestoreTerminal disables mouse reporting and exits alt screen via raw ANSI sequences.
// Used as a safety net when bubbletea cannot clean up (panic, SIGTERM, etc.).
func RestoreTerminal() {
if !term.IsTerminal(int(os.Stdin.Fd())) {
return
}
// Disable mouse reporting modes (1000=basic, 1002=cell motion, 1003=all, 1006=SGR extended)
fmt.Fprint(os.Stderr, "\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l")
// Exit alt screen
fmt.Fprint(os.Stderr, "\x1b[?1049l")
// Show cursor
fmt.Fprint(os.Stderr, "\x1b[?25h")
}
// ExitWithCode exits with the given code. Extracted to avoid exitAfterDefer lint.
func ExitWithCode(code int) { os.Exit(code) }
// BuildCommandItems builds the slash-command palette shown in the TUI:
// merge built-in commands with user-defined ones and sort by name.
func BuildCommandItems(loader *rules.Loader) []tui.CommandItem {
cmds := loader.Commands()
items := make([]tui.CommandItem, 0, len(cmds)+builtinCmdCount)
// Built-in commands
items = append(items,
tui.CommandItem{Name: "compact", Description: "Summarize conversation to free context"},
tui.CommandItem{Name: CmdNameEdit, Description: "Switch to edit mode (with plan if available)"},
tui.CommandItem{Name: "init", Description: "Create .kodrun/ starter structure"},
tui.CommandItem{Name: "clear", Description: "Clear conversation context"},
tui.CommandItem{Name: "diff", Description: "Show git diff (uncommitted changes)"},
tui.CommandItem{Name: "resume", Description: "Resume last saved session"},
tui.CommandItem{Name: "sessions", Description: "List saved sessions"},
tui.CommandItem{Name: "reindex", Description: "Rebuild RAG index"},
tui.CommandItem{Name: "rag", Description: "Show RAG index status"},
tui.CommandItem{Name: "add_doc", Description: "Add a document to RAG index"},
tui.CommandItem{Name: "orchestrate", Description: "Run Plan→Execute→Review pipeline"},
tui.CommandItem{Name: CmdNameCodeReview, Description: "Parallel specialist code review: rules, idiomaticity, best practices, security, structure, architecture"},
tui.CommandItem{Name: "model", Description: "Switch LLM model for this session"},
tui.CommandItem{Name: "exit", Description: "Exit KodRun"},
)
for _, cmd := range cmds {
items = append(items, tui.CommandItem{
Name: cmd.Name,
Description: cmd.Description,
Template: cmd.Template,
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].Name < items[j].Name
})
return items
}
// GitDiff runs git diff and returns formatted output.
func GitDiff(ctx context.Context, workDir string, args []string) (string, error) {
cmdArgs := []string{"diff", "--stat"}
cmdArgs = append(cmdArgs, args...)
// First get stat summary
statCmd := exec.CommandContext(ctx, "git", cmdArgs...)
statCmd.Dir = workDir
var statOut bytes.Buffer
statCmd.Stdout = &statOut
statCmd.Stderr = &statOut
if err := statCmd.Run(); err != nil {
slog.Debug("git diff --stat failed", "error", err)
}
// Then get full diff
fullArgs := []string{"diff"}
fullArgs = append(fullArgs, args...)
fullCmd := exec.CommandContext(ctx, "git", fullArgs...)
fullCmd.Dir = workDir
var fullOut bytes.Buffer
fullCmd.Stdout = &fullOut
fullCmd.Stderr = &fullOut
if err := fullCmd.Run(); err != nil {
var exitErr *exec.ExitError
if !errors.As(err, &exitErr) {
return "", errors.WithMessage(err, "git diff")
}
}
stat := strings.TrimSpace(statOut.String())
full := strings.TrimSpace(fullOut.String())
if full == "" {
return "", nil
}
// Format: stat summary + full diff (truncated to 32KB)
var result strings.Builder
if stat != "" {
result.WriteString("## Summary\n```\n")
result.WriteString(stat)
result.WriteString("\n```\n\n")
}
result.WriteString("## Diff\n```diff\n")
if len(full) > 32*1024 {
result.WriteString(full[:32*1024])
result.WriteString("\n... (truncated)")
} else {
result.WriteString(full)
}
result.WriteString("\n```")
return result.String(), nil
}
// WaitForRAGReady blocks until the background RAG indexing flag clears or
// the timeout/context fires. It is a best-effort gate used by review
// commands so they do not query a half-built index right after startup.
func WaitForRAGReady(ctx context.Context, indexing *atomic.Bool, timeout time.Duration) {
deadline := time.Now().Add(timeout)
for indexing.Load() {
if ctx.Err() != nil {
return
}
if time.Now().After(deadline) {
return
}
select {
case <-ctx.Done():
return
case <-time.After(pollInterval):
}
}
}
// FormatContext renders the agent message history in a compact textual form
// suitable for the TUI context viewer.
func FormatContext(history []llm.Message) string {
var b strings.Builder
for i, msg := range history {
role := strings.ToUpper(msg.Role)
b.WriteString(fmt.Sprintf("─── [%d] %s ───\n", i, role))
content := msg.Content
if content != "" {
b.WriteString(content)
b.WriteString("\n")
}
for _, tc := range msg.ToolCalls {
b.WriteString(fmt.Sprintf(" → tool_call: %s(%v)\n", tc.Function.Name, tc.Function.Arguments))
}
if msg.ToolCallID != "" {
b.WriteString(fmt.Sprintf(" tool_call_id: %s\n", msg.ToolCallID))
}
}
b.WriteString(fmt.Sprintf("\nTotal messages: %d", len(history)))
return b.String()
}
// CleanupLegacyLangDirs removes per-language sub-index directories left over
// from earlier kodrun versions that partitioned RAG by language. Safe and
// idempotent — missing directories are silently ignored.
func CleanupLegacyLangDirs(basePath string) {
for _, lang := range []string{"go", "python", "jsts"} {
_ = os.RemoveAll(filepath.Join(basePath, lang))
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/config"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/projectlang"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/tools"
)
// NewOrchestrator builds an *agent.Orchestrator wired to the configured
// thinking, executor, and extractor providers. The chat client/model are
// reused as the primary "thinking" role unless the config specifies a
// dedicated thinking provider.
func NewOrchestrator(
client llm.Client,
chatProv *config.ProviderConfig,
reg *tools.Registry,
cfg *config.Config,
emit agent.EventHandler,
confirmFn agent.ConfirmFunc,
planConfirmFn agent.PlanConfirmFunc,
stepConfirmFn agent.StepConfirmFunc,
ruleCatalog string,
ragIndex tools.RAGSearcher,
godocIndexer tools.GoDocIndexer,
langState *projectlang.State,
rulesLoader *rules.Loader,
f *Flags,
) *agent.Orchestrator {
thinkProv := cfg.ThinkingProvider()
execProv := cfg.ExecutorProvider()
primaryClient := client
primaryModel := chatProv.Model
primaryCtx := chatProv.ContextSize
if cfg.Agent.ThinkingProvider != "" && cfg.Agent.ThinkingProvider != cfg.Agent.Provider {
primaryClient = NewLLMClient(&thinkProv)
primaryModel = thinkProv.Model
primaryCtx = thinkProv.ContextSize
}
var (
execClient llm.Client
execModel string
execCtxSize int
)
if cfg.Agent.ExecutorProvider != "" {
execClient = NewLLMClient(&execProv)
execModel = execProv.Model
execCtxSize = execProv.ContextSize
}
extractorProv := cfg.ExtractorProvider()
extractorClient := NewLLMClient(&extractorProv)
if cfg.Agent.ExtractorProvider == "" {
extractorClient = client
}
return agent.NewOrchestrator(primaryClient, primaryModel, reg, f.WorkDir, primaryCtx, &agent.OrchestratorConfig{
EventHandler: emit,
ConfirmFunc: confirmFn,
PlanConfirm: planConfirmFn,
StepConfirmFn: stepConfirmFn,
Language: cfg.Agent.Language,
RuleCatalog: ruleCatalog,
Review: cfg.Agent.Review,
HasSnippets: cfg.Snippets.UseTool && !cfg.RAG.Enabled,
HasRAG: cfg.RAG.Enabled,
PrefetchCode: cfg.Agent.PrefetchCode,
RAGIndex: ragIndex,
GodocIndexer: godocIndexer,
LangState: langState,
RulesLoader: rulesLoader,
ExecutorClient: execClient,
ExecutorModel: execModel,
ExecutorContextSize: execCtxSize,
ExtractorClient: extractorClient,
ExtractorModel: extractorProv.Model,
ExtractorContextSize: extractorProv.ContextSize,
ExtractorTemperature: extractorProv.Temperature,
ExtractorFormat: extractorProv.Format,
MaxParallelTasks: cfg.Agent.MaxParallelTasks,
MaxReplans: cfg.Agent.MaxReplans,
MaxIterations: cfg.Agent.MaxIterations,
SpecialistTimeout: cfg.Agent.SpecialistTimeout,
AutoCommit: cfg.Agent.AutoCommit,
Think: cfg.Agent.Think,
Verbose: f.Verbose,
})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"log/slog"
"os"
"path/filepath"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/config"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/mcp"
"github.com/raoptimus/kodrun/internal/projectlang"
"github.com/raoptimus/kodrun/internal/rag"
"github.com/raoptimus/kodrun/internal/ragdb/muninn"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/snippets"
"github.com/raoptimus/kodrun/internal/tools"
)
// defaultRAGTopK is the fallback Top-K used when the config does not set one.
const defaultRAGTopK = 5
// LoadConfig reads kodrun configuration and applies CLI flag overrides
// (model, ollama URL) on top of the file/env-derived values.
func LoadConfig(ctx context.Context, f *Flags) (config.Config, error) {
cfg, err := config.Load(ctx, f.Config, f.WorkDir)
if err != nil {
return cfg, err
}
if f.Model != "" {
p := cfg.ChatProvider()
p.Model = f.Model
cfg.Providers[cfg.Agent.Provider] = p
}
if f.OllamaURL != "" {
p := cfg.ChatProvider()
p.BaseURL = f.OllamaURL
cfg.Providers[cfg.Agent.Provider] = p
}
return cfg, nil
}
// AgentSetup holds the wired dependencies produced by SetupAgent. Keeping
// every collaborator addressable (loader, ragIndex, mcpMgr, ...) lets the
// caller drive lifecycle hooks (shutdown, telemetry) without re-deriving
// state from the agent object.
type AgentSetup struct {
Agent *agent.Agent
Registry *tools.Registry
Loader *rules.Loader
SnippetLoader *snippets.Loader
RAGIndex rag.Backend
GodocIndexer tools.GoDocIndexer
LangState *projectlang.State
MCPManager *mcp.Manager
Client llm.Client
}
// SetupAgent wires every agent dependency from configuration. It is split
// into small builder helpers (build*) so individual concerns can be tested
// or replaced without rewriting the entire wiring flow.
func SetupAgent(ctx context.Context, cfg *config.Config, scope rules.Scope, f *Flags) AgentSetup {
chatProv := cfg.ChatProvider()
chatClient := NewLLMClient(&chatProv)
loader, snippetLoader := buildRulesAndSnippets(ctx, f, cfg)
langState := buildLangState(cfg, f)
currentLang, _ := langState.EnsureDetected()
ragIndex, godocIndexer := buildRAG(cfg, f)
reg := buildToolRegistry(cfg, f, loader, snippetLoader, scope, currentLang, godocIndexer, langState, ragIndex)
mcpMgr := buildMCP(ctx, cfg, f, reg)
ag := agent.New(chatClient, chatProv.Model, reg, cfg.Agent.MaxIterations, f.WorkDir, chatProv.ContextSize)
ag.SetLanguageState(langState)
ag.SetGodocIndexer(godocIndexer)
ag.SetVerbose(f.Verbose)
return AgentSetup{
Agent: ag,
Registry: reg,
Loader: loader,
SnippetLoader: snippetLoader,
RAGIndex: ragIndex,
GodocIndexer: godocIndexer,
LangState: langState,
MCPManager: mcpMgr,
Client: chatClient,
}
}
// buildRulesAndSnippets loads the rules catalog and snippet library from
// the working directory. Both loaders log warnings on partial failures
// and continue with whatever they could parse.
func buildRulesAndSnippets(ctx context.Context, f *Flags, cfg *config.Config) (rulesLoader *rules.Loader, snippetLoader *snippets.Loader) {
rulesLoader = rules.NewLoader(f.WorkDir, cfg.Rules.MaxRefSize)
if err := rulesLoader.Load(ctx); err != nil {
slog.Warn("rules load failed", "error", err)
}
for _, ref := range rulesLoader.UnresolvedRefs() {
slog.Warn("rule references missing file",
"rule", ref.RulePath,
"ref", "@"+ref.RefPath,
)
}
snippetLoader = snippets.NewLoader(f.WorkDir)
if err := snippetLoader.Load(ctx); err != nil {
slog.Warn("snippets load failed", "error", err)
}
return rulesLoader, snippetLoader
}
// buildLangState detects the project's primary programming language. A
// configured override wins over marker-file detection; an empty result
// means "unknown" and language tools attach lazily on first Run.
func buildLangState(cfg *config.Config, f *Flags) *projectlang.State {
langState := projectlang.NewState(projectlang.New(f.WorkDir), projectlang.Language(cfg.Agent.ProjectLanguage))
langState.SetTechDetector(projectlang.NewTechDetector(f.WorkDir))
return langState
}
// buildRAG constructs the RAG backend (local vector store or Muninn) and
// returns nil values when RAG is disabled in config. The godoc indexer
// shares the same backend so go_doc can hit the embeddings index.
func buildRAG(cfg *config.Config, f *Flags) (rag.Backend, tools.GoDocIndexer) {
if !cfg.RAG.Enabled {
return nil, nil
}
var ragIndex rag.Backend
switch cfg.RAG.Backend {
case config.RAGBackendMuninn:
indexPath := cfg.RAG.IndexPath
if !filepath.IsAbs(indexPath) {
indexPath = filepath.Join(f.WorkDir, indexPath)
}
ragIndex = muninn.NewBackend(&muninn.Options{
URL: cfg.RAG.Muninn.URL,
Vault: cfg.RAG.Muninn.Vault,
APIKey: cfg.RAG.Muninn.APIKey,
StateDir: indexPath,
})
default: // "local"
indexPath := cfg.RAG.IndexPath
if !filepath.IsAbs(indexPath) {
indexPath = filepath.Join(f.WorkDir, indexPath)
}
ragProv := cfg.RAGProvider()
ragClient := NewLLMClient(&ragProv)
ragIndex = rag.NewMultiIndex(ragClient, ragProv.Model, indexPath)
// Legacy per-language sub-indexes (go/python/jsts) are no longer
// used — RAG indexes only project conventions, which are not
// partitioned by language. Remove any leftover directories from
// earlier kodrun versions so stale code chunks cannot survive.
CleanupLegacyLangDirs(indexPath)
}
if err := ragIndex.LoadCommon(); err != nil {
slog.Warn("RAG common index load failed", "error", err)
}
if err := ragIndex.LoadGodoc(); err != nil {
slog.Warn("RAG godoc index load failed", "error", err)
}
return ragIndex, &rag.BackendGodocAdapter{B: ragIndex}
}
// buildToolRegistry creates the tools registry, registers all built-in
// tools, RAG search (when enabled) and web_fetch. The returned registry
// is ready for MCP tool merging.
func buildToolRegistry(
cfg *config.Config,
f *Flags,
loader *rules.Loader,
snippetLoader *snippets.Loader,
scope rules.Scope,
currentLang projectlang.Language,
godocIndexer tools.GoDocIndexer,
langState *projectlang.State,
ragIndex rag.Backend,
) *tools.Registry {
reg := tools.NewRegistry()
tools.Register(reg, &tools.RegisterConfig{
WorkDir: f.WorkDir,
Forbidden: cfg.Tools.ForbiddenPatterns,
MaxReadLines: cfg.Tools.MaxReadLines,
Loader: loader,
SnippetLoader: snippetLoader,
Scope: scope,
UseRuleTool: cfg.Rules.UseTool,
UseSnippetTool: cfg.Snippets.UseTool,
RAGEnabled: cfg.RAG.Enabled,
Lang: currentLang,
Indexer: godocIndexer,
LangState: langState,
})
if ragIndex != nil {
reg.Register(tools.NewRAGSearchTool(ragIndex, cfg.RAG.TopK))
}
var webIndexer tools.WebIndexer
if ragIndex != nil {
webIndexer = &rag.BackendWebAdapter{B: ragIndex}
}
topK := cfg.RAG.TopK
if topK <= 0 {
topK = defaultRAGTopK
}
reg.Register(tools.NewWebFetchTool(webIndexer, topK))
return reg
}
// buildMCP starts every configured MCP server and merges their tools into
// the registry. Returns nil when no MCP server is configured. Failures
// are logged (warn/error) but never abort startup so the agent stays
// usable with the built-in toolset.
func buildMCP(ctx context.Context, cfg *config.Config, f *Flags, reg *tools.Registry) *mcp.Manager {
if len(cfg.MCP) == 0 {
return nil
}
mgr := mcp.NewManager()
mcpConfigs := make(map[string]mcp.ServerConfig, len(cfg.MCP))
for name, sc := range cfg.MCP {
mcpConfigs[name] = mcp.ServerConfig{
Command: sc.Command,
Args: sc.Args,
Env: sc.Env,
AutoApprove: sc.AutoApprove,
AutoApproveTools: sc.AutoApproveTools,
ReadOnlyTools: sc.ReadOnlyTools,
Disabled: sc.Disabled,
}
}
if err := mgr.Start(ctx, mcpConfigs, f.WorkDir); err != nil {
slog.Error("MCP start failed", "error", err)
}
for _, e := range mgr.Errors() {
slog.Warn("MCP server warning", "error", e)
}
mgr.RegisterTools(reg)
return mgr
}
// ConfigureSlog sets the default slog handler so that --verbose flips
// the log level from Info to Debug. We log to stderr so structured
// agent events on stdout are not interleaved with diagnostic noise.
func ConfigureSlog(verbose bool) {
level := slog.LevelInfo
if verbose {
level = slog.LevelDebug
}
slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: level})))
}
// NewLLMClient constructs an llm.Client for the given provider. A
// construction failure is fatal — without an LLM there is nothing the
// agent can do — so we exit immediately with a logged error.
func NewLLMClient(prov *config.ProviderConfig) llm.Client {
c, err := llm.NewClient(llm.ProviderConfig{
Type: prov.Type,
APIKey: prov.APIKey,
BaseURL: prov.BaseURL,
Timeout: prov.Timeout,
})
if err != nil {
slog.Error("failed to create LLM client", "type", prov.Type, "error", err)
os.Exit(1)
}
return c
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"strings"
"sync"
"sync/atomic"
"time"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/config"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/projectlang"
"github.com/raoptimus/kodrun/internal/rag"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/snippets"
"github.com/raoptimus/kodrun/internal/tools"
"github.com/raoptimus/kodrun/internal/tui"
)
// splitCmdArgParts is the SplitN limit for "/cmd arg" inputs.
const splitCmdArgParts = 2
// classifyTimeout caps how long the foreground classifier blocks before
// the dialog is surfaced (or skipped).
const classifyTimeout = 60 * time.Second
// ragWaitTimeout caps how long /code-review blocks waiting for background
// RAG indexing to finish.
const ragWaitTimeout = 2 * time.Minute
// TaskBaseCtx carries cross-cutting context used by every command handler:
// cancellation, configuration, CLI flags, on-disk session/rule paths.
type TaskBaseCtx struct {
Ctx context.Context
Cfg *config.Config
Flags *Flags
SessionsDir string
RuleCatalog string
}
// TaskAgentDeps groups the agent and its supporting infrastructure: LLM
// client, tool registry, rule/snippet loaders, RAG and language state.
type TaskAgentDeps struct {
Client llm.Client
ChatProv *config.ProviderConfig
Agent *agent.Agent
Registry *tools.Registry
RulesLoader *rules.Loader
SnippetsLoader *snippets.Loader
LangState *projectlang.State
RAGIndex rag.Backend
GodocIndexer tools.GoDocIndexer
}
// TaskUISignals bundles the channels and callbacks that connect a command
// handler back to the bubbletea TUI: event stream, confirm dialogs, model
// picker, plus background-indexing synchronisation primitives.
type TaskUISignals struct {
Emit agent.EventHandler
PlanConfirm agent.PlanConfirmFunc
StepConfirm agent.StepConfirmFunc
ModelPickerCh chan tui.ModelPickerRequest
Events chan agent.Event
BgWg *sync.WaitGroup
Indexing *atomic.Bool
}
// TaskDeps aggregates everything RunTask needs to dispatch one user input.
// Constructed once in main and reused for every turn. Composed of three
// thematic groups via embedding so call sites still write d.Cfg / d.Agent /
// d.Emit while the structure makes the responsibilities visible.
type TaskDeps struct {
TaskBaseCtx
TaskAgentDeps
TaskUISignals
}
// RunTask processes one user input — either a slash command or free-form
// text. Slash commands dispatch to built-in handlers; free text either runs
// the orchestrator (plan mode + cfg.Agent.Orchestrator) or hits ag.Send and
// then runs the classifier flow.
func RunTask(taskCtx context.Context, d *TaskDeps, input string) {
task := input
if strings.HasPrefix(input, "/") {
parts := strings.SplitN(input, " ", splitCmdArgParts)
cmdName := strings.TrimPrefix(parts[0], "/")
if handled := dispatchBuiltin(taskCtx, d, cmdName, parts); handled {
return
}
if cmdName == "orchestrate" {
runOrchestrate(taskCtx, d, task, parts)
return
}
if cmd, ok := d.RulesLoader.GetCommand(cmdName); ok {
task = cmd.Template
if len(parts) > 1 {
task = strings.ReplaceAll(task, "{{arg}}", strings.TrimSpace(parts[1]))
}
}
}
if d.Cfg.Agent.Orchestrator && d.Agent.Mode() == agent.ModePlan {
runOrchestratorPlan(taskCtx, d, task)
return
}
if err := d.Agent.Send(taskCtx, task); err != nil {
if taskCtx.Err() != nil {
d.Emit(&agent.Event{Type: agent.EventDone})
} else {
d.Emit(&agent.Event{Type: agent.EventError, Message: err.Error()})
d.Emit(&agent.Event{Type: agent.EventDone})
}
return
}
if d.Agent.Mode() == agent.ModePlan && d.Agent.LastPlan() != "" {
d.Emit(&agent.Event{Type: agent.EventAgent, Message: d.Agent.LastPlan()})
}
HandleClassifierFlow(taskCtx, &ClassifierDeps{
Agent: d.Agent,
Client: d.Client,
ChatProv: d.ChatProv,
Cfg: d.Cfg,
Registry: d.Registry,
PlanConfirm: d.PlanConfirm,
StepConfirm: d.StepConfirm,
RuleCatalog: d.RuleCatalog,
RAGIndex: d.RAGIndex,
GodocIndexer: d.GodocIndexer,
LangState: d.LangState,
RulesLoader: d.RulesLoader,
Flags: d.Flags,
}, d.Emit, task, classifyTimeout)
}
// dispatchBuiltin handles every built-in slash command. Returns true when
// cmdName matched and the handler emitted EventDone; false otherwise.
func dispatchBuiltin(taskCtx context.Context, d *TaskDeps, cmdName string, parts []string) bool {
switch cmdName {
case "model":
actionModel(taskCtx, d)
case "compact":
actionCompact(taskCtx, d, parts)
case CmdNameEdit:
actionEdit(d)
case "clear":
actionClear(d)
case "diff":
actionDiff(d, parts)
case "resume":
actionResume(d)
case "sessions":
actionSessions(d)
case "reindex":
actionReindex(d)
case "rag":
actionRAGStatus(d)
case "add_doc":
actionAddDoc(d, parts)
case "init":
actionInit(taskCtx, d)
case CmdNameCodeReview:
actionCodeReview(taskCtx, d, parts)
default:
return false
}
return true
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package cliapp
import (
"context"
"log/slog"
"os"
"sync"
"time"
tea "github.com/charmbracelet/bubbletea"
"golang.org/x/term"
"github.com/raoptimus/kodrun/internal/agent"
"github.com/raoptimus/kodrun/internal/config"
"github.com/raoptimus/kodrun/internal/mcp"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/tui"
)
// shutdownTimeout caps how long we wait for background goroutines to drain
// after the TUI exits. Three seconds is enough for normal cleanup; anything
// longer holds up exit while the user has already closed the program.
const shutdownTimeout = 3 * time.Second
// TUIParams aggregates the dependencies RunTUI needs. Most are produced by
// SetupAgent or by runRoot's local channels.
type TUIParams struct {
Agent *agent.Agent
Cfg *config.Config
ChatProv *config.ProviderConfig
RulesLoader *rules.Loader
MCPManager *mcp.Manager
Events chan agent.Event
ConfirmCh chan tui.ConfirmRequest
PlanConfirmCh chan tui.PlanConfirmRequest
StepConfirmCh chan tui.StepConfirmRequest
ModelPickerCh chan tui.ModelPickerRequest
WorkDir string
Version string
DefaultMode agent.Mode
TaskFn func(string)
CancelTask func()
Cancel context.CancelFunc
BgWg *sync.WaitGroup
}
// RunTUI builds the bubbletea model, saves/restores terminal state around
// p.Run, and orchestrates graceful shutdown: it cancels the root context,
// closes MCP servers, then waits up to shutdownTimeout for background
// goroutines to drain. Returns the error from tea.Program.Run.
func RunTUI(p *TUIParams) error {
ag := p.Agent
setModeFn := func(mode agent.Mode, think bool) {
if mode == agent.ModeEdit && ag.Mode() == agent.ModePlan && ag.LastPlan() != "" {
ag.EnterEditWithPlan()
} else {
ag.SetMode(mode)
ag.SetThink(think)
}
}
contextFn := func() string {
return FormatContext(ag.History())
}
commands := BuildCommandItems(p.RulesLoader)
model := tui.NewModel(p.ChatProv.Model, p.Version, p.ChatProv.ContextSize, p.TaskFn, p.CancelTask, p.Events, commands, p.ConfirmCh, p.PlanConfirmCh, p.StepConfirmCh, p.ModelPickerCh,
p.WorkDir, p.DefaultMode, p.Cfg.Agent.Think, setModeFn, contextFn, p.Cfg.Agent.Language, p.Cfg.TUI.MaxHistory)
// Save terminal state before bubbletea modifies it (alt screen, mouse reporting).
// Deferred restore acts as safety net if bubbletea cannot clean up.
if oldState, err := term.GetState(int(os.Stdin.Fd())); err == nil {
defer func() {
if restoreErr := term.Restore(int(os.Stdin.Fd()), oldState); restoreErr != nil {
slog.Warn("kodrun: failed to restore terminal state", "error", restoreErr)
}
}()
}
prog := tea.NewProgram(model, tea.WithAltScreen(), tea.WithMouseCellMotion())
if _, err := prog.Run(); err != nil {
return err
}
// Cancel context and wait for background goroutines with timeout.
p.Cancel()
// Close MCP servers early (before waiting) so their processes don't linger.
if p.MCPManager != nil {
p.MCPManager.Close()
}
waitDone := make(chan struct{})
go func() {
p.BgWg.Wait()
close(waitDone)
}()
select {
case <-waitDone:
case <-time.After(shutdownTimeout):
slog.Warn("kodrun: shutdown timeout, forcing exit")
}
// Intentionally do not close(events): on shutdown timeout, late goroutines
// may still attempt to send and would panic. The TUI has already exited
// (p.Run returned), so no consumer is left, and the channel will be
// reclaimed by GC once the goroutines finish.
return nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package config
import (
"context"
"errors"
"log/slog"
"os"
"path/filepath"
"time"
pkgerrors "github.com/pkg/errors"
"github.com/spf13/viper"
)
const (
providerDefault = "default"
// RAGBackendLocal is the default RAG backend using local in-memory vector
// index with disk persistence.
RAGBackendLocal = "local"
// RAGBackendMuninn selects Muninn DB as the RAG backend.
RAGBackendMuninn = "muninn"
)
// Config is the root configuration structure.
type Config struct {
Ollama OllamaConfig `mapstructure:"ollama"`
Providers map[string]ProviderConfig `mapstructure:"providers"`
Agent AgentConfig `mapstructure:"agent"`
Tools ToolsConfig `mapstructure:"tools"`
Rules RulesConfig `mapstructure:"rules"`
Snippets SnippetsConfig `mapstructure:"snippets"`
RAG RAGConfig `mapstructure:"rag"`
MCP map[string]MCPServerConfig `mapstructure:"mcp"`
TUI TUIConfig `mapstructure:"tui"`
}
// TUIConfig holds TUI-related settings.
type TUIConfig struct {
MaxHistory int `mapstructure:"max_history"`
}
// ChatProvider returns the provider config for chat/agent use.
func (c *Config) ChatProvider() ProviderConfig {
return c.Providers[c.Agent.Provider]
}
// ThinkingProvider returns the provider used by "thinking" roles
// (planner, reviewer, response classifier). Falls back to ChatProvider.
func (c *Config) ThinkingProvider() ProviderConfig {
if c.Agent.ThinkingProvider != "" {
if p, ok := c.Providers[c.Agent.ThinkingProvider]; ok {
return p
}
}
return c.ChatProvider()
}
// ExecutorProvider returns the provider used by the executor role.
// Falls back to ChatProvider.
func (c *Config) ExecutorProvider() ProviderConfig {
if c.Agent.ExecutorProvider != "" {
if p, ok := c.Providers[c.Agent.ExecutorProvider]; ok {
return p
}
}
return c.ChatProvider()
}
// ExtractorProvider returns the provider used by the extractor role for
// converting free-form planner output into a structured plan. Defaults to
// ChatProvider with Temperature=0 and Format="json" overlaid so that the same
// underlying model produces deterministic JSON without any user configuration.
func (c *Config) ExtractorProvider() ProviderConfig {
var p ProviderConfig
if c.Agent.ExtractorProvider != "" {
if pp, ok := c.Providers[c.Agent.ExtractorProvider]; ok {
p = pp
} else {
p = c.ChatProvider()
}
} else {
p = c.ChatProvider()
}
// Default to deterministic JSON if the user did not override.
if p.Format == "" {
p.Format = "json"
}
// Temperature 0 is intended unless the user explicitly set something
// non-zero in their dedicated extractor profile.
if c.Agent.ExtractorProvider == "" {
p.Temperature = 0
}
return p
}
// RAGProvider returns the provider config for RAG embedding.
func (c *Config) RAGProvider() ProviderConfig {
return c.Providers[c.RAG.Provider]
}
// MCPServerConfig holds configuration for a single MCP server.
type MCPServerConfig struct {
Command string `mapstructure:"command"`
Args []string `mapstructure:"args"`
Env map[string]string `mapstructure:"env"`
AutoApprove bool `mapstructure:"auto_approve"`
AutoApproveTools []string `mapstructure:"auto_approve_tools"`
ReadOnlyTools []string `mapstructure:"read_only_tools"`
Disabled bool `mapstructure:"disabled"`
}
// ProviderConfig describes a single LLM provider instance.
//
// Type selects the backend: "ollama" (default) or "openai" (for vllm and
// other OpenAI-compatible servers). APIKey is sent as a Bearer token when
// set (used by the "openai" backend).
//
// Temperature and Format are optional generation parameters used by roles that
// need different behaviour than the default chat profile. The extractor role,
// for instance, uses Temperature=0 and Format="json" to coerce structured
// output from the same underlying model.
type ProviderConfig struct {
Type string `mapstructure:"type"`
APIKey string `mapstructure:"api_key"`
BaseURL string `mapstructure:"base_url"`
Model string `mapstructure:"model"`
Timeout time.Duration `mapstructure:"timeout"`
ContextSize int `mapstructure:"context_size"`
Temperature float64 `mapstructure:"temperature"`
Format string `mapstructure:"format"`
}
// OllamaConfig holds Ollama API connection settings (legacy, kept for backward compatibility).
type OllamaConfig struct {
BaseURL string `mapstructure:"base_url"`
Model string `mapstructure:"model"`
Timeout time.Duration `mapstructure:"timeout"`
ContextSize int `mapstructure:"context_size"`
}
// AgentConfig holds agent behavior settings.
type AgentConfig struct {
Provider string `mapstructure:"provider"`
ThinkingProvider string `mapstructure:"thinking_provider"`
ExecutorProvider string `mapstructure:"executor_provider"`
ExtractorProvider string `mapstructure:"extractor_provider"`
MaxIterations int `mapstructure:"max_iterations"`
MaxParallelTasks int `mapstructure:"max_parallel_tasks"`
MaxReplans int `mapstructure:"max_replans"`
MaxToolWorkers int `mapstructure:"max_tool_workers"`
AutoFix bool `mapstructure:"auto_fix"`
AutoCommit bool `mapstructure:"auto_commit"`
DefaultMode string `mapstructure:"default_mode"`
Think bool `mapstructure:"think"`
Language string `mapstructure:"language"`
AutoCompact bool `mapstructure:"auto_compact"`
Orchestrator bool `mapstructure:"orchestrator"`
Review bool `mapstructure:"review"`
PrefetchCode bool `mapstructure:"prefetch_code"`
// ProjectLanguage overrides automatic project-language detection.
// Valid values: "go", "python", "jsts". Empty enables auto-detection.
ProjectLanguage string `mapstructure:"project_language"`
// SpecialistTimeout caps wall time for a single review phase.
// 0 means no deadline (only the HTTP client timeout applies).
SpecialistTimeout time.Duration `mapstructure:"specialist_timeout"`
}
// ToolsConfig holds file tool restrictions.
type ToolsConfig struct {
AllowedDirs []string `mapstructure:"allowed_dirs"`
ForbiddenPatterns []string `mapstructure:"forbidden_patterns"`
MaxReadLines int `mapstructure:"max_read_lines"`
}
// RulesConfig holds settings for rules, commands, and docs.
type RulesConfig struct {
MaxRefSize int `mapstructure:"max_ref_size"`
UseTool bool `mapstructure:"use_tool"`
}
// SnippetsConfig holds settings for project snippets.
type SnippetsConfig struct {
UseTool bool `mapstructure:"use_tool"`
}
// RAGConfig holds RAG (Retrieval-Augmented Generation) settings.
//
// Backend selects the storage engine: "local" (default, in-memory vector
// index with disk persistence) or "muninn" (Muninn DB server that handles
// embeddings internally).
//
// When Backend is "local", the embedding model and connection details come
// from the provider profile referenced by Provider. When Backend is
// "muninn", the Provider field is not required — Muninn handles embeddings
// on its own.
//
// Scope: RAG indexes project conventions only — files under
// `.kodrun/rules/`, `.kodrun/snippets/`, `.kodrun/docs/`, plus embedded
// language standards (e.g. Effective Go). Project source code is NOT
// indexed: chunked code snapshots go stale between reindexes and lead to
// reviewers citing code that no longer exists. Live `read_file` calls are
// the authoritative view of source files.
type RAGConfig struct {
Provider string `mapstructure:"provider"`
Enabled bool `mapstructure:"enabled"`
// Backend selects the RAG storage engine: "local" (default) or "muninn".
Backend string `mapstructure:"backend"`
Muninn MuninnConfig `mapstructure:"muninn"`
// Deprecated: kodrun no longer indexes project source code. Only
// .kodrun/rules, .kodrun/snippets, .kodrun/docs and embedded language
// standards are indexed. The field is kept so old configs continue to
// parse, but it is ignored at runtime.
IndexDirs []string `mapstructure:"index_dirs"`
// Deprecated: no longer used — see IndexDirs.
ExcludeDirs []string `mapstructure:"exclude_dirs"`
ChunkSize int `mapstructure:"chunk_size"`
ChunkOverlap int `mapstructure:"chunk_overlap"`
// Deprecated: no longer used — see IndexDirs.
MaxChunksPerFile int `mapstructure:"max_chunks_per_file"`
TopK int `mapstructure:"top_k"`
// ReviewBudgetBytes caps the size of the RAG prefetch block injected
// into /code-review prompts. 0 falls back to the built-in default.
ReviewBudgetBytes int `mapstructure:"review_budget_bytes"`
IndexPath string `mapstructure:"index_path"`
}
// MuninnConfig holds Muninn DB connection settings.
type MuninnConfig struct {
URL string `mapstructure:"url"`
Vault string `mapstructure:"vault"`
APIKey string `mapstructure:"api_key"`
}
// Default configuration values.
const (
defaultOllamaTimeout = 5 * time.Minute
defaultContextSize = 32768
defaultMaxIterations = 50
defaultMaxToolWorkers = 4
defaultMaxReplans = 2
defaultSpecTimeout = 5 * time.Minute
defaultMaxReadLines = 500
defaultMaxRefSize = 4096
defaultRAGChunkSize = 128
defaultRAGChunkOverlap = 16
defaultRAGMaxChunks = 8
defaultRAGTopK = 5
defaultRAGReviewBudget = 24 * 1024
defaultMaxHistory = 100
minProviderContextSize = 1024
defaultProviderTimeout = 5 * time.Minute
)
// Defaults returns a Config with default values.
func Defaults() Config {
return Config{
Ollama: OllamaConfig{
BaseURL: "http://localhost:11434",
Model: "qwen3-coder:30b",
Timeout: defaultOllamaTimeout,
ContextSize: defaultContextSize,
},
Agent: AgentConfig{
Provider: providerDefault,
MaxIterations: defaultMaxIterations,
MaxToolWorkers: defaultMaxToolWorkers,
MaxParallelTasks: 1,
MaxReplans: defaultMaxReplans,
AutoFix: true,
AutoCommit: false,
DefaultMode: "plan",
Think: true,
Language: "en",
AutoCompact: true,
SpecialistTimeout: defaultSpecTimeout,
},
Tools: ToolsConfig{
AllowedDirs: []string{"."},
ForbiddenPatterns: []string{
"*.env", "*.pem", "*.key",
"node_modules/**", "vendor/**",
"*.exe", "*.bin", "*.o", "*.a", "*.so", "*.dylib",
},
MaxReadLines: defaultMaxReadLines,
},
Rules: RulesConfig{
MaxRefSize: defaultMaxRefSize,
UseTool: true,
},
Snippets: SnippetsConfig{
UseTool: true,
},
RAG: RAGConfig{
Enabled: false,
Backend: RAGBackendLocal,
Muninn: MuninnConfig{URL: "http://127.0.0.1:8475", Vault: "kodrun"},
IndexDirs: []string{"."},
ExcludeDirs: []string{".claude", ".git", "vendor", "node_modules", ".kodrun/rag_index"},
ChunkSize: defaultRAGChunkSize,
ChunkOverlap: defaultRAGChunkOverlap,
MaxChunksPerFile: defaultRAGMaxChunks,
TopK: defaultRAGTopK,
ReviewBudgetBytes: defaultRAGReviewBudget,
IndexPath: ".kodrun/rag_index",
},
TUI: TUIConfig{
MaxHistory: defaultMaxHistory,
},
}
}
// Load reads configuration from files and environment variables.
func Load(ctx context.Context, configPath, workDir string) (Config, error) {
cfg := Defaults()
v := viper.New()
v.SetConfigType("yaml")
// Set defaults from struct
v.SetDefault("ollama.base_url", cfg.Ollama.BaseURL)
v.SetDefault("ollama.model", cfg.Ollama.Model)
v.SetDefault("ollama.timeout", cfg.Ollama.Timeout)
v.SetDefault("ollama.context_size", cfg.Ollama.ContextSize)
v.SetDefault("agent.max_iterations", cfg.Agent.MaxIterations)
v.SetDefault("agent.max_tool_workers", cfg.Agent.MaxToolWorkers)
v.SetDefault("agent.auto_fix", cfg.Agent.AutoFix)
v.SetDefault("agent.auto_commit", cfg.Agent.AutoCommit)
v.SetDefault("agent.default_mode", cfg.Agent.DefaultMode)
v.SetDefault("agent.think", cfg.Agent.Think)
v.SetDefault("tools.allowed_dirs", cfg.Tools.AllowedDirs)
v.SetDefault("tools.forbidden_patterns", cfg.Tools.ForbiddenPatterns)
v.SetDefault("tools.max_read_lines", cfg.Tools.MaxReadLines)
v.SetDefault("rules.max_ref_size", cfg.Rules.MaxRefSize)
v.SetDefault("tui.max_history", cfg.TUI.MaxHistory)
// Explicit config file
if configPath != "" {
v.SetConfigFile(configPath)
if err := v.ReadInConfig(); err != nil {
return cfg, err
}
} else {
// Global config
home, homeErr := os.UserHomeDir()
if homeErr == nil && home != "" {
v.AddConfigPath(filepath.Join(home, ".config", "kodrun"))
v.SetConfigName("config")
}
if err := v.ReadInConfig(); err != nil {
// Global config is optional — ignore "not found" errors.
var notFound viper.ConfigFileNotFoundError
if !errors.As(err, ¬Found) {
slog.Debug("failed to read global config", "error", err)
}
}
// Project-local config (higher priority)
localV := viper.New()
localV.SetConfigType("yaml")
localV.SetConfigFile(filepath.Join(workDir, ".kodrun.yaml"))
if err := localV.ReadInConfig(); err == nil {
if err := v.MergeConfigMap(localV.AllSettings()); err != nil {
return cfg, err
}
}
}
if err := v.Unmarshal(&cfg); err != nil {
return cfg, err
}
cfg.migrate(ctx)
if err := cfg.Validate(ctx); err != nil {
return cfg, err
}
return cfg, nil
}
// migrate converts legacy ollama config to providers format.
func (c *Config) migrate(_ context.Context) {
if len(c.Providers) == 0 {
c.Providers = map[string]ProviderConfig{
providerDefault: {
BaseURL: c.Ollama.BaseURL,
Model: c.Ollama.Model,
Timeout: c.Ollama.Timeout,
ContextSize: c.Ollama.ContextSize,
},
}
}
if c.Agent.Provider == "" {
c.Agent.Provider = providerDefault
}
if c.RAG.Provider == "" {
c.RAG.Provider = providerDefault
}
if c.RAG.Backend == "" {
c.RAG.Backend = RAGBackendLocal
}
}
// Validate checks that configuration values are within acceptable ranges.
func (c *Config) Validate(_ context.Context) error {
// Validate provider references
if _, ok := c.Providers[c.Agent.Provider]; !ok {
return pkgerrors.Errorf("agent.provider %q not found in providers", c.Agent.Provider)
}
if c.Agent.ThinkingProvider != "" {
if _, ok := c.Providers[c.Agent.ThinkingProvider]; !ok {
return pkgerrors.Errorf("agent.thinking_provider %q not found in providers", c.Agent.ThinkingProvider)
}
}
if c.Agent.ExecutorProvider != "" {
if _, ok := c.Providers[c.Agent.ExecutorProvider]; !ok {
return pkgerrors.Errorf("agent.executor_provider %q not found in providers", c.Agent.ExecutorProvider)
}
}
if c.Agent.ExtractorProvider != "" {
if _, ok := c.Providers[c.Agent.ExtractorProvider]; !ok {
return pkgerrors.Errorf("agent.extractor_provider %q not found in providers", c.Agent.ExtractorProvider)
}
}
if c.RAG.Enabled {
switch c.RAG.Backend {
case RAGBackendLocal:
ragProv, ok := c.Providers[c.RAG.Provider]
if !ok {
return pkgerrors.Errorf("rag.provider %q not found in providers", c.RAG.Provider)
}
if ragProv.Model == "" {
return pkgerrors.Errorf("rag.provider %q has empty model — set providers.%s.model to your embedding model (e.g. nomic-embed-text)", c.RAG.Provider, c.RAG.Provider)
}
case RAGBackendMuninn:
if c.RAG.Muninn.URL == "" {
return pkgerrors.New("rag.muninn.url is required when backend is \"muninn\"")
}
default:
return pkgerrors.Errorf("rag.backend %q is not supported (use \"local\" or \"muninn\")", c.RAG.Backend)
}
}
// Apply defaults to each provider
for name, p := range c.Providers {
if p.ContextSize < minProviderContextSize {
p.ContextSize = defaultContextSize
}
if p.Timeout <= 0 {
p.Timeout = defaultProviderTimeout
}
c.Providers[name] = p
}
if c.Agent.MaxIterations <= 0 {
c.Agent.MaxIterations = defaultMaxIterations
}
if c.Agent.MaxToolWorkers <= 0 {
c.Agent.MaxToolWorkers = 1
}
if c.Agent.MaxParallelTasks <= 0 {
c.Agent.MaxParallelTasks = 1
}
if c.Agent.MaxReplans < 0 {
c.Agent.MaxReplans = 0
}
if c.Agent.MaxReplans == 0 {
c.Agent.MaxReplans = 2
}
if c.Tools.MaxReadLines <= 0 {
c.Tools.MaxReadLines = 500
}
if c.TUI.MaxHistory <= 0 {
c.TUI.MaxHistory = 100
}
return nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package kodruninit
import (
"context"
"embed"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
)
const (
dirPermission = 0o755
filePermission = 0o644
maxReadmeBytes = 2000 // max bytes to include from README.md
maxGoFileLines = 100 // max lines per key Go file in project context
maxGoFilesCount = 101 // SplitN limit (maxGoFileLines + 1)
maxKeyGoFiles = 10 // max number of key Go files to collect
maxGoFileDepth = 2 // max directory depth for key Go files
dirGit = ".git"
dirKodrun = ".kodrun"
dirNodeModules = "node_modules"
dirVendor = "vendor"
)
//go:embed all:examples
var examples embed.FS
// Result holds the outcome of the init operation.
type Result struct {
Created []string // relative paths of created dirs and files
}
// Run creates the .kodrun/ starter structure and AGENTS.md.
// Returns an error if AGENTS.md already exists.
func Run(ctx context.Context, workDir string, client llm.Client, model string) (*Result, error) {
agentsPath := filepath.Join(workDir, "AGENTS.md")
if _, err := os.Stat(agentsPath); err == nil {
return nil, errors.New("AGENTS.md already exists")
}
res := &Result{}
dirs := []string{
".kodrun/rules",
".kodrun/docs",
".kodrun/commands",
".kodrun/snippets",
}
for _, d := range dirs {
full := filepath.Join(workDir, d)
if _, err := os.Stat(full); err == nil {
continue
}
if err := os.MkdirAll(full, dirPermission); err != nil {
return nil, errors.WithMessagef(err, "create %s", d)
}
res.Created = append(res.Created, d+"/")
}
// Copy embedded examples to .kodrun/ (rules, commands, docs, snippets).
embeddedDirs := []struct{ src, dest string }{
{"examples/rules", ".kodrun/rules"},
{"examples/commands", ".kodrun/commands"},
{"examples/docs", ".kodrun/docs"},
{"examples/snippets", ".kodrun/snippets"},
}
for _, ed := range embeddedDirs {
created, err := copyEmbeddedDir(examples, ed.src, workDir, ed.dest)
if err != nil {
return nil, err
}
res.Created = append(res.Created, created...)
}
// .kodrun/.gitignore
gitignorePath := filepath.Join(workDir, dirKodrun, ".gitignore")
if _, err := os.Stat(gitignorePath); os.IsNotExist(err) {
if err := os.WriteFile(gitignorePath, []byte("# KodRun local state\n*.log\n"), filePermission); err != nil {
return nil, errors.WithMessage(err, "write .kodrun/.gitignore")
}
res.Created = append(res.Created, ".kodrun/.gitignore")
}
// Scan the project and generate AGENTS.md via LLM.
agentsContent, err := generateAgentsMD(ctx, workDir, client, model)
if err != nil {
return nil, errors.WithMessage(err, "generate AGENTS.md")
}
if err := os.WriteFile(agentsPath, []byte(agentsContent), filePermission); err != nil {
return nil, errors.WithMessage(err, "write AGENTS.md")
}
res.Created = append(res.Created, "AGENTS.md")
sort.Strings(res.Created)
return res, nil
}
// generateAgentsMD collects project context and asks the LLM to produce AGENTS.md.
func generateAgentsMD(ctx context.Context, workDir string, client llm.Client, model string) (string, error) {
projectCtx := collectProjectContext(ctx, workDir)
prompt := `Analyze the following Go project information and generate an AGENTS.md file.
The file must include:
1. Project name and brief description (1-2 sentences) based on the code structure and go.mod module name
2. Go version requirement (minimum Go 1.25+)
3. Architecture overview — describe the main packages and their responsibilities
4. Directory structure (concise tree)
5. Commands to build, test, lint the project (based on Makefile if present, otherwise standard go commands)
6. Key conventions or patterns visible from the code structure
Output ONLY the markdown content for AGENTS.md, starting with "# AGENTS.md". Be concise but informative. Write in the same language as any existing documentation found in the project (default to English if none found).
Project information:
` + projectCtx
resp, err := client.ChatSync(ctx, &llm.ChatRequest{
Model: model,
Messages: []llm.Message{
{Role: "user", Content: prompt},
},
})
if err != nil {
return "", errors.WithMessage(err, "LLM request")
}
content := strings.TrimSpace(resp.Content)
if content == "" {
return "", errors.New("LLM returned empty response")
}
return content + "\n", nil
}
// collectProjectContext gathers files and structure for the LLM prompt.
func collectProjectContext(ctx context.Context, workDir string) string {
var b strings.Builder
// go.mod
if data, err := os.ReadFile(filepath.Join(workDir, "go.mod")); err == nil {
b.WriteString("=== go.mod ===\n")
b.Write(data)
b.WriteString("\n\n")
}
// Makefile
if data, err := os.ReadFile(filepath.Join(workDir, "Makefile")); err == nil {
b.WriteString("=== Makefile ===\n")
b.Write(data)
b.WriteString("\n\n")
}
// README.md (if exists)
if data, err := os.ReadFile(filepath.Join(workDir, "README.md")); err == nil {
b.WriteString("=== README.md ===\n")
// Limit to first maxReadmeBytes bytes
if len(data) > maxReadmeBytes {
data = data[:maxReadmeBytes]
}
b.Write(data)
b.WriteString("\n\n")
}
// Directory tree
b.WriteString("=== Directory structure ===\n")
b.WriteString(buildTree(ctx, workDir))
b.WriteByte('\n')
// Collect key .go files (main.go, top-level package files) — first 100 lines each
goFiles := findKeyGoFiles(ctx, workDir)
for _, rel := range goFiles {
data, err := os.ReadFile(filepath.Join(workDir, rel))
if err != nil {
continue
}
lines := strings.SplitN(string(data), "\n", maxGoFilesCount)
if len(lines) > maxGoFileLines {
lines = lines[:maxGoFileLines]
}
b.WriteString(fmt.Sprintf("=== %s (first %d lines) ===\n", rel, maxGoFileLines))
b.WriteString(strings.Join(lines, "\n"))
b.WriteString("\n\n")
}
return b.String()
}
// findKeyGoFiles returns relative paths of important .go files for context.
func findKeyGoFiles(_ context.Context, workDir string) []string {
var files []string
maxFiles := 10
if err := filepath.WalkDir(workDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return filepath.SkipDir
}
if d.IsDir() {
base := filepath.Base(path)
if base == dirGit || base == dirVendor || base == dirNodeModules || base == dirKodrun {
return filepath.SkipDir
}
return nil
}
if len(files) >= maxFiles {
return filepath.SkipAll
}
if filepath.Ext(path) != ".go" {
return nil
}
if strings.HasSuffix(path, "_test.go") {
return nil
}
rel, relErr := filepath.Rel(workDir, path)
if relErr != nil {
return filepath.SkipDir
}
depth := strings.Count(rel, string(filepath.Separator))
// Prioritize: main.go, cmd/*, top-level, shallow files
if depth <= maxGoFileDepth {
files = append(files, rel)
}
return nil
}); err != nil {
return nil
}
return files
}
func buildTree(_ context.Context, workDir string) string {
var lines []string
seen := make(map[string]bool)
if err := filepath.WalkDir(workDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return filepath.SkipDir
}
rel, relErr := filepath.Rel(workDir, path)
if relErr != nil {
return filepath.SkipDir
}
if rel == "." {
return nil
}
base := filepath.Base(rel)
if d.IsDir() {
switch {
case base == dirGit || base == dirVendor || base == dirNodeModules:
return filepath.SkipDir
case strings.HasPrefix(base, ".") && base != dirKodrun:
return filepath.SkipDir
}
}
depth := strings.Count(rel, string(filepath.Separator))
if d.IsDir() {
if depth > maxGoFileDepth {
return filepath.SkipDir
}
if !seen[rel] {
indent := strings.Repeat(" ", depth)
lines = append(lines, fmt.Sprintf("%s%s/", indent, base))
seen[rel] = true
}
} else if depth <= 1 {
indent := strings.Repeat(" ", depth)
lines = append(lines, fmt.Sprintf("%s%s", indent, base))
}
return nil
}); err != nil {
return ""
}
if len(lines) == 0 {
return ""
}
return strings.Join(lines, "\n") + "\n"
}
// copyEmbeddedDir copies .md files from an embed.FS directory to a destination under workDir.
// Existing files are not overwritten.
func copyEmbeddedDir(efs embed.FS, srcDir, workDir, destDir string) ([]string, error) {
entries, err := fs.ReadDir(efs, srcDir)
if err != nil || len(entries) == 0 {
return nil, err
}
created := make([]string, 0, len(entries))
for _, entry := range entries {
if entry.IsDir() || filepath.Ext(entry.Name()) != ".md" {
continue
}
destPath := filepath.Join(workDir, destDir, entry.Name())
if _, err := os.Stat(destPath); err == nil {
continue
}
data, err := efs.ReadFile(srcDir + "/" + entry.Name())
if err != nil {
continue
}
if err := os.WriteFile(destPath, data, filePermission); err != nil {
return created, errors.WithMessagef(err, "write %s/%s", destDir, entry.Name())
}
created = append(created, destDir+"/"+entry.Name())
}
return created, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package llm
import (
"bytes"
"context"
"github.com/pkg/errors"
)
// AggregateChatStream reads all chunks from the channel and returns
// an aggregated ChatChunk. It applies fallback tool-call parsing and
// error-JSON detection on the final result.
func AggregateChatStream(ctx context.Context, ch <-chan ChatChunk, cb ChunkCallback) (ChatChunk, error) {
var result ChatChunk
var contentBuf bytes.Buffer
var evalTokens int
for chunk := range ch {
if chunk.Error != nil {
// If we already accumulated content, return it instead of
// failing completely. This handles truncated streams where
// most of the response was received successfully.
if contentBuf.Len() > 0 {
break
}
return ChatChunk{}, chunk.Error
}
if chunk.Content != "" {
contentBuf.WriteString(chunk.Content)
evalTokens++
}
if len(chunk.ToolCalls) > 0 {
result.ToolCalls = append(result.ToolCalls, chunk.ToolCalls...)
}
result.Done = chunk.Done
if chunk.PromptEvalCount > 0 {
result.PromptEvalCount = chunk.PromptEvalCount
}
if chunk.EvalCount > 0 {
result.EvalCount = chunk.EvalCount
}
if chunk.EvalDuration > 0 {
result.EvalDuration = chunk.EvalDuration
}
if chunk.PromptEvalDuration > 0 {
result.PromptEvalDuration = chunk.PromptEvalDuration
}
if chunk.TotalDuration > 0 {
result.TotalDuration = chunk.TotalDuration
}
if chunk.LoadDuration > 0 {
result.LoadDuration = chunk.LoadDuration
}
if cb != nil {
cb(evalTokens, chunk.Content)
}
}
result.Content = contentBuf.String()
if msg := DetectErrorJSON(result.Content); msg != "" {
return ChatChunk{}, errors.Errorf("llm error: %s", msg)
}
if len(result.ToolCalls) == 0 && result.Content != "" {
if parsed, ok := ParseToolCalls(result.Content); ok {
result.ToolCalls = parsed
result.Content = CleanToolCallText(result.Content)
}
}
return result, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package llm
import (
"encoding/json"
"net"
"strings"
"github.com/pkg/errors"
)
// DetectErrorJSON returns a non-empty message when content is an error envelope
// like {"error":{"type":"...","message":"..."}} or {"error":"..."}.
// Returns "" for normal content.
func DetectErrorJSON(content string) string {
s := strings.TrimSpace(content)
if len(s) < 2 || s[0] != '{' {
return ""
}
var envelope struct {
Error json.RawMessage `json:"error"`
}
if err := json.Unmarshal([]byte(s), &envelope); err != nil || len(envelope.Error) == 0 {
return ""
}
var asStr string
if err := json.Unmarshal(envelope.Error, &asStr); err == nil && asStr != "" {
return asStr
}
var asObj struct {
Type string `json:"type"`
Message string `json:"message"`
}
if err := json.Unmarshal(envelope.Error, &asObj); err == nil {
if asObj.Message != "" {
return asObj.Message
}
if asObj.Type != "" {
return asObj.Type
}
}
return ""
}
// IsDialError returns true when the error is a TCP dial failure (connection
// refused, DNS resolution error, etc.). These indicate that the server is down
// and retrying immediately is pointless.
func IsDialError(err error) bool {
var opErr *net.OpError
if errors.As(err, &opErr) && opErr.Op == "dial" {
return true
}
var dnsErr *net.DNSError
return errors.As(err, &dnsErr)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package llm
import (
"fmt"
"time"
)
// ProviderConfig holds the configuration needed to create an LLM client.
type ProviderConfig struct {
Type string
APIKey string
BaseURL string
Timeout time.Duration
}
// ClientFactory is a function that creates an LLM client from a provider config.
type ClientFactory func(cfg ProviderConfig) (Client, error)
var factories = map[string]ClientFactory{}
// RegisterFactory registers a client factory for a given provider type.
func RegisterFactory(providerType string, f ClientFactory) {
factories[providerType] = f
}
// NewClient creates an LLM client based on the provider config type.
func NewClient(cfg ProviderConfig) (Client, error) {
t := cfg.Type
if t == "" {
t = "ollama"
}
f, ok := factories[t]
if !ok {
return nil, fmt.Errorf("unknown provider type: %q", t)
}
return f(cfg)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package ollama
import (
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"strings"
"time"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
)
const maxErrorBodyBytes = 1024 * 1024
const (
defaultMaxRetries = 3
streamChanBuffer = 16
streamScannerInitSize = 256 * 1024
streamScannerMaxSize = 1024 * 1024
dialTimeout = 30 * time.Second
dialKeepAlive = 30 * time.Second
idleConnTimeout = 90 * time.Second
streamIdleTimeout = 2 * time.Minute
)
func init() {
llm.RegisterFactory("ollama", func(cfg llm.ProviderConfig) (llm.Client, error) {
return New(cfg.BaseURL, cfg.Timeout), nil
})
}
// Client communicates with the Ollama API.
type Client struct {
baseURL string
httpClient *http.Client
maxRetries int
}
// New creates a new Ollama API client.
func New(baseURL string, timeout time.Duration) *Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: dialTimeout,
KeepAlive: dialKeepAlive,
}).DialContext,
ResponseHeaderTimeout: timeout,
IdleConnTimeout: idleConnTimeout,
}
return &Client{
baseURL: baseURL,
httpClient: &http.Client{
Transport: transport,
},
maxRetries: defaultMaxRetries,
}
}
func (c *Client) Ping(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/tags", http.NoBody)
if err != nil {
return errors.WithMessage(err, "create request")
}
resp, err := c.httpClient.Do(req)
if err != nil {
return errors.WithMessagef(err, "ollama unreachable at %s (is 'ollama serve' running?)", c.baseURL)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.Errorf("ollama returned status %d", resp.StatusCode)
}
return nil
}
func (c *Client) Models(ctx context.Context) ([]llm.Model, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/api/tags", http.NoBody)
if err != nil {
return nil, err
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, errors.WithMessage(err, "list models")
}
defer resp.Body.Close()
var result modelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Models, nil
}
func (c *Client) Chat(ctx context.Context, chatReq *llm.ChatRequest) (<-chan llm.ChatChunk, error) {
wireReq := chatRequest{
Model: chatReq.Model,
Messages: chatReq.Messages,
Tools: chatReq.Tools,
Options: chatReq.Options,
Format: chatReq.Format,
Stream: true,
}
body, err := json.Marshal(wireReq)
if err != nil {
return nil, errors.WithMessage(err, "marshal request")
}
var resp *http.Response
for attempt := range c.maxRetries {
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/chat", bytes.NewReader(body))
if reqErr != nil {
return nil, errors.WithMessage(reqErr, "create request")
}
req.Header.Set("Content-Type", "application/json")
resp, err = c.httpClient.Do(req)
if err != nil {
if llm.IsDialError(err) {
return nil, errors.WithMessage(err, "chat request")
}
if attempt < c.maxRetries-1 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(1<<attempt) * time.Second):
continue
}
}
return nil, errors.WithMessage(err, "chat request")
}
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable {
resp.Body.Close()
if attempt < c.maxRetries-1 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(1<<attempt) * time.Second):
continue
}
}
return nil, errors.Errorf("ollama returned %d after %d retries", resp.StatusCode, c.maxRetries)
}
if resp.StatusCode != http.StatusOK {
bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
resp.Body.Close()
if readErr != nil {
return nil, errors.Errorf("ollama returned %d and failed to read body: %v", resp.StatusCode, readErr)
}
if resp.StatusCode == http.StatusInternalServerError &&
strings.Contains(string(bodyBytes), "XML syntax error") &&
len(wireReq.Tools) > 0 {
wireReq.Tools = nil
chatReq.Tools = nil
body, err = json.Marshal(wireReq)
if err != nil {
return nil, errors.WithMessage(err, "marshal retry request")
}
continue
}
return nil, errors.Errorf("ollama error %d: %s", resp.StatusCode, string(bodyBytes))
}
break
}
ch := make(chan llm.ChatChunk, streamChanBuffer)
go c.streamResponse(ctx, resp, ch)
return ch, nil
}
func (c *Client) streamResponse(ctx context.Context, resp *http.Response, ch chan<- llm.ChatChunk) {
defer close(ch)
defer resp.Body.Close()
idle := time.AfterFunc(streamIdleTimeout, func() { resp.Body.Close() })
defer idle.Stop()
scanner := bufio.NewScanner(resp.Body)
scanner.Buffer(make([]byte, 0, streamScannerInitSize), streamScannerMaxSize)
for scanner.Scan() {
line := scanner.Bytes()
if len(line) == 0 {
continue
}
var chatResp chatResponse
if err := json.Unmarshal(line, &chatResp); err != nil {
select {
case ch <- llm.ChatChunk{Error: errors.WithMessage(err, "decode chunk")}:
case <-ctx.Done():
}
return
}
// Reset idle timer only on meaningful content (not empty keepalive chunks).
if chatResp.Message.Content != "" || chatResp.Done || len(chatResp.Message.ToolCalls) > 0 {
idle.Reset(streamIdleTimeout)
}
chunk := llm.ChatChunk{
Content: chatResp.Message.Content,
ToolCalls: chatResp.Message.ToolCalls,
Done: chatResp.Done,
PromptEvalCount: chatResp.PromptEvalCount,
PromptEvalDuration: chatResp.PromptEvalDuration,
EvalCount: chatResp.EvalCount,
EvalDuration: chatResp.EvalDuration,
TotalDuration: chatResp.TotalDuration,
LoadDuration: chatResp.LoadDuration,
}
select {
case ch <- chunk:
case <-ctx.Done():
return
}
if chatResp.Done {
return
}
}
if err := scanner.Err(); err != nil {
select {
case ch <- llm.ChatChunk{Error: errors.WithMessage(err, "read stream")}:
case <-ctx.Done():
}
}
}
func (c *Client) ChatSync(ctx context.Context, chatReq *llm.ChatRequest) (llm.ChatChunk, error) {
return c.ChatSyncWithCallback(ctx, chatReq, nil)
}
func (c *Client) ChatSyncWithCallback(ctx context.Context, chatReq *llm.ChatRequest, cb llm.ChunkCallback) (llm.ChatChunk, error) {
ch, err := c.Chat(ctx, chatReq)
if err != nil {
return llm.ChatChunk{}, err
}
return llm.AggregateChatStream(ctx, ch, cb)
}
func (c *Client) Embed(ctx context.Context, req llm.EmbedRequest) (*llm.EmbedResponse, error) {
wireReq := embedRequest{
Model: req.Model,
Input: req.Input,
Truncate: req.Truncate,
}
body, err := json.Marshal(wireReq)
if err != nil {
return nil, errors.WithMessage(err, "marshal embed request")
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/embed", bytes.NewReader(body))
if err != nil {
return nil, errors.WithMessage(err, "create embed request")
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, errors.WithMessage(err, "embed request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
if readErr != nil {
return nil, errors.Errorf("embed error %d (failed to read body: %v)", resp.StatusCode, readErr)
}
return nil, errors.Errorf("embed error %d: %s", resp.StatusCode, string(bodyBytes))
}
var wireResp embedResponse
if err := json.NewDecoder(resp.Body).Decode(&wireResp); err != nil {
return nil, errors.WithMessage(err, "decode embed response")
}
return &llm.EmbedResponse{
Model: wireResp.Model,
Embeddings: wireResp.Embeddings,
}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
"io"
"net"
"net/http"
"sort"
"strings"
"time"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
)
const maxErrorBodyBytes = 1024 * 1024
const (
defaultMaxRetries = 3
streamChanBuffer = 16
streamScannerMaxSize = 1024 * 1024
dialTimeout = 30 * time.Second
dialKeepAlive = 30 * time.Second
idleConnTimeout = 90 * time.Second
)
func init() {
llm.RegisterFactory("openai", func(cfg llm.ProviderConfig) (llm.Client, error) {
return New(cfg.BaseURL, cfg.APIKey, cfg.Timeout), nil
})
}
// Client communicates with an OpenAI-compatible API (e.g. vllm).
type Client struct {
baseURL string
apiKey string
httpClient *http.Client
maxRetries int
}
// New creates a new OpenAI-compatible API client.
func New(baseURL, apiKey string, timeout time.Duration) *Client {
transport := &http.Transport{
DialContext: (&net.Dialer{
Timeout: dialTimeout,
KeepAlive: dialKeepAlive,
}).DialContext,
ResponseHeaderTimeout: timeout,
IdleConnTimeout: idleConnTimeout,
}
return &Client{
baseURL: strings.TrimRight(baseURL, "/"),
apiKey: apiKey,
httpClient: &http.Client{
Transport: transport,
},
maxRetries: defaultMaxRetries,
}
}
func (c *Client) setAuth(req *http.Request) {
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
}
func (c *Client) Ping(ctx context.Context) error {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/models", http.NoBody)
if err != nil {
return errors.WithMessage(err, "create request")
}
c.setAuth(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return errors.WithMessagef(err, "openai-compatible server unreachable at %s", c.baseURL)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return errors.Errorf("openai-compatible server returned status %d", resp.StatusCode)
}
return nil
}
func (c *Client) Models(ctx context.Context) ([]llm.Model, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/v1/models", http.NoBody)
if err != nil {
return nil, err
}
c.setAuth(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, errors.WithMessage(err, "list models")
}
defer resp.Body.Close()
var result modelsResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
models := make([]llm.Model, 0, len(result.Data))
for _, m := range result.Data {
models = append(models, llm.Model{
Name: m.ID,
})
}
return models, nil
}
func (c *Client) Chat(ctx context.Context, chatReq *llm.ChatRequest) (<-chan llm.ChatChunk, error) {
wireReq := c.buildChatRequest(chatReq)
body, err := json.Marshal(wireReq)
if err != nil {
return nil, errors.WithMessage(err, "marshal request")
}
var resp *http.Response
for attempt := range c.maxRetries {
req, reqErr := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/chat/completions", bytes.NewReader(body))
if reqErr != nil {
return nil, errors.WithMessage(reqErr, "create request")
}
req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
resp, err = c.httpClient.Do(req)
if err != nil {
if llm.IsDialError(err) {
return nil, errors.WithMessage(err, "chat request")
}
if attempt < c.maxRetries-1 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(1<<attempt) * time.Second):
continue
}
}
return nil, errors.WithMessage(err, "chat request")
}
if resp.StatusCode == http.StatusTooManyRequests || resp.StatusCode == http.StatusServiceUnavailable {
resp.Body.Close()
if attempt < c.maxRetries-1 {
select {
case <-ctx.Done():
return nil, ctx.Err()
case <-time.After(time.Duration(1<<attempt) * time.Second):
continue
}
}
return nil, errors.Errorf("server returned %d after %d retries", resp.StatusCode, c.maxRetries)
}
if resp.StatusCode != http.StatusOK {
bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
resp.Body.Close()
if readErr != nil {
return nil, errors.Errorf("server returned %d and failed to read body: %v", resp.StatusCode, readErr)
}
return nil, errors.Errorf("openai error %d: %s", resp.StatusCode, string(bodyBytes))
}
break
}
ch := make(chan llm.ChatChunk, streamChanBuffer)
go c.streamResponse(ctx, resp, ch)
return ch, nil
}
func (c *Client) buildChatRequest(chatReq *llm.ChatRequest) chatRequest {
msgs := make([]chatMsg, 0, len(chatReq.Messages))
for _, m := range chatReq.Messages {
msg := chatMsg{
Role: m.Role,
Content: m.Content,
ToolCallID: m.ToolCallID,
}
for _, tc := range m.ToolCalls {
argsJSON, err := json.Marshal(tc.Function.Arguments)
if err != nil {
argsJSON = []byte("{}")
}
msg.ToolCalls = append(msg.ToolCalls, toolCall{
ID: tc.ID,
Type: "function",
Function: toolCallFunc{
Name: tc.Function.Name,
Arguments: string(argsJSON),
},
})
}
msgs = append(msgs, msg)
}
tools := make([]toolDef, 0, len(chatReq.Tools))
for i := range chatReq.Tools {
td := &chatReq.Tools[i]
tools = append(tools, toolDef{
Type: td.Type,
Function: toolFuncDef{
Name: td.Function.Name,
Description: td.Function.Description,
Parameters: convertSchema(&td.Function.Parameters),
},
})
}
req := chatRequest{
Model: chatReq.Model,
Messages: msgs,
Tools: tools,
Stream: true,
StreamOptions: &streamOpts{
IncludeUsage: true,
},
}
if chatReq.Options != nil {
if temp, ok := chatReq.Options["temperature"]; ok {
if v, ok := temp.(float64); ok {
req.Temperature = &v
}
}
if maxTok, ok := chatReq.Options["num_ctx"]; ok {
if v, ok := maxTok.(int); ok {
req.MaxTokens = v
}
}
}
if chatReq.Format == "json" {
req.ResponseFormat = &respFormat{Type: "json_object"}
}
return req
}
func convertSchema(s *llm.JSONSchema) jsonSchema {
result := jsonSchema{
Type: s.Type,
Required: s.Required,
Description: s.Description,
Enum: s.Enum,
}
if s.Properties != nil {
result.Properties = make(map[string]jsonSchema, len(s.Properties))
for k, v := range s.Properties {
result.Properties[k] = convertSchema(&v)
}
}
if s.Items != nil {
converted := convertSchema(s.Items)
result.Items = &converted
}
return result
}
func (c *Client) streamResponse(ctx context.Context, resp *http.Response, ch chan<- llm.ChatChunk) {
defer close(ch)
defer resp.Body.Close()
scanner := bufio.NewScanner(resp.Body)
const streamScannerInitSize = 64 * 1024
scanner.Buffer(make([]byte, 0, streamScannerInitSize), streamScannerMaxSize)
toolCallAccumulators := map[int]*toolCallAccumulator{}
var usage *usageInfo
for scanner.Scan() {
line := scanner.Text()
if line == "" {
continue
}
if !strings.HasPrefix(line, "data: ") {
continue
}
data := strings.TrimPrefix(line, "data: ")
if data == "[DONE]" {
// Emit final chunk with accumulated tool calls and usage.
chunk := llm.ChatChunk{Done: true}
if len(toolCallAccumulators) > 0 {
chunk.ToolCalls = buildToolCalls(toolCallAccumulators)
}
if usage != nil {
chunk.PromptEvalCount = usage.PromptTokens
chunk.EvalCount = usage.CompletionTokens
}
select {
case ch <- chunk:
case <-ctx.Done():
}
return
}
var sseChunk chatStreamChunk
if err := json.Unmarshal([]byte(data), &sseChunk); err != nil {
select {
case ch <- llm.ChatChunk{Error: errors.WithMessage(err, "decode chunk")}:
case <-ctx.Done():
}
return
}
if sseChunk.Usage != nil {
usage = sseChunk.Usage
}
if len(sseChunk.Choices) == 0 {
continue
}
delta := sseChunk.Choices[0].Delta
// Accumulate tool call deltas.
for _, tc := range delta.ToolCalls {
idx := tc.Index_
acc, ok := toolCallAccumulators[idx]
if !ok {
acc = &toolCallAccumulator{}
toolCallAccumulators[idx] = acc
}
if tc.ID != "" {
acc.id = tc.ID
}
if tc.Function.Name != "" {
acc.name = tc.Function.Name
}
if tc.Function.Arguments != "" {
acc.argsBytes = append(acc.argsBytes, tc.Function.Arguments...)
}
}
if delta.Content != "" {
select {
case ch <- llm.ChatChunk{Content: delta.Content}:
case <-ctx.Done():
return
}
}
}
if err := scanner.Err(); err != nil {
select {
case ch <- llm.ChatChunk{Error: errors.WithMessage(err, "read stream")}:
case <-ctx.Done():
}
}
}
func buildToolCalls(accs map[int]*toolCallAccumulator) []llm.ToolCall {
indices := make([]int, 0, len(accs))
for idx := range accs {
indices = append(indices, idx)
}
sort.Ints(indices)
calls := make([]llm.ToolCall, 0, len(indices))
for _, idx := range indices {
acc := accs[idx]
var args map[string]any
if len(acc.argsBytes) > 0 {
if err := json.Unmarshal(acc.argsBytes, &args); err != nil {
args = map[string]any{"_raw": string(acc.argsBytes)}
}
}
calls = append(calls, llm.ToolCall{
ID: acc.id,
Function: llm.ToolCallFunc{
Name: acc.name,
Arguments: args,
},
})
}
return calls
}
func (c *Client) ChatSync(ctx context.Context, chatReq *llm.ChatRequest) (llm.ChatChunk, error) {
return c.ChatSyncWithCallback(ctx, chatReq, nil)
}
func (c *Client) ChatSyncWithCallback(ctx context.Context, chatReq *llm.ChatRequest, cb llm.ChunkCallback) (llm.ChatChunk, error) {
ch, err := c.Chat(ctx, chatReq)
if err != nil {
return llm.ChatChunk{}, err
}
return llm.AggregateChatStream(ctx, ch, cb)
}
func (c *Client) Embed(ctx context.Context, req llm.EmbedRequest) (*llm.EmbedResponse, error) {
wireReq := embedRequest{
Model: req.Model,
Input: req.Input,
}
body, err := json.Marshal(wireReq)
if err != nil {
return nil, errors.WithMessage(err, "marshal embed request")
}
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/v1/embeddings", bytes.NewReader(body))
if err != nil {
return nil, errors.WithMessage(err, "create embed request")
}
httpReq.Header.Set("Content-Type", "application/json")
c.setAuth(httpReq)
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return nil, errors.WithMessage(err, "embed request")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
bodyBytes, readErr := io.ReadAll(io.LimitReader(resp.Body, maxErrorBodyBytes))
if readErr != nil {
return nil, errors.Errorf("embed error %d (failed to read body: %v)", resp.StatusCode, readErr)
}
return nil, errors.Errorf("embed error %d: %s", resp.StatusCode, string(bodyBytes))
}
var wireResp embedResponse
if err := json.NewDecoder(resp.Body).Decode(&wireResp); err != nil {
return nil, errors.WithMessage(err, "decode embed response")
}
embeddings := make([][]float64, len(wireResp.Data))
for i, d := range wireResp.Data {
embeddings[i] = d.Embedding
}
return &llm.EmbedResponse{
Model: wireResp.Model,
Embeddings: embeddings,
}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package llm
import (
"encoding/json"
"fmt"
"regexp"
"strings"
)
// ParseToolCalls attempts to extract tool calls from text content.
// Some models return tool calls as JSON text instead of structured tool_calls.
func ParseToolCalls(content string) ([]ToolCall, bool) {
content = strings.TrimSpace(content)
// Try parsing as a single tool call object
var single struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal([]byte(content), &single); err == nil && single.Name != "" {
return []ToolCall{{
ID: "fallback-0",
Function: ToolCallFunc{
Name: single.Name,
Arguments: single.Arguments,
},
}}, true
}
// Try parsing as an array of tool calls
var arr []struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments"`
}
if err := json.Unmarshal([]byte(content), &arr); err == nil && len(arr) > 0 {
calls := make([]ToolCall, 0, len(arr))
for _, item := range arr {
if item.Name == "" {
continue
}
calls = append(calls, ToolCall{
ID: fmt.Sprintf("fallback-%d", len(calls)),
Function: ToolCallFunc{
Name: item.Name,
Arguments: item.Arguments,
},
})
}
if len(calls) > 0 {
return calls, true
}
}
// Try parsing XML-like format: <function=name><parameter=key>value</parameter></function>
if calls := parseXMLToolCalls(content); len(calls) > 0 {
return calls, true
}
return nil, false
}
var (
reFuncBlock = regexp.MustCompile(`(?s)<function=(\w+)>(.*?)</function>`)
reParam = regexp.MustCompile(`(?s)<parameter=(\w+)>\s*(.*?)\s*</parameter>`)
)
// CleanToolCallText removes tool call markup from text, keeping surrounding prose.
func CleanToolCallText(content string) string {
// Remove XML-style tool calls
cleaned := reFuncBlock.ReplaceAllString(content, "")
// Remove </tool_call> tags
cleaned = strings.ReplaceAll(cleaned, "</tool_call>", "")
cleaned = strings.ReplaceAll(cleaned, "<tool_call>", "")
// Remove JSON tool calls (heuristic: entire content was a tool call)
cleaned = strings.TrimSpace(cleaned)
return cleaned
}
// parseXMLToolCalls handles the XML-like tool call format some models use.
func parseXMLToolCalls(content string) []ToolCall {
funcMatches := reFuncBlock.FindAllStringSubmatch(content, -1)
if len(funcMatches) == 0 {
return nil
}
calls := make([]ToolCall, 0, len(funcMatches))
for _, fm := range funcMatches {
name := fm[1]
body := fm[2]
args := make(map[string]any)
paramMatches := reParam.FindAllStringSubmatch(body, -1)
for _, pm := range paramMatches {
args[pm[1]] = strings.TrimSpace(pm[2])
}
calls = append(calls, ToolCall{
ID: fmt.Sprintf("fallback-%d", len(calls)),
Function: ToolCallFunc{
Name: name,
Arguments: args,
},
})
}
return calls
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package mcp
import (
"context"
"encoding/json"
"sync/atomic"
"github.com/pkg/errors"
)
const protocolVersion = "2025-03-26"
// MCPToolDef represents a tool definition returned by the MCP server.
type MCPToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
InputSchema map[string]any `json:"inputSchema"`
}
// MCPToolResult is the result of calling an MCP tool.
type MCPToolResult struct {
Content []ContentBlock `json:"content"`
IsError bool `json:"isError"`
}
// ContentBlock is a single content item in a tool result.
type ContentBlock struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
}
// ServerInfo holds information about the connected MCP server.
type ServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities"`
}
// Client communicates with an MCP server over a transport.
type Client struct {
transport Transport
nextID atomic.Int64
serverInfo ServerInfo
closed atomic.Bool
}
// NewClient creates a new MCP client.
func NewClient(transport Transport) *Client {
return &Client{transport: transport}
}
// Initialize performs the MCP handshake with the server.
func (c *Client) Initialize(ctx context.Context) error {
if c.closed.Load() {
return errors.New("client is closed")
}
type clientInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
type initParams struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities struct{} `json:"capabilities"`
ClientInfo clientInfo `json:"clientInfo"`
}
resp, err := c.call(ctx, "initialize", initParams{
ProtocolVersion: protocolVersion,
ClientInfo: clientInfo{Name: "kodrun", Version: "0.1.0"},
})
if err != nil {
return errors.WithMessage(err, "initialize")
}
var initResult struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities map[string]any `json:"capabilities"`
ServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
} `json:"serverInfo"`
}
if err := json.Unmarshal(resp.Result, &initResult); err != nil {
return errors.WithMessage(err, "parse init result")
}
c.serverInfo = ServerInfo{
Name: initResult.ServerInfo.Name,
Version: initResult.ServerInfo.Version,
ProtocolVersion: initResult.ProtocolVersion,
Capabilities: initResult.Capabilities,
}
// Send initialized notification (no ID = notification).
return c.transport.Send(Request{
JSONRPC: "2.0",
Method: "notifications/initialized",
})
}
// ServerInfo returns information about the connected server.
func (c *Client) ServerInfo() ServerInfo {
return c.serverInfo
}
// ListTools retrieves the list of available tools from the server.
func (c *Client) ListTools(ctx context.Context) ([]MCPToolDef, error) {
if c.closed.Load() {
return nil, errors.New("client is closed")
}
resp, err := c.call(ctx, "tools/list", nil)
if err != nil {
return nil, errors.WithMessage(err, "tools/list")
}
var result struct {
Tools []MCPToolDef `json:"tools"`
}
if err := json.Unmarshal(resp.Result, &result); err != nil {
return nil, errors.WithMessage(err, "parse tools")
}
return result.Tools, nil
}
// CallTool invokes a tool on the server.
func (c *Client) CallTool(ctx context.Context, name string, arguments map[string]any) (MCPToolResult, error) {
if c.closed.Load() {
return MCPToolResult{}, errors.New("client is closed")
}
type callParams struct {
Name string `json:"name"`
Arguments map[string]any `json:"arguments,omitempty"`
}
resp, err := c.call(ctx, "tools/call", callParams{
Name: name,
Arguments: arguments,
})
if err != nil {
return MCPToolResult{}, errors.WithMessagef(err, "tools/call %s", name)
}
var result MCPToolResult
if err := json.Unmarshal(resp.Result, &result); err != nil {
return MCPToolResult{}, errors.WithMessage(err, "parse call result")
}
return result, nil
}
// Close shuts down the client and transport.
func (c *Client) Close() error {
if c.closed.Swap(true) {
return nil
}
return c.transport.Close()
}
// call sends a JSON-RPC request and waits for the response.
// It skips server notifications (responses without an ID).
func (c *Client) call(ctx context.Context, method string, params any) (Response, error) {
id := c.nextID.Add(1)
req := Request{
JSONRPC: "2.0",
ID: &id,
Method: method,
Params: params,
}
if err := c.transport.Send(req); err != nil {
return Response{}, errors.WithMessage(err, "send")
}
// Read responses, skipping notifications (no ID) until we get ours.
for {
resp, err := c.transport.Receive(ctx)
if err != nil {
return Response{}, errors.WithMessage(err, "receive")
}
// Skip notifications (messages without ID).
if resp.ID == nil {
continue
}
if *resp.ID != id {
continue
}
if resp.Error != nil {
return Response{}, resp.Error
}
return resp, nil
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package mcp
import (
"bufio"
"encoding/json"
"fmt"
"io"
"github.com/pkg/errors"
)
// Request is a JSON-RPC 2.0 request or notification.
type Request struct {
JSONRPC string `json:"jsonrpc"`
ID *int64 `json:"id,omitempty"`
Method string `json:"method"`
Params any `json:"params,omitempty"`
}
// Response is a JSON-RPC 2.0 response.
type Response struct {
JSONRPC string `json:"jsonrpc"`
ID *int64 `json:"id,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error *RPCError `json:"error,omitempty"`
}
// RPCError is a JSON-RPC 2.0 error object.
type RPCError struct {
Code int `json:"code"`
Message string `json:"message"`
Data any `json:"data,omitempty"`
}
func (e *RPCError) Error() string {
return fmt.Sprintf("rpc error %d: %s", e.Code, e.Message)
}
// encode writes a JSON-RPC message followed by a newline.
func encode(w io.Writer, msg any) error {
data, err := json.Marshal(msg)
if err != nil {
return errors.WithMessage(err, "marshal")
}
data = append(data, '\n')
_, err = w.Write(data)
return err
}
// decode reads a single newline-delimited JSON message.
func decode(r *bufio.Reader) (Response, error) {
line, err := r.ReadBytes('\n')
if err != nil {
return Response{}, errors.WithMessage(err, "read")
}
var resp Response
if err := json.Unmarshal(line, &resp); err != nil {
return Response{}, errors.WithMessage(err, "unmarshal")
}
return resp, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package mcp
import (
"context"
"fmt"
"strings"
"sync"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/tools"
"slices"
)
// ServerConfig holds configuration for a single MCP server.
type ServerConfig struct {
Command string `mapstructure:"command"`
Args []string `mapstructure:"args"`
Env map[string]string `mapstructure:"env"`
AutoApprove bool `mapstructure:"auto_approve"`
AutoApproveTools []string `mapstructure:"auto_approve_tools"`
ReadOnlyTools []string `mapstructure:"read_only_tools"`
Disabled bool `mapstructure:"disabled"`
}
// Manager orchestrates the lifecycle of multiple MCP servers.
type Manager struct {
clients map[string]*Client
adapters []*ToolAdapter
configs map[string]ServerConfig
errors []string
closeOnce sync.Once
closeErr error
}
// NewManager creates a new MCP manager.
func NewManager() *Manager {
return &Manager{
clients: make(map[string]*Client),
configs: make(map[string]ServerConfig),
}
}
// Start connects to all enabled MCP servers, initializes them, and discovers tools.
func (m *Manager) Start(ctx context.Context, servers map[string]ServerConfig, workDir string) error {
var errs []string
for name, cfg := range servers {
if cfg.Disabled {
continue
}
transport, err := NewStdioTransport(ctx, cfg.Command, cfg.Args, cfg.Env, workDir)
if err != nil {
errs = append(errs, fmt.Sprintf("%s: %s", name, err))
continue
}
client := NewClient(transport)
if err := client.Initialize(ctx); err != nil {
_ = client.Close()
errs = append(errs, fmt.Sprintf("%s: init: %s", name, err))
continue
}
toolDefs, err := client.ListTools(ctx)
if err != nil {
_ = client.Close()
errs = append(errs, fmt.Sprintf("%s: list tools: %s", name, err))
continue
}
m.clients[name] = client
m.configs[name] = cfg
for _, def := range toolDefs {
adapter := NewToolAdapter(client, name, def)
m.adapters = append(m.adapters, adapter)
}
}
m.errors = errs
if len(errs) > 0 && len(m.clients) == 0 {
// All servers failed — return error.
return errors.Errorf("all mcp servers failed: %s", strings.Join(errs, "; "))
}
// Partial failures are non-fatal; retrieve them via Errors().
return nil
}
// RegisterTools adds all discovered MCP tool adapters to the registry.
func (m *Manager) RegisterTools(reg *tools.Registry) {
for _, adapter := range m.adapters {
reg.Register(adapter)
}
}
// ConfirmTools returns the set of MCP tool names that require user confirmation.
func (m *Manager) ConfirmTools() map[string]bool {
result := make(map[string]bool)
for _, adapter := range m.adapters {
cfg := m.configs[adapter.serverName]
if cfg.AutoApprove {
continue
}
if slices.Contains(cfg.AutoApproveTools, adapter.mcpName) {
continue
}
result[adapter.registryName] = true
}
return result
}
// ReadOnlyTools returns the set of MCP tool names that are safe for parallel execution.
func (m *Manager) ReadOnlyTools() map[string]bool {
result := make(map[string]bool)
for _, adapter := range m.adapters {
cfg := m.configs[adapter.serverName]
if slices.Contains(cfg.ReadOnlyTools, adapter.mcpName) {
result[adapter.registryName] = true
}
}
return result
}
// ToolCount returns the number of discovered MCP tools.
func (m *Manager) ToolCount() int {
return len(m.adapters)
}
// Errors returns any non-fatal errors from server startup.
func (m *Manager) Errors() []string {
return m.errors
}
// Close gracefully shuts down all MCP server connections.
func (m *Manager) Close() error {
m.closeOnce.Do(func() {
var errs []string
for name, client := range m.clients {
if err := client.Close(); err != nil {
errs = append(errs, fmt.Sprintf("%s: %s", name, err))
}
}
if len(errs) > 0 {
m.closeErr = errors.Errorf("close errors: %s", strings.Join(errs, "; "))
}
})
return m.closeErr
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package mcp
import (
"context"
"fmt"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/tools"
)
// ToolAdapter wraps an MCP tool as a tools.Tool for the agent registry.
type ToolAdapter struct {
client *Client
serverName string
mcpName string // original tool name on the MCP server
registryName string // "mcp_<server>_<tool>"
description string
schema llm.JSONSchema
}
// NewToolAdapter creates a tool adapter that proxies calls to an MCP server.
func NewToolAdapter(client *Client, serverName string, def MCPToolDef) *ToolAdapter {
return &ToolAdapter{
client: client,
serverName: serverName,
mcpName: def.Name,
registryName: fmt.Sprintf("mcp_%s_%s", serverName, def.Name),
description: fmt.Sprintf("[MCP:%s] %s", serverName, def.Description),
schema: convertSchema(def.InputSchema),
}
}
func (a *ToolAdapter) Name() string { return a.registryName }
func (a *ToolAdapter) Description() string { return a.description }
func (a *ToolAdapter) Schema() llm.JSONSchema { return a.schema }
// Execute calls the MCP tool and converts the result to ToolResult.
func (a *ToolAdapter) Execute(ctx context.Context, params map[string]any) (*tools.ToolResult, error) {
result, err := a.client.CallTool(ctx, a.mcpName, params)
if err != nil {
return nil, fmt.Errorf("mcp call %s: %w", a.mcpName, err)
}
output := formatContent(result.Content)
if result.IsError {
return nil, &tools.ToolError{Msg: output}
}
return &tools.ToolResult{
Output: output,
}, nil
}
// formatContent concatenates text content blocks into a single string.
func formatContent(blocks []ContentBlock) string {
var sb strings.Builder
for i, b := range blocks {
if b.Type == "text" {
if i > 0 {
sb.WriteString("\n")
}
sb.WriteString(b.Text)
}
}
return sb.String()
}
// convertSchema converts an MCP JSON Schema (map[string]any) to llm.JSONSchema.
func convertSchema(raw map[string]any) llm.JSONSchema {
if raw == nil {
return llm.JSONSchema{Type: "object"}
}
s := llm.JSONSchema{}
if t, ok := raw["type"].(string); ok {
s.Type = t
}
if d, ok := raw["description"].(string); ok {
s.Description = d
}
if props, ok := raw["properties"].(map[string]any); ok {
s.Properties = make(map[string]llm.JSONSchema, len(props))
for key, val := range props {
if propMap, ok := val.(map[string]any); ok {
s.Properties[key] = convertSchema(propMap)
}
}
}
if req, ok := raw["required"].([]any); ok {
for _, v := range req {
if str, ok := v.(string); ok {
s.Required = append(s.Required, str)
}
}
}
if enum, ok := raw["enum"].([]any); ok {
for _, v := range enum {
if str, ok := v.(string); ok {
s.Enum = append(s.Enum, str)
}
}
}
if items, ok := raw["items"].(map[string]any); ok {
converted := convertSchema(items)
s.Items = &converted
}
return s
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package mcp
import (
"bufio"
"context"
"io"
"log/slog"
"os"
"os/exec"
"regexp"
"strings"
"sync"
"time"
"github.com/pkg/errors"
)
const (
stderrBufferSize = 4096
receiveTimeout = 60 * time.Second
shutdownTimeout = 3 * time.Second
killTimeout = 2 * time.Second
)
var validEnvKey = regexp.MustCompile(`^[a-zA-Z_][a-zA-Z0-9_]*$`)
// Transport is the communication layer for MCP protocol.
type Transport interface {
Send(req Request) error
Receive(ctx context.Context) (Response, error)
Close() error
}
// StdioTransport communicates with an MCP server via stdin/stdout of a subprocess.
type StdioTransport struct {
cmd *exec.Cmd
stdin io.WriteCloser
stdout *bufio.Reader
stdoutCloser io.Closer // underlying stdout pipe, closed to unblock decode goroutines
closeStdout sync.Once // prevents double-close of stdoutCloser
stderr *ringBuffer
mu sync.Mutex // protects stdin writes
closed chan struct{}
}
// NewStdioTransport starts a subprocess and wires stdin/stdout for JSON-RPC communication.
func NewStdioTransport(ctx context.Context, command string, args []string, env map[string]string, workDir string) (*StdioTransport, error) {
cmd := exec.CommandContext(ctx, command, args...)
cmd.Dir = workDir
// Merge env vars with key validation.
cmd.Env = os.Environ()
for k, v := range env {
upper := strings.ToUpper(k)
if !validEnvKey.MatchString(k) || strings.HasPrefix(upper, "LD_") || strings.HasPrefix(upper, "DYLD_") {
return nil, errors.Errorf("invalid env key %q", k)
}
cmd.Env = append(cmd.Env, k+"="+v)
}
stdin, err := cmd.StdinPipe()
if err != nil {
return nil, errors.WithMessage(err, "stdin pipe")
}
stdout, err := cmd.StdoutPipe()
if err != nil {
return nil, errors.WithMessage(err, "stdout pipe")
}
stderrBuf := newRingBuffer(stderrBufferSize)
cmd.Stderr = stderrBuf
if err := cmd.Start(); err != nil {
return nil, errors.WithMessagef(err, "start %s", command)
}
return &StdioTransport{
cmd: cmd,
stdin: stdin,
stdout: bufio.NewReader(stdout),
stdoutCloser: stdout,
stderr: stderrBuf,
closed: make(chan struct{}),
}, nil
}
// Send writes a JSON-RPC request to the subprocess stdin.
func (t *StdioTransport) Send(req Request) error {
t.mu.Lock()
defer t.mu.Unlock()
return encode(t.stdin, req)
}
// Receive reads a JSON-RPC response from the subprocess stdout.
// Blocks until a response is available, the context is cancelled, or the read timeout (60s) expires.
// On timeout or context cancellation, the stdout pipe is closed to unblock the goroutine.
func (t *StdioTransport) Receive(ctx context.Context) (Response, error) {
type result struct {
resp Response
err error
}
ch := make(chan result, 1)
go func() {
resp, err := decode(t.stdout)
ch <- result{resp, err}
}()
select {
case r := <-ch:
return r.resp, r.err
case <-t.closed:
return Response{}, errors.New("transport closed")
case <-ctx.Done():
// Close stdout to unblock the decode goroutine.
t.closeStdout.Do(func() { _ = t.stdoutCloser.Close() })
return Response{}, ctx.Err()
case <-time.After(receiveTimeout):
// Close stdout to unblock the decode goroutine.
t.closeStdout.Do(func() { _ = t.stdoutCloser.Close() })
return Response{}, errors.Errorf("receive timeout (%s)", receiveTimeout)
}
}
// Close terminates the subprocess gracefully.
func (t *StdioTransport) Close() error {
// Signal all Receive() callers that we're closing.
select {
case <-t.closed:
default:
close(t.closed)
}
_ = t.stdin.Close()
// Close stdout pipe to unblock any goroutines blocked in decode().
t.closeStdout.Do(func() { _ = t.stdoutCloser.Close() })
if t.cmd.Process != nil {
// Try SIGINT first, then force kill.
if sigErr := t.cmd.Process.Signal(os.Interrupt); sigErr != nil {
slog.Debug("mcp: failed to send interrupt", "error", sigErr)
}
done := make(chan error, 1)
go func() { done <- t.cmd.Wait() }()
select {
case <-done:
case <-time.After(shutdownTimeout):
if killErr := t.cmd.Process.Kill(); killErr != nil {
slog.Debug("mcp: failed to kill process", "error", killErr)
}
select {
case <-done:
case <-time.After(killTimeout):
// Process is a zombie, give up waiting.
}
}
}
return nil
}
// Stderr returns captured stderr output from the subprocess.
func (t *StdioTransport) Stderr() string {
return t.stderr.String()
}
// ringBuffer is a fixed-size circular buffer implementing io.Writer.
type ringBuffer struct {
buf []byte
pos int
full bool
mu sync.Mutex
}
func newRingBuffer(size int) *ringBuffer {
return &ringBuffer{buf: make([]byte, size)}
}
func (r *ringBuffer) Write(p []byte) (int, error) {
r.mu.Lock()
defer r.mu.Unlock()
n := len(p)
size := len(r.buf)
if n == 0 {
return 0, nil
}
if n >= size {
copy(r.buf, p[n-size:])
r.pos = 0
r.full = true
return n, nil
}
space := size - r.pos
if n <= space {
copy(r.buf[r.pos:], p)
} else {
copy(r.buf[r.pos:], p[:space])
copy(r.buf, p[space:])
}
newPos := (r.pos + n) % size
if !r.full && newPos <= r.pos {
r.full = true
}
r.pos = newPos
return n, nil
}
func (r *ringBuffer) String() string {
r.mu.Lock()
defer r.mu.Unlock()
if !r.full {
return string(r.buf[:r.pos])
}
return string(r.buf[r.pos:]) + string(r.buf[:r.pos])
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
// Package projectlang detects the primary programming language of a project
// by looking for well-known marker files in the working directory.
package projectlang
import (
"os"
"path/filepath"
)
// Language identifies a programming language ecosystem.
type Language string
const (
// LangUnknown means no marker file was found yet.
LangUnknown Language = ""
// LangGo means the project is a Go module.
LangGo Language = "go"
// LangPython means the project is a Python project.
LangPython Language = "python"
// LangJSTS means the project is a JavaScript/TypeScript project.
LangJSTS Language = "jsts"
)
// String returns the string form of the language identifier.
func (l Language) String() string { return string(l) }
// Detector inspects a working directory and returns the primary language.
type Detector struct {
workDir string
}
// New creates a Detector for the given working directory.
func New(workDir string) *Detector {
return &Detector{workDir: workDir}
}
// markerRule maps a single marker file to its language.
type markerRule struct {
file string
lang Language
}
// orderedMarkers lists marker files in priority order.
// First match wins, so Go beats Python beats JSTS in mixed projects.
var orderedMarkers = []markerRule{
{file: "go.mod", lang: LangGo},
{file: "pyproject.toml", lang: LangPython},
{file: "requirements.txt", lang: LangPython},
{file: "setup.py", lang: LangPython},
{file: "Pipfile", lang: LangPython},
{file: "package.json", lang: LangJSTS},
}
// Detect returns the detected language or LangUnknown when no marker file is present.
// It only checks the immediate working directory; nested manifests are ignored.
func (d *Detector) Detect() Language {
if d == nil || d.workDir == "" {
return LangUnknown
}
for _, m := range orderedMarkers {
if fileExists(filepath.Join(d.workDir, m.file)) {
return m.lang
}
}
return LangUnknown
}
func fileExists(path string) bool {
info, err := os.Stat(path)
if err != nil {
return false
}
return !info.IsDir()
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package projectlang
import "sync"
// State holds the currently detected language with lazy re-detection support.
// It is safe for concurrent use.
type State struct {
mu sync.RWMutex
detector *Detector
techDetector *TechDetector
override Language
current Language
techOnce sync.Once
techStack TechStack
}
// NewState creates a new State backed by the given Detector.
// If override is not LangUnknown, it short-circuits detection and the
// override is always returned.
func NewState(detector *Detector, override Language) *State {
return &State{detector: detector, override: override, current: override}
}
// SetTechDetector sets the technology stack detector. Must be called before
// EnsureTechDetected.
func (s *State) SetTechDetector(td *TechDetector) {
s.mu.Lock()
defer s.mu.Unlock()
s.techDetector = td
}
// EnsureTechDetected lazily detects and returns the project's technology stack.
func (s *State) EnsureTechDetected() TechStack {
s.techOnce.Do(func() {
s.mu.RLock()
td := s.techDetector
s.mu.RUnlock()
if td == nil {
return
}
lang, _ := s.EnsureDetected()
s.techStack = td.Detect(lang)
})
return s.techStack
}
// Current returns the last known language without running detection.
func (s *State) Current() Language {
s.mu.RLock()
defer s.mu.RUnlock()
return s.current
}
// EnsureDetected returns the current language and re-runs detection
// if it is still LangUnknown. The changed flag is true when this call
// transitioned the language from LangUnknown to a concrete value.
func (s *State) EnsureDetected() (lang Language, changed bool) {
s.mu.RLock()
if s.current != LangUnknown || s.override != LangUnknown {
c := s.current
s.mu.RUnlock()
return c, false
}
s.mu.RUnlock()
s.mu.Lock()
defer s.mu.Unlock()
// Re-check under write lock.
if s.current != LangUnknown {
return s.current, false
}
if s.detector == nil {
return LangUnknown, false
}
detected := s.detector.Detect()
if detected == LangUnknown {
return LangUnknown, false
}
s.current = detected
return detected, true
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package projectlang
import (
"bufio"
"os"
"path/filepath"
"slices"
"strings"
)
// Technology identifies a technology or framework used by a project.
type Technology string
const (
TechGRPC Technology = "grpc"
TechHTTP Technology = "http"
TechPostgres Technology = "postgres"
TechClickhouse Technology = "clickhouse"
TechMongo Technology = "mongodb"
TechRedis Technology = "redis"
TechKafka Technology = "kafka"
)
// AllTechnologies is the full list of detectable technologies.
var AllTechnologies = []Technology{
TechGRPC, TechHTTP, TechPostgres, TechClickhouse,
TechMongo, TechRedis, TechKafka,
}
// TechStack is a set of technologies detected in a project.
type TechStack []Technology
// Has reports whether the stack contains the given technology.
func (ts TechStack) Has(t Technology) bool {
return slices.Contains(ts, t)
}
// HasAll reports whether the stack contains all given technology identifiers.
func (ts TechStack) HasAll(required []string) bool {
for _, r := range required {
if !ts.Has(Technology(r)) {
return false
}
}
return true
}
// Strings returns the stack as a slice of strings.
func (ts TechStack) Strings() []string {
out := make([]string, len(ts))
for i, t := range ts {
out[i] = string(t)
}
return out
}
// String returns a comma-separated list of technologies.
func (ts TechStack) String() string {
return strings.Join(ts.Strings(), ", ")
}
// goModRule maps a module prefix in go.mod to a Technology.
type goModRule struct {
prefix string
tech Technology
}
// goModRules lists known Go module prefixes and the technologies they indicate.
var goModRules = []goModRule{
{"google.golang.org/grpc", TechGRPC},
{"github.com/jackc/pgx", TechPostgres},
{"github.com/lib/pq", TechPostgres},
{"github.com/jmoiron/sqlx", TechPostgres},
{"github.com/ClickHouse/clickhouse-go", TechClickhouse},
{"go.mongodb.org/mongo-driver", TechMongo},
{"github.com/redis/go-redis", TechRedis},
{"github.com/gomodule/redigo", TechRedis},
{"github.com/segmentio/kafka-go", TechKafka},
{"github.com/IBM/sarama", TechKafka},
{"github.com/go-chi/chi", TechHTTP},
{"github.com/labstack/echo", TechHTTP},
{"github.com/gin-gonic/gin", TechHTTP},
{"github.com/gofiber/fiber", TechHTTP},
}
// protoDirs are directories checked for .proto files as a gRPC signal.
var protoDirs = []string{".", "proto", "api"}
// TechDetector inspects a working directory and detects the technology stack.
type TechDetector struct {
workDir string
}
// NewTechDetector creates a TechDetector for the given working directory.
func NewTechDetector(workDir string) *TechDetector {
return &TechDetector{workDir: workDir}
}
// Detect returns the technologies used by the project.
// For Go projects it inspects go.mod and checks for .proto files.
func (d *TechDetector) Detect(lang Language) TechStack {
if d == nil || d.workDir == "" {
return nil
}
switch lang {
case LangGo:
return d.detectGo()
default:
return nil
}
}
func (d *TechDetector) detectGo() TechStack {
seen := make(map[Technology]bool)
d.scanGoMod(seen)
d.scanProtoFiles(seen)
stack := make(TechStack, 0, len(seen))
for _, t := range AllTechnologies {
if seen[t] {
stack = append(stack, t)
}
}
return stack
}
func (d *TechDetector) scanGoMod(seen map[Technology]bool) {
f, err := os.Open(filepath.Join(d.workDir, "go.mod"))
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
for _, rule := range goModRules {
if strings.HasPrefix(line, rule.prefix) || strings.Contains(line, rule.prefix) {
seen[rule.tech] = true
}
}
}
}
func (d *TechDetector) scanProtoFiles(seen map[Technology]bool) {
if seen[TechGRPC] {
return
}
for _, dir := range protoDirs {
pattern := filepath.Join(d.workDir, dir, "*.proto")
matches, err := filepath.Glob(pattern)
if err == nil && len(matches) > 0 {
seen[TechGRPC] = true
return
}
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rag
import (
"context"
"time"
)
// Backend is the interface that RAG storage backends must implement.
// Both the local in-memory index (MultiIndex) and remote backends
// (e.g. Muninn) satisfy it.
type Backend interface {
// LoadCommon loads the common sub-index from persistent storage.
LoadCommon() error
// LoadGodoc loads the Go documentation sub-index.
LoadGodoc() error
// HasLegacyCodeChunks reports whether the index contains stale
// source-code entries from older kodrun versions.
HasLegacyCodeChunks() bool
// Reset wipes all common index data (in-memory and persistent).
Reset() error
// BuildCommon indexes chunks into the common sub-index.
BuildCommon(ctx context.Context, chunks []Chunk) (int, error)
// BuildCommonWithProgress is BuildCommon with a progress callback.
BuildCommonWithProgress(ctx context.Context, chunks []Chunk, progress ProgressFunc) (int, error)
// Build is a convenience alias for BuildCommon.
Build(ctx context.Context, chunks []Chunk) (int, error)
// Save persists the common sub-index.
Save() error
// SaveCommon writes the common sub-index to persistent storage.
SaveCommon() error
// Search returns top-k matching chunks from the common sub-index.
Search(ctx context.Context, query string, topK int) ([]SearchResult, error)
// Size returns the number of entries in the common sub-index.
Size() int
// Updated returns the last update timestamp.
Updated() time.Time
// BasePath returns the root storage path (local path or remote URL).
BasePath() string
// Godoc sub-index methods.
BuildGodoc(ctx context.Context, chunks []Chunk) (int, error)
SaveGodoc() error
SearchGodoc(ctx context.Context, query string, topK int) ([]SearchResult, error)
ResetGodoc() error
GodocSize() int
// Web sub-index methods (session-scoped, in-memory).
BuildWeb(ctx context.Context, chunks []Chunk) (int, error)
SearchWeb(ctx context.Context, query string, topK int) ([]SearchResult, error)
WebSize() int
}
// BackendGodocAdapter wraps a Backend into an adapter that satisfies
// tools.GoDocIndexer by delegating to the godoc sub-index methods.
type BackendGodocAdapter struct {
B Backend
}
func (a *BackendGodocAdapter) Build(ctx context.Context, chunks []Chunk) (int, error) {
return a.B.BuildGodoc(ctx, chunks)
}
func (a *BackendGodocAdapter) Save() error {
return a.B.SaveGodoc()
}
func (a *BackendGodocAdapter) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) {
return a.B.SearchGodoc(ctx, query, topK)
}
// BackendWebAdapter wraps a Backend into an adapter that satisfies
// tools.WebIndexer by delegating to the web sub-index methods.
type BackendWebAdapter struct {
B Backend
}
func (a *BackendWebAdapter) Build(ctx context.Context, chunks []Chunk) (int, error) {
return a.B.BuildWeb(ctx, chunks)
}
func (a *BackendWebAdapter) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) {
return a.B.SearchWeb(ctx, query, topK)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rag
import (
"context"
"io/fs"
"os"
"path/filepath"
"strings"
)
// MaxChunkBytes is the maximum size in bytes for a single chunk.
// ~500-650 tokens at ~3-4 chars/token, safely under nomic-embed-text's 2048 token context.
const MaxChunkBytes = 2000
// Chunk represents a text chunk with metadata.
type Chunk struct {
FilePath string `json:"file_path"`
Content string `json:"content"`
StartLine int `json:"start_line"`
EndLine int `json:"end_line"`
}
// ChunkFile reads a single file and splits it into chunks.
func ChunkFile(filePath string, chunkSize, chunkOverlap int) ([]Chunk, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
content := string(data)
if len(content) == 0 {
// Empty file produces no chunks; nil slice is valid for []Chunk.
return nil, nil
}
return splitIntoChunks(filePath, content, chunkSize, chunkOverlap), nil
}
// ChunkOptions carries optional chunking controls. A zero value is safe:
// empty ExcludeDirs means "skip only the built-in noise directories"
// (.git, vendor, node_modules, any dot-prefixed dir), and MaxChunksPerFile=0
// disables the per-file cap.
type ChunkOptions struct {
// ExcludeDirs are directory names or workDir-relative paths to skip
// in addition to the built-in defaults. An entry like ".claude" skips
// every directory with that base name; "tmp/fixtures" skips that
// specific relative path under workDir.
ExcludeDirs []string
// MaxChunksPerFile caps how many chunks a single file can contribute.
// 0 means unlimited.
MaxChunksPerFile int
}
// ChunkFiles walks the given directories and splits files into chunks.
// It respects forbidden patterns and only processes text-like files.
func ChunkFiles(ctx context.Context, workDir string, dirs []string, chunkSize, chunkOverlap int) ([]Chunk, error) {
return ChunkFilesOpts(ctx, workDir, dirs, chunkSize, chunkOverlap, ChunkOptions{})
}
// ChunkFilesOpts is ChunkFiles with explicit ChunkOptions.
func ChunkFilesOpts(ctx context.Context, workDir string, dirs []string, chunkSize, chunkOverlap int, opts ChunkOptions) ([]Chunk, error) {
var chunks []Chunk
// Pre-compute exclude sets for O(1) lookup.
excludeBase := map[string]bool{}
var excludeRel []string
for _, e := range opts.ExcludeDirs {
e = strings.TrimSpace(e)
if e == "" {
continue
}
if strings.ContainsRune(e, filepath.Separator) {
excludeRel = append(excludeRel, filepath.Clean(e))
} else {
excludeBase[e] = true
}
}
seen := make(map[string]bool)
for _, dir := range dirs {
absDir := dir
if !filepath.IsAbs(dir) {
absDir = filepath.Join(workDir, dir)
}
err := filepath.WalkDir(absDir, func(path string, d fs.DirEntry, walkErr error) error {
if ctx.Err() != nil {
return ctx.Err()
}
if walkErr != nil {
return filepath.SkipDir
}
if d.IsDir() {
base := filepath.Base(path)
if strings.HasPrefix(base, ".") || base == "vendor" || base == "node_modules" {
return filepath.SkipDir
}
if excludeBase[base] {
return filepath.SkipDir
}
if len(excludeRel) > 0 {
rel, relErr := filepath.Rel(workDir, path)
if relErr != nil {
return filepath.SkipDir
}
rel = filepath.Clean(rel)
for _, ex := range excludeRel {
if rel == ex || strings.HasPrefix(rel, ex+string(filepath.Separator)) {
return filepath.SkipDir
}
}
}
return nil
}
if !isIndexableFile(path) {
return nil
}
relPath, relErr := filepath.Rel(workDir, path)
if relErr != nil || relPath == "" {
relPath = path
}
if seen[relPath] {
return nil
}
seen[relPath] = true
data, readErr := os.ReadFile(path)
if readErr == nil && len(data) > 0 {
content := string(data)
fileChunks := splitIntoChunks(relPath, content, chunkSize, chunkOverlap)
if opts.MaxChunksPerFile > 0 && len(fileChunks) > opts.MaxChunksPerFile {
fileChunks = fileChunks[:opts.MaxChunksPerFile]
}
chunks = append(chunks, fileChunks...)
}
return nil
})
if err != nil {
return chunks, err
}
}
return chunks, nil
}
// ChunkText splits arbitrary text into chunks. The source parameter is used
// as FilePath in the resulting chunks (e.g. "web://example.com/page").
func ChunkText(source, content string, chunkSize, chunkOverlap int) []Chunk {
return splitIntoChunks(source, content, chunkSize, chunkOverlap)
}
func splitIntoChunks(filePath, content string, chunkSize, overlap int) []Chunk {
lines := strings.Split(content, "\n")
if len(lines) == 0 {
return nil
}
var chunks []Chunk
i := 0
for i < len(lines) {
end := i
byteCount := 0
for end < len(lines) && (end-i) < chunkSize {
lineLen := len(lines[end]) + 1 // +1 for newline
if byteCount+lineLen > MaxChunkBytes && end > i {
break
}
byteCount += lineLen
end++
}
chunkContent := strings.Join(lines[i:end], "\n")
if strings.TrimSpace(chunkContent) != "" {
chunks = append(chunks, Chunk{
FilePath: filePath,
Content: chunkContent,
StartLine: i + 1,
EndLine: end,
})
}
if end >= len(lines) {
break
}
next := end - overlap
if next <= i {
next = end // ensure forward progress
}
i = next
}
return chunks
}
var indexableExts = map[string]bool{
".go": true, ".md": true, ".txt": true, ".yaml": true, ".yml": true,
".json": true, ".toml": true, ".sh": true, ".bash": true,
".py": true, ".js": true, ".ts": true, ".rs": true, ".c": true,
".h": true, ".sql": true, ".proto": true, ".graphql": true,
".html": true, ".css": true, ".xml": true, ".mod": true, ".sum": true,
}
func isIndexableFile(path string) bool {
base := strings.ToLower(filepath.Base(path))
if strings.HasPrefix(base, ".") {
return false
}
ext := strings.ToLower(filepath.Ext(path))
if indexableExts[ext] {
return true
}
return base == "makefile" || base == "dockerfile" || base == "readme"
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rag
import (
"path/filepath"
"strings"
)
// CompressChunk applies conservative, idempotent heuristics to strip
// low-information tokens from a chunk before it is injected into a prompt.
// The goal is LongLLMLingua-style budget savings without a dedicated ML
// model: we only drop things whose absence does not change the semantics
// a reviewer cares about (blank lines, pure-comment lines in Go, import
// blocks, trailing whitespace, HTML comments in markdown, etc.).
//
// CompressChunk is file-type aware via the filePath suffix. Unknown types
// fall through to a generic whitespace pass. The function never touches
// content inside fenced code blocks in markdown to preserve example code.
func CompressChunk(filePath, content string) string {
if content == "" {
return content
}
ext := strings.ToLower(filepath.Ext(filePath))
switch ext {
case ".go":
return compressGo(content)
case ".md", ".markdown":
return compressMarkdown(content)
default:
return compressGeneric(content)
}
}
// compressGo drops blank lines, single-line comments, import blocks and
// trailing whitespace. It intentionally keeps doc comments attached to
// exported declarations (heuristic: comment immediately followed by a
// func/type/var/const whose first identifier rune is uppercase).
func compressGo(content string) string {
lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
inImport := false
inBlockComment := false
isDecl := func(s string) bool {
s = strings.TrimSpace(s)
for _, kw := range []string{"func ", "type ", "var ", "const "} {
if strings.HasPrefix(s, kw) {
rest := strings.TrimSpace(strings.TrimPrefix(s, kw))
if rest == "" {
return false
}
r := rest[0]
return r >= 'A' && r <= 'Z'
}
}
return false
}
for i := 0; i < len(lines); i++ {
line := lines[i]
trim := strings.TrimSpace(line)
// Block comments — drop entirely (docs rarely land inside a chunk).
if inBlockComment {
if strings.Contains(line, "*/") {
inBlockComment = false
}
continue
}
if strings.HasPrefix(trim, "/*") && !strings.Contains(trim, "*/") {
inBlockComment = true
continue
}
// Import blocks — drop the whole thing.
if inImport {
if trim == ")" {
inImport = false
}
continue
}
if trim == "import (" {
inImport = true
continue
}
if strings.HasPrefix(trim, "import \"") || strings.HasPrefix(trim, "import `") {
continue
}
// Blank lines — drop.
if trim == "" {
continue
}
// Line comments — keep only if attached to an exported decl
// on the next non-blank line.
if strings.HasPrefix(trim, "//") {
keep := false
for j := i + 1; j < len(lines); j++ {
next := strings.TrimSpace(lines[j])
if next == "" || strings.HasPrefix(next, "//") {
continue
}
keep = isDecl(next)
break
}
if !keep {
continue
}
}
out = append(out, strings.TrimRight(line, " \t"))
}
return strings.Join(out, "\n")
}
// compressMarkdown drops HTML comments, blank-line runs and trailing
// whitespace. Content inside fenced code blocks is preserved verbatim.
func compressMarkdown(content string) string {
lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
inFence := false
prevBlank := false
for _, line := range lines {
trim := strings.TrimSpace(line)
if strings.HasPrefix(trim, "```") {
inFence = !inFence
out = append(out, line)
prevBlank = false
continue
}
if inFence {
out = append(out, line)
prevBlank = false
continue
}
// HTML comments on their own line.
if strings.HasPrefix(trim, "<!--") && strings.HasSuffix(trim, "-->") {
continue
}
if trim == "" {
if prevBlank {
continue
}
prevBlank = true
out = append(out, "")
continue
}
prevBlank = false
out = append(out, strings.TrimRight(line, " \t"))
}
return strings.Join(out, "\n")
}
// compressGeneric collapses blank-line runs and strips trailing whitespace.
func compressGeneric(content string) string {
lines := strings.Split(content, "\n")
out := make([]string, 0, len(lines))
prevBlank := false
for _, line := range lines {
trim := strings.TrimSpace(line)
if trim == "" {
if prevBlank {
continue
}
prevBlank = true
out = append(out, "")
continue
}
prevBlank = false
out = append(out, strings.TrimRight(line, " \t"))
}
return strings.Join(out, "\n")
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rag
import (
_ "embed"
"strings"
)
//go:embed docs/effective_go.md
var effectiveGoContent string
//go:embed docs/go_code_review_comments.md
var goCodeReviewCommentsContent string
//go:embed docs/go_common_mistakes.md
var goCommonMistakesContent string
// embeddedDoc describes one built-in reference document and the language
// it applies to. An empty Lang means the document is language-neutral.
type embeddedDoc struct {
Path string
Lang string
Content string
}
// embeddedDocs is the registry of built-in reference docs. Add new entries
// here when shipping per-language documentation. Empty Lang documents are
// always included regardless of the requested filter.
var embeddedDocs = []embeddedDoc{
{Path: "embedded://effective_go.md", Lang: "go", Content: effectiveGoContent},
{Path: "embedded://go_code_review_comments.md", Lang: "go", Content: goCodeReviewCommentsContent},
{Path: "embedded://go_common_mistakes.md", Lang: "go", Content: goCommonMistakesContent},
// TODO: ship Python (PEP 8) and JS/TS (TypeScript handbook) reference docs.
}
// EmbeddedDocNames returns the human-readable names of embedded reference
// documents for the given language. An empty lang returns only language-neutral
// doc names. Names are derived from the Path field
// (e.g. "embedded://effective_go.md" → "effective_go").
func EmbeddedDocNames(lang string) []string {
var out []string
for _, d := range embeddedDocs {
if d.Lang != "" && d.Lang != lang {
continue
}
name := strings.TrimPrefix(d.Path, "embedded://")
name = strings.TrimSuffix(name, ".md")
if name != "" {
out = append(out, name)
}
}
return out
}
// ChunkEmbeddedDocs returns chunks for embedded reference documents whose
// Lang matches the given lang or that are language-neutral. An empty lang
// returns only the language-neutral subset.
func ChunkEmbeddedDocs(lang string, chunkSize, chunkOverlap int) []Chunk {
var out []Chunk
for _, d := range embeddedDocs {
if d.Lang != "" && d.Lang != lang {
continue
}
out = append(out, splitIntoChunks(d.Path, d.Content, chunkSize, chunkOverlap)...)
}
return out
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rag
import (
"strings"
)
// ChunkGoDoc splits the output of `go doc` into semantic chunks based on
// Go declaration boundaries (func, type, var, const, package). Each
// declaration plus its preceding comment block forms one chunk. The
// pkgPath is used as a virtual file path prefix (godoc://<pkgPath>).
func ChunkGoDoc(pkgPath, docOutput string, maxBytes int) []Chunk {
if maxBytes <= 0 {
maxBytes = MaxChunkBytes
}
docOutput = strings.TrimSpace(docOutput)
if docOutput == "" {
return nil
}
prefix := "godoc://" + pkgPath
lines := strings.Split(docOutput, "\n")
// Find declaration boundary lines.
type boundary struct {
line int // 0-based index into lines
}
var bounds []boundary
for i, ln := range lines {
if isGoDeclLine(ln) {
bounds = append(bounds, boundary{line: i})
}
}
// No declarations found — treat the whole output as a single chunk.
if len(bounds) == 0 {
return singleOrSplit(prefix, docOutput, lines, maxBytes)
}
// Split into blocks: [start-of-output..first-decl), [decl1..decl2), ..., [last-decl..end)
var chunks []Chunk
for i, b := range bounds {
start := b.line
// Include preceding comment lines (lines starting with // or blank
// lines between comments) that belong to this declaration.
start = findCommentStart(lines, start)
var end int
if i+1 < len(bounds) {
end = findCommentStart(lines, bounds[i+1].line)
} else {
end = len(lines)
}
content := strings.Join(lines[start:end], "\n")
content = strings.TrimSpace(content)
if content == "" {
continue
}
if len(content) > maxBytes {
// Oversized declaration block — fall back to generic splitting.
sub := splitIntoChunks(prefix, content, 0, 0)
// Adjust line numbers relative to the doc output.
for j := range sub {
sub[j].StartLine += start + 1
sub[j].EndLine += start
}
chunks = append(chunks, sub...)
} else {
chunks = append(chunks, Chunk{
FilePath: prefix,
Content: content,
StartLine: start + 1,
EndLine: end,
})
}
}
// If there is a preamble before the first declaration (package doc),
// include it as a separate chunk.
firstDeclStart := findCommentStart(lines, bounds[0].line)
if firstDeclStart > 0 {
preamble := strings.TrimSpace(strings.Join(lines[:firstDeclStart], "\n"))
if preamble != "" {
pre := Chunk{
FilePath: prefix,
Content: preamble,
StartLine: 1,
EndLine: firstDeclStart,
}
chunks = append([]Chunk{pre}, chunks...)
}
}
return chunks
}
// isGoDeclLine returns true if the line looks like a Go declaration start.
func isGoDeclLine(line string) bool {
trimmed := strings.TrimSpace(line)
// go doc output uses unindented declaration keywords.
return strings.HasPrefix(trimmed, "func ") ||
strings.HasPrefix(trimmed, "type ") ||
strings.HasPrefix(trimmed, "var ") ||
strings.HasPrefix(trimmed, "const ") ||
strings.HasPrefix(trimmed, "package ")
}
// findCommentStart walks backwards from line idx to find the first line
// of a contiguous comment/blank block preceding the declaration.
func findCommentStart(lines []string, idx int) int {
i := idx - 1
for i >= 0 {
trimmed := strings.TrimSpace(lines[i])
if trimmed == "" || strings.HasPrefix(trimmed, "//") {
i--
continue
}
break
}
return i + 1
}
// singleOrSplit returns the whole output as one chunk if it fits, or
// falls back to generic line-based splitting.
func singleOrSplit(prefix, content string, lines []string, maxBytes int) []Chunk {
if len(content) <= maxBytes {
return []Chunk{{
FilePath: prefix,
Content: content,
StartLine: 1,
EndLine: len(lines),
}}
}
return splitIntoChunks(prefix, content, 0, 0)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rag
import (
"context"
"crypto/sha256"
"encoding/json"
stderrors "errors"
"fmt"
"math"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
)
const (
maxEmbedInputBytes = 2000 // ~500-650 tokens, safely under nomic-embed-text 2048 token context
maxEmbedRetries = 3 // total attempts per batch before failing
embedRetryBaseDelay = 500 * time.Millisecond
dirPermission = 0o755
filePermission = 0o644
initialCapDivisor = 2 // pre-allocate half of input size for new chunks
)
func truncateInput(s string, maxBytes int) string {
if len(s) <= maxBytes {
return s
}
truncated := s[:maxBytes]
if idx := strings.LastIndex(truncated, "\n"); idx > maxBytes/2 {
return truncated[:idx]
}
return truncated
}
// IndexEntry stores a chunk with its embedding.
type IndexEntry struct {
Chunk Chunk `json:"chunk"`
Embedding []float64 `json:"embedding"`
Hash string `json:"hash"`
}
// Index is an in-memory vector index with disk persistence.
type Index struct {
mu sync.RWMutex
entries []IndexEntry
path string
model string
client llm.Embedder
updated time.Time
}
// NewIndex creates a new RAG index.
func NewIndex(client llm.Embedder, model, indexPath string) *Index {
return &Index{
client: client,
model: model,
path: indexPath,
}
}
// hasLegacyCodeChunks returns true if any loaded entry points at real
// project source code rather than a conventions source. Legitimate
// convention sources are:
//
// - synthetic prefixes rules://, snippets://, embedded://
// - anything whose path contains .kodrun/ (rules, snippets, docs live
// there — ChunkRules stores the absolute file path, so prefix checks
// against "rules://" alone are not enough)
// - anything under a /docs/ directory (ref docs from the global config)
//
// Everything else is assumed to be stale code indexed by an older kodrun
// build and triggers a one-shot Reset on startup.
func (idx *Index) hasLegacyCodeChunks() bool {
idx.mu.RLock()
defer idx.mu.RUnlock()
for _, e := range idx.entries {
p := e.Chunk.FilePath
if strings.HasPrefix(p, "rules://") ||
strings.HasPrefix(p, "snippets://") ||
strings.HasPrefix(p, "embedded://") ||
strings.HasPrefix(p, "godoc://") ||
strings.Contains(p, ".kodrun/") ||
strings.Contains(p, "/docs/") {
continue
}
return true
}
return false
}
// Reset clears in-memory entries and removes the on-disk index file.
// Used by /reindex to guarantee a fresh rebuild, so no stale entries from
// earlier kodrun versions (e.g. indexed source code) can survive.
func (idx *Index) Reset() error {
idx.mu.Lock()
defer idx.mu.Unlock()
idx.entries = nil
idx.updated = time.Time{}
if err := os.RemoveAll(idx.path); err != nil {
return errors.WithMessage(err, "remove index dir")
}
return nil
}
// Load reads the index from disk. If the index has no path (in-memory mode),
// this is a no-op.
func (idx *Index) Load() error {
if idx.path == "" {
return nil
}
idx.mu.Lock()
defer idx.mu.Unlock()
dataPath := filepath.Join(idx.path, "index.json")
data, err := os.ReadFile(dataPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return errors.WithMessage(err, "read index")
}
var entries []IndexEntry
if err := json.Unmarshal(data, &entries); err != nil {
return errors.WithMessage(err, "decode index")
}
idx.entries = entries
return nil
}
// Save writes the index to disk. If the index has no path (in-memory mode),
// this is a no-op.
func (idx *Index) Save() error {
if idx.path == "" {
return nil
}
idx.mu.Lock()
defer idx.mu.Unlock()
if err := os.MkdirAll(idx.path, dirPermission); err != nil {
return errors.WithMessage(err, "create index dir")
}
data, err := json.Marshal(idx.entries)
if err != nil {
return errors.WithMessage(err, "encode index")
}
return os.WriteFile(filepath.Join(idx.path, "index.json"), data, filePermission)
}
// ProgressFunc is invoked from Build/BuildWithProgress while embedding
// chunks. done counts chunks that have already been embedded; total is the
// number of new chunks that need embedding (not the input chunk count, since
// already-indexed chunks are skipped). label is a short human-readable hint.
type ProgressFunc func(label string, done, total int)
// Build indexes the given chunks, skipping already-indexed ones.
// Returns the number of new chunks indexed.
func (idx *Index) Build(ctx context.Context, chunks []Chunk) (int, error) {
return idx.BuildWithProgress(ctx, chunks, nil)
}
// BuildWithProgress is Build with an optional progress callback. The callback
// is invoked once with (0, total) when embedding starts, then after each
// embedding batch with (done, total), and finally with (total, total) when
// the embedding loop finishes (before merge/prune).
func (idx *Index) BuildWithProgress(ctx context.Context, chunks []Chunk, progress ProgressFunc) (int, error) {
// Phase 1: Read existing hashes under RLock.
idx.mu.RLock()
existing := make(map[string]bool, len(idx.entries))
for _, e := range idx.entries {
existing[e.Hash] = true
}
idx.mu.RUnlock()
// Find new chunks.
newChunks := make([]Chunk, 0, len(chunks)/initialCapDivisor)
newHashes := make([]string, 0, len(chunks)/initialCapDivisor)
for _, c := range chunks {
h := chunkHash(c)
if !existing[h] {
newChunks = append(newChunks, c)
newHashes = append(newHashes, h)
}
}
if len(newChunks) == 0 {
// Still need to prune stale entries.
idx.pruneStale(chunks)
if progress != nil {
progress("up to date", 0, 0)
}
return 0, nil
}
// Phase 2: Embed in batches WITHOUT holding the lock.
const batchSize = 4
total := len(newChunks)
if progress != nil {
progress("embedding", 0, total)
}
newEntries := make([]IndexEntry, 0, len(newChunks))
for i := 0; i < len(newChunks); i += batchSize {
select {
case <-ctx.Done():
return len(newEntries), ctx.Err()
default:
}
end := i + batchSize
if end > len(newChunks) {
end = len(newChunks)
}
batch := newChunks[i:end]
inputs := make([]string, len(batch))
for j, c := range batch {
// Prefix with file path for context, truncate to stay within embedding model limits
inputs[j] = truncateInput(fmt.Sprintf("File: %s\n%s", c.FilePath, c.Content), maxEmbedInputBytes)
}
// Retry transient embedding failures with linear backoff so a single
// flaky network/timeout does not abort the whole Build, leaving the
// on-disk snapshot stale. maxEmbedRetries attempts is enough to ride
// out brief blips while still failing fast on hard errors.
var resp *llm.EmbedResponse
var attemptErrs []error
for attempt := 1; attempt <= maxEmbedRetries; attempt++ {
var err error
resp, err = idx.client.Embed(ctx, llm.EmbedRequest{
Model: idx.model,
Input: inputs,
Truncate: true,
})
if err == nil {
attemptErrs = nil
break
}
attemptErrs = append(attemptErrs, errors.WithMessagef(err, "attempt %d/%d", attempt, maxEmbedRetries))
if ctx.Err() != nil {
return len(newEntries), ctx.Err()
}
if attempt < maxEmbedRetries {
select {
case <-ctx.Done():
return len(newEntries), ctx.Err()
case <-time.After(time.Duration(attempt) * embedRetryBaseDelay):
}
}
}
if len(attemptErrs) > 0 {
return len(newEntries), errors.WithMessagef(stderrors.Join(attemptErrs...), "embed batch %d", i/batchSize)
}
if len(resp.Embeddings) != len(batch) {
return len(newEntries), errors.Errorf("embed batch: got %d embeddings, expected %d", len(resp.Embeddings), len(batch))
}
for j, emb := range resp.Embeddings {
newEntries = append(newEntries, IndexEntry{
Chunk: batch[j],
Embedding: emb,
Hash: newHashes[i+j],
})
}
if progress != nil {
progress("embedding", len(newEntries), total)
}
}
// Phase 3: Merge results under exclusive lock.
idx.mu.Lock()
idx.entries = append(idx.entries, newEntries...)
idx.updated = time.Now()
idx.mu.Unlock()
// Prune stale entries.
idx.pruneStale(chunks)
return len(newChunks), nil
}
// pruneStale removes entries for chunks that are no longer present.
func (idx *Index) pruneStale(chunks []Chunk) {
chunkSet := make(map[string]bool, len(chunks))
for _, c := range chunks {
chunkSet[chunkHash(c)] = true
}
idx.mu.Lock()
defer idx.mu.Unlock()
filtered := idx.entries[:0]
for _, e := range idx.entries {
if chunkSet[e.Hash] {
filtered = append(filtered, e)
}
}
idx.entries = filtered
}
// Search finds the top-k most similar chunks to the query.
func (idx *Index) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) {
idx.mu.RLock()
defer idx.mu.RUnlock()
if len(idx.entries) == 0 {
// No entries to search; nil slice is valid for []SearchResult.
return nil, nil
}
resp, err := idx.client.Embed(ctx, llm.EmbedRequest{
Model: idx.model,
Input: []string{truncateInput(query, maxEmbedInputBytes)},
Truncate: true,
})
if err != nil {
return nil, errors.WithMessage(err, "embed query")
}
if len(resp.Embeddings) == 0 {
return nil, errors.New("no embedding returned for query")
}
queryEmb := resp.Embeddings[0]
type scored struct {
entry IndexEntry
score float64
}
results := make([]scored, 0, len(idx.entries))
for _, e := range idx.entries {
sim := cosineSimilarity(queryEmb, e.Embedding)
results = append(results, scored{entry: e, score: sim})
}
sort.Slice(results, func(i, j int) bool {
return results[i].score > results[j].score
})
if topK > len(results) {
topK = len(results)
}
out := make([]SearchResult, topK)
for i := 0; i < topK; i++ {
out[i] = SearchResult{
Chunk: results[i].entry.Chunk,
Score: results[i].score,
}
}
return out, nil
}
// Size returns the number of indexed entries.
func (idx *Index) Size() int {
idx.mu.RLock()
defer idx.mu.RUnlock()
return len(idx.entries)
}
// Updated returns the last index update time.
func (idx *Index) Updated() time.Time {
idx.mu.RLock()
defer idx.mu.RUnlock()
return idx.updated
}
// SearchResult holds a chunk and its similarity score.
type SearchResult struct {
Chunk Chunk `json:"chunk"`
Score float64 `json:"score"`
}
func chunkHash(c Chunk) string {
h := sha256.New()
fmt.Fprintf(h, "%s:%d:%d:%s", c.FilePath, c.StartLine, c.EndLine, c.Content)
return fmt.Sprintf("%x", h.Sum(nil))[:16]
}
// ChunkSetHash computes an order-independent fingerprint for a chunk set.
// Used by backends without per-chunk dedup to detect unchanged inputs.
func ChunkSetHash(chunks []Chunk) string {
hashes := make([]string, len(chunks))
for i, c := range chunks {
hashes[i] = chunkHash(c)
}
sort.Strings(hashes)
h := sha256.New()
for _, hash := range hashes {
fmt.Fprint(h, hash)
}
return fmt.Sprintf("%x", h.Sum(nil))[:16]
}
func cosineSimilarity(a, b []float64) float64 {
if len(a) != len(b) || len(a) == 0 {
return 0
}
var dot, normA, normB float64
for i := range a {
dot += a[i] * b[i]
normA += a[i] * a[i]
normB += b[i] * b[i]
}
denom := math.Sqrt(normA) * math.Sqrt(normB)
if denom == 0 {
return 0
}
return dot / denom
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rag
import (
"context"
"path/filepath"
"time"
"github.com/raoptimus/kodrun/internal/llm"
)
// Compile-time assertion: *MultiIndex satisfies Backend.
var _ Backend = (*MultiIndex)(nil)
// commonKey is the subdirectory used for the single on-disk sub-index.
// The name is preserved for backward compatibility with existing index
// layouts on disk (`.kodrun/rag_index/common/index.json`).
const commonKey = "common"
// godocKey is the subdirectory for the Go documentation sub-index.
const godocKey = "godoc"
// MultiIndex wraps a single Index sub-directory. Historically it supported
// per-language sub-indexes so that switching the active project language
// would not require re-embedding language-neutral chunks, but RAG now only
// indexes project conventions (rules / snippets / docs / embedded language
// standards) — none of which are language-partitioned — so a single common
// sub-index is sufficient. The MultiIndex wrapper is kept purely to avoid
// churning every caller; the name is historical.
type MultiIndex struct {
basePath string
common *Index
godoc *Index
web *Index // in-memory, session-scoped; never persisted to disk
}
// NewMultiIndex creates a new MultiIndex rooted at basePath.
func NewMultiIndex(client llm.Embedder, model, basePath string) *MultiIndex {
return &MultiIndex{
basePath: basePath,
common: NewIndex(client, model, filepath.Join(basePath, commonKey)),
godoc: NewIndex(client, model, filepath.Join(basePath, godocKey)),
web: NewIndex(client, model, ""), // in-memory only
}
}
// BasePath returns the root directory the index is stored under.
func (m *MultiIndex) BasePath() string { return m.basePath }
// LoadCommon loads the sub-index from disk.
func (m *MultiIndex) LoadCommon() error { return m.common.Load() }
// Reset wipes the sub-index both in memory and on disk. /reindex uses it
// to start every rebuild from a clean slate.
func (m *MultiIndex) Reset() error { return m.common.Reset() }
// HasLegacyCodeChunks reports whether the loaded index contains entries
// whose FilePath points at real source files (not rules://, snippets://,
// embedded:// or files under a /docs/ directory). Such entries are leftovers
// from earlier kodrun versions that indexed project code; they must be
// wiped on startup so stale snippets cannot surface in /code-review.
func (m *MultiIndex) HasLegacyCodeChunks() bool {
return m.common.hasLegacyCodeChunks()
}
// BuildCommon embeds chunks into the sub-index.
func (m *MultiIndex) BuildCommon(ctx context.Context, chunks []Chunk) (int, error) {
return m.common.Build(ctx, chunks)
}
// BuildCommonWithProgress is BuildCommon with a progress callback. See
// Index.BuildWithProgress for the callback contract.
func (m *MultiIndex) BuildCommonWithProgress(ctx context.Context, chunks []Chunk, progress ProgressFunc) (int, error) {
return m.common.BuildWithProgress(ctx, chunks, progress)
}
// Build is a convenience alias for BuildCommon used by callers that add
// language-neutral content (e.g. /add_doc, /reindex).
func (m *MultiIndex) Build(ctx context.Context, chunks []Chunk) (int, error) {
return m.BuildCommon(ctx, chunks)
}
// Save persists the sub-index.
func (m *MultiIndex) Save() error { return m.common.Save() }
// SaveCommon writes the sub-index to disk.
func (m *MultiIndex) SaveCommon() error { return m.common.Save() }
// Search returns the top-k chunks matching query from the sub-index.
func (m *MultiIndex) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) {
return m.common.Search(ctx, query, topK)
}
// Size returns the number of indexed entries.
func (m *MultiIndex) Size() int { return m.common.Size() }
// Updated returns the most recent update timestamp.
func (m *MultiIndex) Updated() time.Time { return m.common.Updated() }
// LoadGodoc loads the godoc sub-index from disk.
func (m *MultiIndex) LoadGodoc() error { return m.godoc.Load() }
// BuildGodoc embeds Go documentation chunks into the godoc sub-index.
func (m *MultiIndex) BuildGodoc(ctx context.Context, chunks []Chunk) (int, error) {
return m.godoc.Build(ctx, chunks)
}
// SaveGodoc persists the godoc sub-index to disk.
func (m *MultiIndex) SaveGodoc() error { return m.godoc.Save() }
// SearchGodoc returns the top-k chunks matching query from the godoc sub-index.
func (m *MultiIndex) SearchGodoc(ctx context.Context, query string, topK int) ([]SearchResult, error) {
return m.godoc.Search(ctx, query, topK)
}
// ResetGodoc wipes the godoc sub-index both in memory and on disk.
func (m *MultiIndex) ResetGodoc() error { return m.godoc.Reset() }
// GodocSize returns the number of entries in the godoc sub-index.
func (m *MultiIndex) GodocSize() int { return m.godoc.Size() }
// BuildWeb embeds chunks into the in-memory web sub-index.
func (m *MultiIndex) BuildWeb(ctx context.Context, chunks []Chunk) (int, error) {
return m.web.Build(ctx, chunks)
}
// SearchWeb returns the top-k chunks matching query from the web sub-index.
func (m *MultiIndex) SearchWeb(ctx context.Context, query string, topK int) ([]SearchResult, error) {
return m.web.Search(ctx, query, topK)
}
// WebSize returns the number of entries in the web sub-index.
func (m *MultiIndex) WebSize() int { return m.web.Size() }
// GodocIndexer returns an adapter that satisfies the tools.GoDocIndexer
// interface by delegating to the godoc sub-index.
func (m *MultiIndex) GodocIndexer() *godocIndexerAdapter {
return &godocIndexerAdapter{m: m}
}
// godocIndexerAdapter adapts MultiIndex to the GoDocIndexer interface
// (Build + Save) by routing to the godoc sub-index methods.
type godocIndexerAdapter struct {
m *MultiIndex
}
func (a *godocIndexerAdapter) Build(ctx context.Context, chunks []Chunk) (int, error) {
return a.m.BuildGodoc(ctx, chunks)
}
func (a *godocIndexerAdapter) Save() error {
return a.m.SaveGodoc()
}
func (a *godocIndexerAdapter) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) {
return a.m.SearchGodoc(ctx, query, topK)
}
// WebIndexer returns an adapter for the in-memory web sub-index.
func (m *MultiIndex) WebIndexer() *webIndexerAdapter {
return &webIndexerAdapter{m: m}
}
// webIndexerAdapter adapts MultiIndex to the tools.WebIndexer interface.
type webIndexerAdapter struct {
m *MultiIndex
}
func (a *webIndexerAdapter) Build(ctx context.Context, chunks []Chunk) (int, error) {
return a.m.BuildWeb(ctx, chunks)
}
func (a *webIndexerAdapter) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) {
return a.m.SearchWeb(ctx, query, topK)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rag
import (
"fmt"
"path/filepath"
)
// RuleInfo holds the rule metadata needed for chunking.
type RuleInfo struct {
Name string
Content string
Path string
}
// ChunkRules converts rules into RAG chunks. chunkSize/chunkOverlap come
// from cfg.RAG so rules use the same chunking parameters as project files —
// otherwise hashes built with different sizes never align across reindexes.
func ChunkRules(rules []RuleInfo, chunkSize, chunkOverlap int) []Chunk {
if chunkSize <= 0 {
chunkSize = 128
}
chunks := make([]Chunk, 0, len(rules))
for _, r := range rules {
body := fmt.Sprintf("Rule: %s\n\n%s", r.Name, r.Content)
relPath := r.Path
if relPath == "" {
relPath = "rules://" + r.Name
}
subChunks := splitIntoChunks(relPath, body, chunkSize, chunkOverlap)
chunks = append(chunks, subChunks...)
}
return chunks
}
// RefDocInfo holds reference documentation metadata for chunking.
type RefDocInfo struct {
Path string
Content string
}
// ChunkRefDocs converts reference documents into RAG chunks.
func ChunkRefDocs(docs []RefDocInfo, chunkSize, chunkOverlap int) []Chunk {
if chunkSize <= 0 {
chunkSize = 128
}
chunks := make([]Chunk, 0, len(docs))
for _, d := range docs {
name := filepath.Base(d.Path)
body := fmt.Sprintf("Documentation: %s\n\n%s", name, d.Content)
subChunks := splitIntoChunks(d.Path, body, chunkSize, chunkOverlap)
chunks = append(chunks, subChunks...)
}
return chunks
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rag
import (
"fmt"
"strings"
)
const (
snippetChunkMaxBytes = 512 // max bytes per snippet chunk
snippetChunkOverlap = 32 // overlap bytes between snippet chunks
)
// SnippetInfo holds the snippet metadata needed for chunking.
type SnippetInfo struct {
Name string
Description string
Tags []string
Requires []string
Content string
SourcePath string
}
// ChunkSnippets converts snippets into RAG chunks.
// Large snippets are automatically split via splitIntoChunks to respect byte limits.
// Snippets whose Requires are not satisfied by projectTech are skipped.
func ChunkSnippets(snippets []SnippetInfo, projectTech []string) []Chunk {
techSet := toStringSet(projectTech)
chunks := make([]Chunk, 0, len(snippets))
for _, s := range snippets {
if !requiresMet(s.Requires, techSet) {
continue
}
body := fmt.Sprintf("Snippet: %s\nDescription: %s\nTags: %s\n\n%s",
s.Name, s.Description, strings.Join(s.Tags, ", "), s.Content)
subChunks := splitIntoChunks(s.SourcePath, body, snippetChunkMaxBytes, snippetChunkOverlap)
chunks = append(chunks, subChunks...)
}
return chunks
}
// requiresMet returns true if all required items are present in the available set.
// An empty requires list is always satisfied.
func requiresMet(requires []string, available map[string]bool) bool {
for _, r := range requires {
if !available[r] {
return false
}
}
return true
}
func toStringSet(ss []string) map[string]bool {
m := make(map[string]bool, len(ss))
for _, s := range ss {
m[s] = true
}
return m
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package muninn
import (
"context"
"encoding/json"
"log/slog"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/raoptimus/kodrun/internal/rag"
)
// Compile-time assertion.
var _ rag.Backend = (*Backend)(nil)
const (
tagCommon = "common"
tagGodoc = "godoc"
tagWeb = "web"
writeThrottle = 50 * time.Millisecond
stateDirPerm = 0o755
stateFilePerm = 0o600
)
// Backend implements rag.Backend using Muninn DB as the storage engine.
// Muninn handles embeddings internally, so no external LLM client is needed.
type Backend struct {
common *Client
godoc *Client
web *Client
stateDir string // local directory for chunk-set hash cache
mu sync.RWMutex
updated time.Time
size int
}
// NewBackend creates a Muninn-backed RAG backend. Sub-indexes are isolated
// via vault suffixes: "{vault}", "{vault}-godoc", "{vault}-web".
func NewBackend(opts *Options) *Backend {
vault := opts.Vault
if vault == "" {
vault = "default"
}
return &Backend{
common: NewClient(&Options{URL: opts.URL, Vault: vault}),
godoc: NewClient(&Options{URL: opts.URL, Vault: vault + "-godoc"}),
web: NewClient(&Options{URL: opts.URL, Vault: vault + "-web"}),
stateDir: opts.StateDir,
}
}
type muninnState struct {
ChunkHash string `json:"chunk_hash"`
}
func (b *Backend) stateFile() string {
if b.stateDir == "" {
return ""
}
return filepath.Join(b.stateDir, "muninn_state.json")
}
func (b *Backend) loadHash() string {
f := b.stateFile()
if f == "" {
return ""
}
data, err := os.ReadFile(f)
if err != nil {
return ""
}
var s muninnState
if err := json.Unmarshal(data, &s); err != nil {
return ""
}
return s.ChunkHash
}
func (b *Backend) saveHash(hash string) error {
f := b.stateFile()
if f == "" {
return nil
}
if err := os.MkdirAll(filepath.Dir(f), stateDirPerm); err != nil {
return err
}
data, err := json.Marshal(muninnState{ChunkHash: hash})
if err != nil {
return err
}
return os.WriteFile(f, data, stateFilePerm)
}
func (b *Backend) LoadCommon() error { return nil }
func (b *Backend) LoadGodoc() error { return nil }
func (b *Backend) HasLegacyCodeChunks() bool { return false }
func (b *Backend) Reset() error {
return b.deleteAll(context.Background(), b.common)
}
func (b *Backend) BuildCommon(ctx context.Context, chunks []rag.Chunk) (int, error) {
return b.BuildCommonWithProgress(ctx, chunks, nil)
}
func (b *Backend) BuildCommonWithProgress(ctx context.Context, chunks []rag.Chunk, progress rag.ProgressFunc) (int, error) {
if b.stateDir != "" {
currentHash := rag.ChunkSetHash(chunks)
storedHash := b.loadHash()
if storedHash == currentHash {
if progress != nil {
progress("up to date", 0, 0)
}
return 0, nil
}
// Hash changed: reset to avoid stale/duplicate engrams, then re-index.
if storedHash != "" {
if err := b.deleteAll(ctx, b.common); err != nil {
slog.Warn("muninn: reset before reindex failed", "error", err)
}
}
}
n, err := b.buildChunks(ctx, b.common, tagCommon, chunks, progress)
if err != nil {
return n, err
}
if b.stateDir != "" {
if saveErr := b.saveHash(rag.ChunkSetHash(chunks)); saveErr != nil {
slog.Warn("muninn: save state failed", "error", saveErr)
}
}
return n, nil
}
func (b *Backend) Build(ctx context.Context, chunks []rag.Chunk) (int, error) {
return b.BuildCommon(ctx, chunks)
}
func (b *Backend) Save() error { return nil }
func (b *Backend) SaveCommon() error { return nil }
func (b *Backend) Search(ctx context.Context, query string, topK int) ([]rag.SearchResult, error) {
return b.search(ctx, b.common, query, topK)
}
func (b *Backend) Size() int {
b.mu.RLock()
defer b.mu.RUnlock()
return b.size
}
func (b *Backend) Updated() time.Time {
b.mu.RLock()
defer b.mu.RUnlock()
return b.updated
}
func (b *Backend) BasePath() string { return b.common.baseURL }
// Godoc sub-index methods.
func (b *Backend) BuildGodoc(ctx context.Context, chunks []rag.Chunk) (int, error) {
return b.buildChunks(ctx, b.godoc, tagGodoc, chunks, nil)
}
func (b *Backend) SaveGodoc() error { return nil }
func (b *Backend) SearchGodoc(ctx context.Context, query string, topK int) ([]rag.SearchResult, error) {
return b.search(ctx, b.godoc, query, topK)
}
func (b *Backend) ResetGodoc() error {
return b.deleteAll(context.Background(), b.godoc)
}
func (b *Backend) GodocSize() int { return 0 }
// Web sub-index methods.
func (b *Backend) BuildWeb(ctx context.Context, chunks []rag.Chunk) (int, error) {
return b.buildChunks(ctx, b.web, tagWeb, chunks, nil)
}
func (b *Backend) SearchWeb(ctx context.Context, query string, topK int) ([]rag.SearchResult, error) {
return b.search(ctx, b.web, query, topK)
}
func (b *Backend) WebSize() int { return 0 }
// Internal helpers.
func (b *Backend) buildChunks(ctx context.Context, client *Client, tag string, chunks []rag.Chunk, progress rag.ProgressFunc) (int, error) {
written := 0
for i, ch := range chunks {
if ctx.Err() != nil {
return written, ctx.Err()
}
err := client.WriteEngram(ctx, &WriteEngramIn{
Concept: ch.FilePath,
Content: ch.Content,
Tags: []string{tag},
})
if err != nil {
return written, err
}
written++
if progress != nil {
progress(tag, i+1, len(chunks))
}
// Throttle writes to avoid overloading Muninn.
if i < len(chunks)-1 {
select {
case <-ctx.Done():
return written, ctx.Err()
case <-time.After(writeThrottle):
}
}
}
b.mu.Lock()
b.size += written
b.updated = time.Now()
b.mu.Unlock()
return written, nil
}
func (b *Backend) search(ctx context.Context, client *Client, query string, topK int) ([]rag.SearchResult, error) {
engrams, err := client.Activate(ctx, &ActivateIn{
Context: []string{query},
MaxResults: topK,
})
if err != nil {
return nil, err
}
results := make([]rag.SearchResult, 0, len(engrams))
for _, e := range engrams {
results = append(results, rag.SearchResult{
Chunk: rag.Chunk{
FilePath: e.Concept,
Content: e.Content,
},
Score: e.Score,
})
}
return results, nil
}
func (b *Backend) deleteAll(ctx context.Context, client *Client) error {
engrams, err := client.ListEngrams(ctx)
if err != nil {
return err
}
for _, e := range engrams {
if err := client.DeleteEngram(ctx, e.ID); err != nil {
// Skip errors for individual deletions during bulk reset.
if !strings.Contains(err.Error(), "not found") {
return err
}
}
}
return nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package muninn
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"github.com/pkg/errors"
)
var ErrUnexpectedStatus = errors.New("muninn: unexpected status")
const (
defaultTimeout = 2 * time.Minute
maxRetries = 3
initialRetryDelay = 500 * time.Millisecond
listPageSize = 100
)
type Options struct {
URL string
Vault string
APIKey string
StateDir string // local directory for caching chunk-set hash
}
type WriteEngramIn struct {
Concept string
Content string
Tags []string
}
type ActivateIn struct {
Context []string
MaxResults int
MaxHops int
IncludeWhy bool
}
type Engram struct {
ID string `json:"id"`
Concept string `json:"concept"`
Content string `json:"content"`
Score float64 `json:"score,omitempty"`
Why string `json:"why,omitempty"`
}
type Client struct {
httpClient *http.Client
baseURL string
vault string
apiKey string
}
func NewClient(opts *Options) *Client {
return &Client{
httpClient: &http.Client{Timeout: defaultTimeout},
baseURL: opts.URL,
vault: opts.Vault,
apiKey: opts.APIKey,
}
}
func (c *Client) setAuth(req *http.Request) {
if c.apiKey != "" {
req.Header.Set("Authorization", "Bearer "+c.apiKey)
}
}
func (c *Client) WriteEngram(ctx context.Context, in *WriteEngramIn) error {
body := map[string]any{
"vault": c.vault,
"concept": in.Concept,
"content": in.Content,
}
if len(in.Tags) > 0 {
body["tags"] = in.Tags
}
data, err := json.Marshal(body)
if err != nil {
return errors.Wrap(err, "muninn: marshal engram")
}
delay := initialRetryDelay
for attempt := range maxRetries + 1 {
if attempt > 0 {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(delay):
}
delay *= 2
}
err = c.doWriteEngram(ctx, data)
if err == nil {
return nil
}
if !isRetriable(err) {
return err
}
if d := retryAfterDelay(err); d > 0 {
delay = d
}
}
return err
}
func (c *Client) doWriteEngram(ctx context.Context, data []byte) error {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/engrams", bytes.NewReader(data))
if err != nil {
return errors.Wrap(err, "muninn: create request")
}
req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return errors.Wrap(err, "muninn: write engram")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode == http.StatusOK || resp.StatusCode == http.StatusCreated {
return nil
}
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return errors.Wrap(readErr, "muninn: read error response")
}
statusErr := &statusError{
statusCode: resp.StatusCode,
retryAfter: resp.Header.Get("Retry-After"),
}
return errors.Wrapf(statusErr, "write engram: status %d: %s", resp.StatusCode, string(respBody))
}
type statusError struct {
statusCode int
retryAfter string
}
func (e *statusError) Error() string {
return ErrUnexpectedStatus.Error()
}
func (e *statusError) Is(target error) bool {
return target == ErrUnexpectedStatus
}
func isRetriable(err error) bool {
var se *statusError
if !errors.As(err, &se) {
return false
}
return se.statusCode == http.StatusTooManyRequests || se.statusCode >= http.StatusInternalServerError
}
func retryAfterDelay(err error) time.Duration {
var se *statusError
if !errors.As(err, &se) || se.retryAfter == "" {
return 0
}
seconds, parseErr := strconv.Atoi(se.retryAfter)
if parseErr != nil {
return 0
}
return time.Duration(seconds) * time.Second
}
func (c *Client) Activate(ctx context.Context, in *ActivateIn) ([]Engram, error) {
maxResults := in.MaxResults
if maxResults == 0 {
maxResults = 5
}
body := map[string]any{
"context": in.Context,
"max_results": maxResults,
}
if c.vault != "" {
body["vault"] = c.vault
}
if in.MaxHops > 0 {
body["max_hops"] = in.MaxHops
}
if in.IncludeWhy {
body["include_why"] = true
}
data, err := json.Marshal(body)
if err != nil {
return nil, errors.Wrap(err, "muninn: marshal activate request")
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/activate", bytes.NewReader(data))
if err != nil {
return nil, errors.Wrap(err, "muninn: create request")
}
req.Header.Set("Content-Type", "application/json")
c.setAuth(req)
req.Close = true
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, errors.Wrap(err, "muninn: activate")
}
defer func() { _ = resp.Body.Close() }()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, errors.Wrap(err, "muninn: read activate response")
}
if resp.StatusCode != http.StatusOK {
return nil, errors.Wrapf(ErrUnexpectedStatus, "activate: status %d: %s", resp.StatusCode, string(respBody))
}
var result struct {
Activations []Engram `json:"activations"`
}
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, errors.Wrap(err, "muninn: decode activate response")
}
return result.Activations, nil
}
func (c *Client) ListEngrams(ctx context.Context) ([]Engram, error) {
var all []Engram
for offset := 0; ; offset += listPageSize {
url := fmt.Sprintf("%s/api/engrams?vault=%s&limit=%d&offset=%d",
c.baseURL, c.vault, listPageSize, offset)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody)
if err != nil {
return nil, errors.Wrap(err, "muninn: create list request")
}
c.setAuth(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, errors.Wrap(err, "muninn: list engrams")
}
respBody, err := io.ReadAll(resp.Body)
_ = resp.Body.Close()
if err != nil {
return nil, errors.Wrap(err, "muninn: read list response")
}
if resp.StatusCode != http.StatusOK {
return nil, errors.Wrapf(ErrUnexpectedStatus, "list engrams: status %d: %s", resp.StatusCode, string(respBody))
}
var page struct {
Engrams []Engram `json:"engrams"`
Total int `json:"total"`
}
if err := json.Unmarshal(respBody, &page); err != nil {
return nil, errors.Wrap(err, "muninn: decode list response")
}
all = append(all, page.Engrams...)
if len(all) >= page.Total {
break
}
}
return all, nil
}
func (c *Client) DeleteEngram(ctx context.Context, id string) error {
url := fmt.Sprintf("%s/api/engrams/%s", c.baseURL, id)
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, url, http.NoBody)
if err != nil {
return errors.Wrap(err, "muninn: create delete request")
}
c.setAuth(req)
resp, err := c.httpClient.Do(req)
if err != nil {
return errors.Wrap(err, "muninn: delete engram")
}
defer func() { _ = resp.Body.Close() }()
if resp.StatusCode != http.StatusOK {
respBody, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return errors.Wrap(readErr, "muninn: read delete error response")
}
return errors.Wrapf(ErrUnexpectedStatus, "delete engram %s: status %d: %s", id, resp.StatusCode, string(respBody))
}
return nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package rules
import (
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"time"
"github.com/pkg/errors"
)
// Priority determines the order rules are included in the prompt.
type Priority int
const (
PriorityHigh Priority = 0
PriorityNormal Priority = 1
PriorityLow Priority = 2
)
// Scope determines when a rule is active.
type Scope string
const (
ScopeAll Scope = "all"
ScopeCoding Scope = "coding"
ScopeReview Scope = "review"
ScopeFix Scope = "fix"
frontMatterDescription = "description"
)
// Rule represents a loaded rule file.
type Rule struct {
Path string
Content string
Priority Priority
Scope Scope
ModTime time.Time
RefPaths []string // which docs this rule references
description string // optional description from front matter
}
// Command represents a user-defined command.
type Command struct {
Name string
Description string
Template string
Args []CommandArg
}
// CommandArg describes a command argument.
type CommandArg struct {
Name string
Required bool
}
// Fixed directories for rules, commands, and docs.
var (
RulesDirs = ".kodrun/rules"
CommandsDir = ".kodrun/commands"
DocsDir = ".kodrun/docs"
)
// Loader loads and caches rules from directories.
type Loader struct {
dirs []string
workDir string
rules []*Rule
commands map[string]*Command
refDocs map[string]string // path → resolved content (deduplicated)
refOrder []string // insertion order for deterministic output
maxRefSize int // max size of a single doc (0 = no limit)
unresolvedRefs []UnresolvedRef // @-references that failed to resolve during Load
}
// UnresolvedRef describes a single @-reference in a rule file that could not
// be resolved to an existing file on disk.
type UnresolvedRef struct {
RulePath string // path to the rule file containing the broken reference
RefPath string // the @-target as written in the rule (without the leading @)
}
const minKeyValueParts = 2 // expected parts when splitting "key: value"
// refPattern matches @path references in rule content.
// Matches: @.kodrun/docs/file.md, @.kodrun/docs/example.go, etc.
var refPattern = regexp.MustCompile(`@([^\s,]+\.\w+)`)
// NewLoader creates a rules loader.
// workDir is the project root used to resolve @file references.
// maxRefSize limits the size of each referenced doc (0 = no limit).
func NewLoader(workDir string, maxRefSize int) *Loader {
return &Loader{
dirs: []string{RulesDirs, CommandsDir},
workDir: workDir,
commands: make(map[string]*Command),
refDocs: make(map[string]string),
maxRefSize: maxRefSize,
}
}
// Load reads all rule files (.md) from configured directories.
func (l *Loader) Load(ctx context.Context) error {
l.rules = nil
l.commands = make(map[string]*Command)
l.refDocs = make(map[string]string)
l.refOrder = nil
l.unresolvedRefs = nil
for _, dir := range l.dirs {
absDir := dir
if !filepath.IsAbs(dir) {
absDir = filepath.Join(l.workDir, dir)
}
if _, err := os.Stat(absDir); os.IsNotExist(err) {
continue
}
err := filepath.WalkDir(absDir, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return filepath.SkipDir
}
if d.IsDir() || filepath.Ext(path) != ".md" {
return nil
}
if err := l.loadRule(ctx, dir, path, d); err != nil {
return err
}
return nil
})
if err != nil {
return err
}
}
sort.Slice(l.rules, func(i, j int) bool {
return l.rules[i].Priority < l.rules[j].Priority
})
return nil
}
// loadRule reads a single rule file and appends it to l.rules.
func (l *Loader) loadRule(ctx context.Context, dir, path string, d fs.DirEntry) error {
data, readErr := os.ReadFile(path)
if readErr != nil {
return fmt.Errorf("read rule %s: %w", path, readErr)
}
if len(data) == 0 {
return nil
}
info, infoErr := d.Info()
if infoErr != nil {
return fmt.Errorf("stat rule %s: %w", path, infoErr)
}
if info == nil {
return nil
}
content := string(data)
priority, scope, body, desc := parseFrontMatter(content)
// Collect @file references: deduplicate into refDocs, replace with labels
body, refPaths := l.collectReferences(ctx, path, body)
// Check if this is a command definition
if strings.Contains(dir, "commands") {
cmd := parseCommand(path, content)
if cmd != nil {
l.commands[cmd.Name] = cmd
}
}
l.rules = append(l.rules, &Rule{
Path: path,
Content: body,
Priority: priority,
Scope: scope,
ModTime: info.ModTime(),
RefPaths: refPaths,
description: desc,
})
return nil
}
// collectReferences scans @path refs in text, resolves and deduplicates them into
// refDocs, and replaces inline references with [see: filename] labels.
// Returns the modified text and list of reference paths.
// Any @-reference that fails to resolve is recorded in l.unresolvedRefs so the
// caller can surface a warning instead of silently dropping the doc from RAG.
func (l *Loader) collectReferences(ctx context.Context, rulePath, content string) (resolvedContent string, refPaths []string) {
seen := make(map[string]bool)
result := refPattern.ReplaceAllStringFunc(content, func(match string) string {
refPath := match[1:] // strip leading @
resolvedPath, data, err := l.resolveRef(ctx, refPath)
if err != nil {
l.unresolvedRefs = append(l.unresolvedRefs, UnresolvedRef{
RulePath: rulePath,
RefPath: refPath,
})
return match // leave as-is if not found
}
base := filepath.Base(resolvedPath)
// Track this ref for the rule
if !seen[resolvedPath] {
seen[resolvedPath] = true
refPaths = append(refPaths, resolvedPath)
}
// Store in global deduplicated map
if _, exists := l.refDocs[resolvedPath]; !exists {
docContent := l.formatDoc(ctx, resolvedPath, string(data))
l.refDocs[resolvedPath] = docContent
l.refOrder = append(l.refOrder, resolvedPath)
}
return fmt.Sprintf("[see: %s]", base)
})
return result, refPaths
}
// resolveRef reads a referenced file. The @-syntax always resolves relative
// to the project root (workDir); the path is used as-is without any rewriting.
func (l *Loader) resolveRef(ctx context.Context, refPath string) (resolved string, data []byte, err error) {
data, err = l.readRef(ctx, refPath)
if err != nil {
return "", nil, err
}
return refPath, data, nil
}
// formatDoc formats and optionally truncates a doc for inclusion in ReferenceDocs.
func (l *Loader) formatDoc(_ context.Context, path, content string) string {
if l.maxRefSize > 0 && len(content) > l.maxRefSize {
content = content[:l.maxRefSize] + fmt.Sprintf(
"\n[truncated — use read_file(%q) for full content]", path,
)
}
base := filepath.Base(path)
ext := filepath.Ext(path)
switch ext {
case ".go":
return fmt.Sprintf("```go\n// %s\n%s\n```", base, content)
default:
return fmt.Sprintf("--- %s ---\n%s", base, content)
}
}
// readRef reads a referenced file. The @-syntax always resolves relative to
// the project root (workDir): a leading "/" is stripped so that @/.kodrun/...
// behaves the same as @.kodrun/..., and the resolved path must stay inside
// workDir to prevent escaping via "..".
func (l *Loader) readRef(_ context.Context, refPath string) ([]byte, error) {
cleaned := strings.TrimPrefix(refPath, "/")
if filepath.IsAbs(cleaned) {
return nil, errors.Errorf("@-reference must be relative to project root, got absolute path %q", refPath)
}
absPath := filepath.Join(l.workDir, cleaned)
rel, err := filepath.Rel(l.workDir, absPath)
if err != nil || strings.HasPrefix(rel, "..") {
return nil, errors.Errorf("@-reference %q escapes project root", refPath)
}
return os.ReadFile(absPath)
}
// Rules returns loaded rules filtered by scope.
// AllRules returns all loaded rules regardless of scope.
func (l *Loader) AllRules() []*Rule {
return l.rules
}
// UnresolvedRefs returns all @-references collected during Load that did not
// resolve to an existing file. The caller can surface these as warnings so a
// typo in a rule (e.g. @.koderun/... instead of @.kodrun/...) does not silently
// drop docs from RAG indexing.
func (l *Loader) UnresolvedRefs() []UnresolvedRef {
return l.unresolvedRefs
}
func (l *Loader) Rules(_ context.Context, scope Scope) []*Rule {
var filtered []*Rule
for _, r := range l.rules {
if r.Scope == ScopeAll || r.Scope == scope {
filtered = append(filtered, r)
}
}
return filtered
}
// AllRulesContent returns concatenated content of all rules for a scope.
func (l *Loader) AllRulesContent(ctx context.Context, scope Scope) string {
rules := l.Rules(ctx, scope)
var b strings.Builder
for _, r := range rules {
b.WriteString(r.Content)
b.WriteByte('\n')
}
return b.String()
}
// ReferenceDocs returns deduplicated documentation referenced by rules of
// the given scope. Each doc is included at most once, in insertion order.
func (l *Loader) ReferenceDocs(ctx context.Context, scope Scope) string {
rules := l.Rules(ctx, scope)
// Collect unique ref paths from matching rules
needed := make(map[string]bool)
for _, r := range rules {
for _, p := range r.RefPaths {
needed[p] = true
}
}
var b strings.Builder
for _, path := range l.refOrder {
if !needed[path] {
continue
}
if b.Len() > 0 {
b.WriteString("\n\n")
}
b.WriteString(l.refDocs[path])
}
return b.String()
}
// ReferenceDocPaths returns all unique reference doc paths and their content.
func (l *Loader) ReferenceDocPaths() map[string]string {
result := make(map[string]string, len(l.refDocs))
for path, content := range l.refDocs {
result[path] = content
}
return result
}
// RuleSummary is a compact representation of a rule for the catalog.
type RuleSummary struct {
Name string // file name without extension (e.g. "service")
Description string // auto-extracted or from front matter
RefFiles []string // base names of referenced docs
}
// RuleCatalog returns a compact list of rule summaries for the given scope.
func (l *Loader) RuleCatalog(ctx context.Context, scope Scope) []RuleSummary {
rules := l.Rules(ctx, scope)
summaries := make([]RuleSummary, 0, len(rules))
for _, r := range rules {
name := strings.TrimSuffix(filepath.Base(r.Path), ".md")
desc := l.extractDescription(ctx, r)
var refFiles []string
for _, p := range r.RefPaths {
refFiles = append(refFiles, filepath.Base(p))
}
summaries = append(summaries, RuleSummary{
Name: name,
Description: desc,
RefFiles: refFiles,
})
}
return summaries
}
// RuleCatalogString returns a formatted catalog string for the system prompt.
// When useTool is false, returns empty string — rules are not exposed to the model.
func (l *Loader) RuleCatalogString(ctx context.Context, scope Scope, useTool bool) string {
if !useTool {
return ""
}
summaries := l.RuleCatalog(ctx, scope)
if len(summaries) == 0 {
return ""
}
var b strings.Builder
b.WriteString("Available project rules (call get_rule(name) before writing code):\n")
for _, s := range summaries {
b.WriteString("- ")
b.WriteString(s.Name)
b.WriteString(": ")
b.WriteString(s.Description)
if len(s.RefFiles) > 0 {
b.WriteString(" [see: ")
b.WriteString(strings.Join(s.RefFiles, ", "))
b.WriteString("]")
}
b.WriteByte('\n')
}
return b.String()
}
// GetRuleContent returns the full text of a rule and all its referenced docs.
func (l *Loader) GetRuleContent(ctx context.Context, name string, scope Scope) (string, error) {
rules := l.Rules(ctx, scope)
for _, r := range rules {
ruleName := strings.TrimSuffix(filepath.Base(r.Path), ".md")
if ruleName != name {
continue
}
var b strings.Builder
b.WriteString("# Rule: ")
b.WriteString(name)
b.WriteString("\n\n")
b.WriteString(r.Content)
b.WriteString("\n")
// Append all referenced docs
for _, refPath := range r.RefPaths {
if doc, ok := l.refDocs[refPath]; ok {
b.WriteString("\n\n")
b.WriteString(doc)
}
}
return b.String(), nil
}
// Try without scope filter as fallback
for _, r := range l.rules {
ruleName := strings.TrimSuffix(filepath.Base(r.Path), ".md")
if ruleName == name {
var b strings.Builder
b.WriteString("# Rule: ")
b.WriteString(name)
b.WriteString("\n\n")
b.WriteString(r.Content)
b.WriteString("\n")
for _, refPath := range r.RefPaths {
if doc, ok := l.refDocs[refPath]; ok {
b.WriteString("\n\n")
b.WriteString(doc)
}
}
return b.String(), nil
}
}
return "", errors.Errorf("rule %q not found", name)
}
// extractDescription derives a description for a rule.
// Priority: 1) front matter "description" field, 2) first referenced doc heading + first sentence, 3) rule file name.
func (l *Loader) extractDescription(_ context.Context, r *Rule) string {
// Check front matter description
if r.description != "" {
return r.description
}
// Try to extract from first referenced doc
if len(r.RefPaths) > 0 {
if doc, ok := l.refDocs[r.RefPaths[0]]; ok {
if desc := extractDocDescription(doc); desc != "" {
return desc
}
}
}
// Fallback to file name
return strings.TrimSuffix(filepath.Base(r.Path), ".md")
}
// extractDocDescription extracts heading + first sentence from a formatted doc.
func extractDocDescription(doc string) string {
// Doc format is either "--- filename ---\ncontent" or "```go\n// filename\ncontent\n```"
lines := strings.Split(doc, "\n")
var heading, firstSentence string
for _, line := range lines {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "---") || strings.HasPrefix(line, "```") || strings.HasPrefix(line, "//") {
continue
}
if strings.HasPrefix(line, "# ") {
heading = strings.TrimPrefix(line, "# ")
continue
}
if heading != "" && firstSentence == "" {
// Take first sentence (up to first period or end of line)
firstSentence = line
if idx := strings.Index(firstSentence, "."); idx >= 0 {
firstSentence = firstSentence[:idx]
}
break
}
}
if heading != "" && firstSentence != "" {
return heading + " — " + firstSentence
}
if heading != "" {
return heading
}
return ""
}
// GetCommand returns a command by name.
func (l *Loader) GetCommand(name string) (*Command, bool) {
cmd, ok := l.commands[name]
return cmd, ok
}
// Commands returns all loaded commands.
func (l *Loader) Commands() map[string]*Command {
return l.commands
}
func parseFrontMatter(content string) (outPriority Priority, outScope Scope, outBody, outDesc string) {
outPriority = PriorityNormal
outScope = ScopeAll
if !strings.HasPrefix(content, "---") {
return outPriority, outScope, content, outDesc
}
end := strings.Index(content[3:], "---")
if end == -1 {
return outPriority, outScope, content, outDesc
}
frontMatter := content[3 : end+3]
bodyText := content[end+6:]
for _, line := range strings.Split(frontMatter, "\n") {
line = strings.TrimSpace(line)
parts := strings.SplitN(line, ":", minKeyValueParts)
if len(parts) != minKeyValueParts {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
switch key {
case "priority":
switch val {
case "high":
outPriority = PriorityHigh
case "low":
outPriority = PriorityLow
}
case "scope":
outScope = Scope(val)
case frontMatterDescription:
outDesc = strings.Trim(val, "\"")
}
}
return outPriority, outScope, strings.TrimSpace(bodyText), outDesc
}
func parseCommand(path, content string) *Command {
if !strings.HasPrefix(content, "---") {
return nil
}
end := strings.Index(content[3:], "---")
if end == -1 {
return nil
}
frontMatter := content[3 : end+3]
body := strings.TrimSpace(content[end+6:])
cmd := &Command{
Template: body,
}
for _, line := range strings.Split(frontMatter, "\n") {
line = strings.TrimSpace(line)
parts := strings.SplitN(line, ":", minKeyValueParts)
if len(parts) != minKeyValueParts {
continue
}
key := strings.TrimSpace(parts[0])
val := strings.TrimSpace(parts[1])
val = strings.Trim(val, "\"")
switch key {
case "command":
cmd.Name = strings.TrimPrefix(val, "/")
case frontMatterDescription:
cmd.Description = val
}
}
if cmd.Name == "" {
// Use filename as command name
cmd.Name = strings.TrimSuffix(filepath.Base(path), ".md")
}
return cmd
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package runner
import (
"context"
"fmt"
"strings"
"github.com/pkg/errors"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/tools"
)
// Fixer uses an LLM to automatically fix Go errors.
type Fixer struct {
client llm.Client
model string
reg *tools.Registry
maxIter int
}
// NewFixer creates a new error fixer.
func NewFixer(_ context.Context, client llm.Client, model string, reg *tools.Registry, maxIter int) *Fixer {
return &Fixer{
client: client,
model: model,
reg: reg,
maxIter: maxIter,
}
}
// Fix attempts to fix errors from a tool run.
func (f *Fixer) Fix(ctx context.Context, toolName, output string, onEvent func(string)) (bool, error) {
errs := ParseErrors(ctx, output)
if len(errs) == 0 {
return false, nil
}
// Read affected files for context
files := AffectedFiles(ctx, errs)
var fileContents strings.Builder
for _, file := range files {
result, err := f.reg.Execute(ctx, tools.NameReadFile, map[string]any{"path": file})
if err != nil {
continue
}
fmt.Fprintf(&fileContents, "=== %s ===\n%s\n\n", file, result.Output)
}
prompt := fmt.Sprintf(`Fix the following Go errors. Use the edit_file or write_file tools to apply fixes.
Errors:
%s
File contents:
%s
Fix each error. Use edit_file with old_str/new_str for targeted changes.`,
FormatErrors(ctx, errs),
fileContents.String(),
)
for iter := range f.maxIter {
if onEvent != nil {
onEvent(fmt.Sprintf("[fix] attempt %d/%d", iter+1, f.maxIter))
}
resp, err := f.client.ChatSync(ctx, &llm.ChatRequest{
Model: f.model,
Messages: []llm.Message{
{Role: "system", Content: "You are a Go expert. Fix errors using available tools. Be precise and minimal in changes."},
{Role: "user", Content: prompt},
},
Tools: f.reg.ToolDefs(),
})
if err != nil {
return false, errors.WithMessage(err, "fixer chat")
}
if len(resp.ToolCalls) == 0 {
return false, nil
}
for _, tc := range resp.ToolCalls {
_, err := f.reg.Execute(ctx, tc.Function.Name, tc.Function.Arguments)
if onEvent != nil {
status := "✓"
if err != nil {
status = "✗"
}
onEvent(fmt.Sprintf("[fix] %s %s", tc.Function.Name, status))
}
}
// Re-run the original tool to check if errors are fixed
result, err := f.reg.Execute(ctx, toolName, map[string]any{})
if err != nil {
return false, err
}
// Update prompt with remaining errors
errs = ParseErrors(ctx, result.Output)
if len(errs) == 0 {
return true, nil
}
prompt = fmt.Sprintf("Still have errors:\n%s\n\nFix them.", FormatErrors(ctx, errs))
}
return false, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package runner
import (
"context"
"regexp"
"strconv"
"strings"
)
// ParsedError represents a parsed Go error with file location.
type ParsedError struct {
File string
Line int
Col int
Message string
Raw string
}
var (
// go build / go vet: ./path/file.go:10:5: error message
reBuild = regexp.MustCompile(`^\.?/?(.+\.go):(\d+):(\d+):\s*(.+)$`)
// go test failure: file_test.go:32: message
reTest = regexp.MustCompile(`^\s*(.+_test\.go):(\d+):\s*(.+)$`)
// golangci-lint: path/file.go:12:1: message (linter)
reLint = regexp.MustCompile(`^(.+\.go):(\d+):(\d+):\s*(.+)$`)
)
// ParseErrors extracts structured errors from command output.
func ParseErrors(_ context.Context, output string) []ParsedError {
var errors []ParsedError
seen := make(map[string]bool)
for _, line := range strings.Split(output, "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var pe ParsedError
if m := reBuild.FindStringSubmatch(line); m != nil {
pe.File = m[1]
pe.Line = atoiOr0(m[2])
pe.Col = atoiOr0(m[3])
pe.Message = m[4]
} else if m := reLint.FindStringSubmatch(line); m != nil {
pe.File = m[1]
pe.Line = atoiOr0(m[2])
pe.Col = atoiOr0(m[3])
pe.Message = m[4]
} else if m := reTest.FindStringSubmatch(line); m != nil {
pe.File = m[1]
pe.Line = atoiOr0(m[2])
pe.Message = m[3]
} else {
continue
}
pe.Raw = line
key := pe.File + ":" + strconv.Itoa(pe.Line) + ":" + pe.Message
if !seen[key] {
seen[key] = true
errors = append(errors, pe)
}
}
return errors
}
// FormatErrors converts parsed errors to a human-readable string.
func FormatErrors(_ context.Context, errors []ParsedError) string {
if len(errors) == 0 {
return ""
}
var b strings.Builder
for i, e := range errors {
if i > 0 {
b.WriteByte('\n')
}
b.WriteString(e.Raw)
}
return b.String()
}
// AffectedFiles returns unique file paths from parsed errors.
func AffectedFiles(_ context.Context, errors []ParsedError) []string {
seen := make(map[string]bool)
var files []string
for _, e := range errors {
if e.File != "" && !seen[e.File] {
seen[e.File] = true
files = append(files, e.File)
}
}
return files
}
// atoiOr0 parses s as int, returning 0 on failure.
func atoiOr0(s string) int {
n, err := strconv.Atoi(s)
if err != nil {
return 0
}
return n
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package snippets
import (
"context"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"github.com/pkg/errors"
yaml "go.yaml.in/yaml/v3"
)
// Snippet represents a parsed snippet file.
type Snippet struct {
Name string
Description string `yaml:"description"`
Tags []string `yaml:"tags"`
Paths []string `yaml:"paths"`
Requires []string `yaml:"requires"`
Related []string `yaml:"related"`
Placeholders map[string]string `yaml:"placeholders"`
Lang string `yaml:"lang"`
Content string
SourcePath string // absolute path to the source .md file
nameLower string
descLower string
searchTokens []string
tagSet map[string]struct{}
globs []compiledGlob
sections []section
}
type compiledGlob struct {
raw string
segParts [][]string
simple bool
}
type section struct {
heading string
body string
}
// SnippetsDir is the fixed directory for project snippets.
var SnippetsDir = ".kodrun/snippets"
// Loader loads snippets from configured directories.
type Loader struct {
dirs []string
workDir string
snippets []Snippet
}
// NewLoader creates a snippets loader.
func NewLoader(workDir string) *Loader {
return &Loader{dirs: []string{SnippetsDir}, workDir: workDir}
}
// Load reads snippet files from configured directories.
func (l *Loader) Load(_ context.Context) error {
l.snippets = nil
for _, dir := range l.dirs {
absDir := dir
if !filepath.IsAbs(absDir) {
absDir = filepath.Join(l.workDir, dir)
}
info, err := os.Stat(absDir)
if os.IsNotExist(err) {
continue
}
if err != nil {
return errors.WithMessagef(err, "stat snippets dir %q", absDir)
}
if !info.IsDir() {
continue
}
if err := filepath.WalkDir(absDir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || filepath.Ext(path) != ".md" {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return errors.WithMessagef(err, "read %s", path)
}
snippet, err := parseSnippet(path, data)
if err != nil {
return errors.WithMessagef(err, "parse %s", path)
}
l.snippets = append(l.snippets, snippet)
return nil
}); err != nil {
return err
}
}
sort.Slice(l.snippets, func(i, j int) bool {
return l.snippets[i].Name < l.snippets[j].Name
})
return nil
}
// Snippets returns a copy of loaded snippets.
func (l *Loader) Snippets() []Snippet {
out := make([]Snippet, len(l.snippets))
copy(out, l.snippets)
return out
}
// SourcePathByName returns the source file path for a snippet by name.
func (l *Loader) SourcePathByName(name string) string {
for i := range l.snippets {
if l.snippets[i].Name == name {
return l.snippets[i].SourcePath
}
}
return ""
}
func parseSnippet(filename string, data []byte) (Snippet, error) {
content := string(data)
if !strings.HasPrefix(content, "---\n") {
return Snippet{}, errors.New("missing YAML frontmatter")
}
rest := content[4:]
yamlPart, body, found := strings.Cut(rest, "\n---\n")
if !found {
return Snippet{}, errors.New("unterminated YAML frontmatter")
}
var snippet Snippet
if err := yaml.Unmarshal([]byte(yamlPart), &snippet); err != nil {
return Snippet{}, errors.WithMessage(err, "parse YAML")
}
snippet.Name = strings.TrimSuffix(filepath.Base(filename), ".md")
snippet.SourcePath = filename
snippet.Content = strings.TrimSpace(body)
snippet.nameLower = strings.ToLower(snippet.Name)
snippet.descLower = strings.ToLower(snippet.Description)
snippet.searchTokens = tokenizeForSearch(snippet.nameLower, snippet.descLower, snippet.Tags)
snippet.tagSet = make(map[string]struct{}, len(snippet.Tags))
for _, tag := range snippet.Tags {
snippet.tagSet[tag] = struct{}{}
}
snippet.globs = make([]compiledGlob, len(snippet.Paths))
for i, p := range snippet.Paths {
snippet.globs[i] = compileGlob(p)
}
snippet.sections = parseSections(snippet.Content)
return snippet, nil
}
func parseSections(content string) []section {
lines := strings.Split(content, "\n")
var sections []section
var current *section
for _, line := range lines {
if strings.HasPrefix(line, "## ") || strings.HasPrefix(line, "### ") {
if current != nil {
current.body = strings.TrimSpace(current.body)
sections = append(sections, *current)
}
heading := strings.TrimLeft(line, "#")
heading = strings.TrimSpace(heading)
current = §ion{heading: heading, body: line + "\n"}
continue
}
if current != nil {
current.body += line + "\n"
}
}
if current != nil {
current.body = strings.TrimSpace(current.body)
sections = append(sections, *current)
}
return sections
}
func compileGlob(pattern string) compiledGlob {
if !strings.Contains(pattern, "**") {
return compiledGlob{raw: pattern, simple: true}
}
parts := strings.Split(pattern, "**")
segments := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.Trim(part, "/")
if part != "" {
segments = append(segments, part)
}
}
segParts := make([][]string, len(segments))
for i, segment := range segments {
segParts[i] = strings.Split(segment, "/")
}
return compiledGlob{raw: pattern, segParts: segParts}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package snippets
import (
"path/filepath"
"sort"
"strings"
"unicode"
)
const (
minFilenameTagHits = 2
minTokenLen = 2 // minimum length for a token to be considered meaningful
)
type matchScore struct {
index int
hits int
}
// MatchOpts holds all filter parameters for snippet matching.
type MatchOpts struct {
Paths []string
Tags []string
TagMode string
Query string
Section string
}
// MatchOutput holds match results and metadata.
type MatchOutput struct {
Snippets []Snippet
FilenameFallback bool
}
// TagGroup maps tag names to the snippet names that carry them.
type TagGroup struct {
Tag string `json:"tag"`
Snippets []string `json:"snippets"`
}
// FilterByTags applies tag filtering with the given mode.
func FilterByTags(snippets []Snippet, tags []string, tagMode string) []Snippet {
if len(tags) == 0 {
return snippets
}
if strings.EqualFold(tagMode, "or") {
return MatchTagsOr(snippets, tags)
}
return MatchTags(snippets, tags)
}
// MatchTags returns snippets containing all tags.
func MatchTags(snippets []Snippet, tags []string) []Snippet {
if len(tags) == 0 {
return nil
}
var result []Snippet
for i := range snippets {
if hasAllTags(snippets[i].tagSet, tags) {
result = append(result, snippets[i])
}
}
return result
}
// MatchTagsOr returns snippets containing any tag.
func MatchTagsOr(snippets []Snippet, tags []string) []Snippet {
if len(tags) == 0 {
return nil
}
var result []Snippet
for i := range snippets {
if hasAnyTag(snippets[i].tagSet, tags) {
result = append(result, snippets[i])
}
}
return result
}
// GroupByTags returns snippets grouped by tag.
func GroupByTags(snippets []Snippet) []TagGroup {
tagMap := make(map[string][]string)
for i := range snippets {
for _, tag := range snippets[i].Tags {
tagMap[tag] = append(tagMap[tag], snippets[i].Name)
}
}
groups := make([]TagGroup, 0, len(tagMap))
for tag, names := range tagMap {
sort.Strings(names)
groups = append(groups, TagGroup{Tag: tag, Snippets: names})
}
sort.Slice(groups, func(i, j int) bool {
return groups[i].Tag < groups[j].Tag
})
return groups
}
// MatchWithOpts matches snippets by paths, tags, and query.
func MatchWithOpts(snippets []Snippet, opts *MatchOpts) MatchOutput {
queryLower := strings.ToLower(opts.Query)
queryTokens := tokenizeQuery(queryLower)
hasQuery := opts.Query != "" && len(queryTokens) > 0
if len(opts.Paths) == 0 && len(opts.Tags) == 0 && !hasQuery {
return MatchOutput{}
}
tagMode := strings.ToLower(opts.TagMode)
if tagMode == "" {
tagMode = "and"
}
hasPaths := len(opts.Paths) > 0
scored := make(map[int]int)
var globMatched bool
for i := range snippets {
s := &snippets[i]
var tagHit, queryHit bool
if len(opts.Tags) > 0 {
if tagMode == "or" {
tagHit = hasAnyTag(s.tagSet, opts.Tags)
} else {
tagHit = hasAllTags(s.tagSet, opts.Tags)
}
}
if hasQuery {
queryHit = snippetMatchesQuery(s, queryLower, queryTokens)
}
if hasQuery && !queryHit {
continue
}
if !hasPaths {
if len(opts.Tags) > 0 && !tagHit {
continue
}
scored[i] = countHits(false, tagHit, queryHit)
continue
}
pathHit := matchesAnyPath(s.globs, opts.Paths)
if pathHit {
globMatched = true
}
if len(opts.Tags) > 0 && !pathHit && !tagHit {
continue
}
if len(opts.Tags) == 0 && !pathHit {
continue
}
hits := countHits(pathHit, tagHit, queryHit)
if prev, ok := scored[i]; !ok || hits > prev {
scored[i] = hits
}
}
var usedFallback bool
if hasPaths && !globMatched && len(opts.Tags) == 0 && !hasQuery {
for _, path := range opts.Paths {
tokens := filenameTokens(path)
for i := range snippets {
if matchFilenameTags(&snippets[i], tokens) {
if _, ok := scored[i]; !ok {
scored[i] = 0
}
}
}
}
usedFallback = len(scored) > 0
}
entries := make([]matchScore, 0, len(scored))
for idx, hits := range scored {
entries = append(entries, matchScore{index: idx, hits: hits})
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].hits != entries[j].hits {
return entries[i].hits > entries[j].hits
}
return entries[i].index < entries[j].index
})
result := make([]Snippet, len(entries))
for i := range entries {
snippet := snippets[entries[i].index]
if opts.Section != "" {
snippet.Content = filterSections(&snippet, opts.Section)
}
result[i] = snippet
}
return MatchOutput{
Snippets: result,
FilenameFallback: usedFallback,
}
}
// SectionHeadings returns the headings present in a snippet.
func SectionHeadings(s *Snippet) []string {
if len(s.sections) == 0 {
return nil
}
headings := make([]string, 0, len(s.sections))
for _, sec := range s.sections {
headings = append(headings, sec.heading)
}
return headings
}
// CompactContent extracts mostly structured content from a snippet.
func CompactContent(s *Snippet) string {
if len(s.sections) == 0 {
return extractStructured("", s.Content)
}
var parts []string
if preamble := preambleText(s.Content, s.sections[0]); preamble != "" {
if blocks := extractStructured("", preamble); blocks != "" {
parts = append(parts, blocks)
}
}
for _, sec := range s.sections {
if blocks := extractStructured(sec.heading, sec.body); blocks != "" {
parts = append(parts, blocks)
}
}
if len(parts) == 0 {
return s.Content
}
return strings.Join(parts, "\n\n")
}
func matchesAnyPath(globs []compiledGlob, paths []string) bool {
for _, path := range paths {
if matchesAnyCompiledGlob(globs, path, strings.Split(path, "/")) {
return true
}
}
return false
}
func countHits(pathHit, tagHit, queryHit bool) int {
hits := 0
if pathHit {
hits++
}
if tagHit {
hits++
}
if queryHit {
hits++
}
return hits
}
func filterSections(s *Snippet, heading string) string {
h := strings.ToLower(heading)
var parts []string
for _, sec := range s.sections {
if strings.Contains(strings.ToLower(sec.heading), h) {
parts = append(parts, sec.body)
}
}
if len(parts) == 0 {
return s.Content
}
return strings.Join(parts, "\n\n")
}
func filenameTokens(path string) []string {
base := filepath.Base(path)
stem := strings.TrimSuffix(base, filepath.Ext(base))
parts := strings.FieldsFunc(stem, func(r rune) bool {
return r == '_' || r == '-' || r == '.'
})
seen := make(map[string]struct{})
var result []string
for _, part := range parts {
for _, token := range splitCamel(part) {
token = strings.ToLower(token)
if len(token) < minTokenLen {
continue
}
if _, ok := seen[token]; ok {
continue
}
seen[token] = struct{}{}
result = append(result, token)
}
}
return result
}
func splitCamel(s string) []string {
runes := []rune(s)
if len(runes) <= 1 {
return []string{s}
}
var words []string
start := 0
for i := 1; i < len(runes); i++ {
curr := unicode.IsUpper(runes[i])
prev := unicode.IsUpper(runes[i-1])
switch {
case curr && !prev:
words = append(words, string(runes[start:i]))
start = i
case !curr && prev && i-start > 1:
words = append(words, string(runes[start:i-1]))
start = i - 1
}
}
words = append(words, string(runes[start:]))
return words
}
func matchFilenameTags(s *Snippet, tokens []string) bool {
hits := 0
for _, token := range tokens {
if _, ok := s.tagSet[token]; ok {
hits++
if hits >= minFilenameTagHits {
return true
}
}
}
return false
}
func snippetMatchesQuery(s *Snippet, queryLower string, queryTokens []string) bool {
if strings.Contains(s.nameLower, queryLower) || strings.Contains(s.descLower, queryLower) {
return true
}
return matchQueryTokens(s.searchTokens, queryTokens)
}
func matchQueryTokens(searchTokens, queryTokens []string) bool {
if len(queryTokens) == 0 {
return false
}
for _, queryToken := range queryTokens {
found := false
for _, searchToken := range searchTokens {
if strings.HasPrefix(searchToken, queryToken) {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func tokenizeForSearch(nameLower, descLower string, tags []string) []string {
combined := nameLower + " " + descLower + " " + strings.Join(tags, " ")
parts := strings.FieldsFunc(combined, isTokenSeparator)
seen := make(map[string]struct{})
var tokens []string
for _, part := range parts {
for _, word := range splitCamel(part) {
word = strings.ToLower(word)
if len(word) < minTokenLen {
continue
}
if _, ok := seen[word]; ok {
continue
}
seen[word] = struct{}{}
tokens = append(tokens, word)
}
}
return tokens
}
func tokenizeQuery(queryLower string) []string {
parts := strings.FieldsFunc(queryLower, isTokenSeparator)
var tokens []string
for _, part := range parts {
if len(part) >= minTokenLen {
tokens = append(tokens, part)
}
}
return tokens
}
func isTokenSeparator(r rune) bool {
return unicode.IsSpace(r) || r == '-' || r == '_' || r == '.'
}
func matchesAnyCompiledGlob(globs []compiledGlob, path string, pathParts []string) bool {
for i := range globs {
if matchCompiledGlob(&globs[i], path, pathParts) {
return true
}
}
return false
}
func matchCompiledGlob(g *compiledGlob, path string, pathParts []string) bool {
if g.simple {
matched, err := filepath.Match(g.raw, path)
if err != nil {
return false
}
return matched
}
if len(g.segParts) == 0 {
return true
}
return matchSegments(pathParts, g.segParts, 0, 0)
}
func matchSegments(pathParts []string, segParts [][]string, pi, si int) bool {
if si >= len(segParts) {
return true
}
sp := segParts[si]
for start := pi; start+len(sp) <= len(pathParts); start++ {
if matchSequence(pathParts, sp, start) && matchSegments(pathParts, segParts, start+len(sp), si+1) {
return true
}
}
return false
}
func matchSequence(pathParts, segParts []string, offset int) bool {
for i, part := range segParts {
matched, err := filepath.Match(part, pathParts[offset+i])
if err != nil || !matched {
return false
}
}
return true
}
func hasAllTags(tagSet map[string]struct{}, required []string) bool {
for _, tag := range required {
if _, ok := tagSet[tag]; !ok {
return false
}
}
return true
}
func hasAnyTag(tagSet map[string]struct{}, candidates []string) bool {
for _, tag := range candidates {
if _, ok := tagSet[tag]; ok {
return true
}
}
return false
}
func preambleText(content string, firstSection section) string {
prefix := "## " + firstSection.heading
idx := strings.Index(content, prefix)
if idx <= 0 {
prefix = "### " + firstSection.heading
idx = strings.Index(content, prefix)
}
if idx <= 0 {
return ""
}
return strings.TrimSpace(content[:idx])
}
func extractStructured(heading, text string) string {
lines := strings.Split(text, "\n")
var parts []string
var block []string
inFence := false
headingWritten := false
writeHeading := func() {
if heading != "" && !headingWritten {
parts = append(parts, "// "+heading)
headingWritten = true
}
}
flushTable := func() {
if len(block) == 0 {
return
}
writeHeading()
parts = append(parts, strings.Join(block, "\n"))
block = nil
}
for _, line := range lines {
isFence := strings.HasPrefix(line, "```")
isTableRow := !inFence && strings.HasPrefix(line, "|")
switch {
case isFence && !inFence:
flushTable()
inFence = true
block = []string{line}
case isFence && inFence:
block = append(block, line)
inFence = false
writeHeading()
parts = append(parts, strings.Join(block, "\n"))
block = nil
case inFence:
block = append(block, line)
case isTableRow:
block = append(block, line)
default:
flushTable()
}
}
flushTable()
return strings.Join(parts, "\n\n")
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
// CachePolicy declares how a tool's results should be cached.
//
// Cacheable tools (typically read-only) implement the Cacheable interface and
// return a non-zero CachePolicy. Write tools may declare themselves as
// invalidators of read-tool entries via the Invalidators field of the readers,
// and the registry uses that index to drop stale entries on write.
type CachePolicy struct {
// Cacheable is true if results of this tool can be cached.
Cacheable bool
// KeyFunc builds a deterministic cache key from the call params. If nil,
// the registry falls back to a generic JSON-based key.
KeyFunc func(params map[string]any) string
// PathParams lists param keys that resolve to filesystem paths. The registry
// uses these both for mtime-based invalidation (re-stat on hit) and for
// write-driven invalidation (a write to one of these paths drops the entry).
PathParams []string
// Invalidators lists tool names that invalidate this entry when they run.
// This is the read-tool's view: "if any of these write tools fires for one
// of my PathParams, drop me".
Invalidators []string
}
// Cacheable is implemented by tools that opt into result caching.
type Cacheable interface {
Tool
CachePolicy() CachePolicy
}
// cacheEntry stores a tool result together with the mtimes of its dependency
// paths so the registry can detect on-disk changes.
type cacheEntry struct {
result *ToolResult
paths []string
mtimes map[string]time.Time
createdAt time.Time
}
// ResultCache is a thread-safe per-session cache of tool results.
//
// It is safe for concurrent use by multiple sub-agents sharing the same
// orchestrator session. Entries are keyed by tool name plus a policy-supplied
// key. On hit, the registry re-stats the dependency paths to make sure they
// have not changed since the entry was stored.
type ResultCache struct {
mu sync.RWMutex
entries map[string]cacheEntry
pathIdx map[string]map[string]struct{} // path -> set of cache keys depending on it
hits atomic.Int64
misses atomic.Int64
stores atomic.Int64
invalids atomic.Int64
}
// NewResultCache creates an empty cache.
func NewResultCache() *ResultCache {
return &ResultCache{
entries: make(map[string]cacheEntry),
pathIdx: make(map[string]map[string]struct{}),
}
}
// Hits returns the total number of cache hits.
func (c *ResultCache) Hits() int64 { return c.hits.Load() }
// Misses returns the total number of cache misses.
func (c *ResultCache) Misses() int64 { return c.misses.Load() }
// Stores returns the total number of entries written to the cache.
func (c *ResultCache) Stores() int64 { return c.stores.Load() }
// Invalidations returns the total number of entries dropped due to writes.
func (c *ResultCache) Invalidations() int64 { return c.invalids.Load() }
// HitRate returns the cache hit ratio in [0, 1]. Returns 0 if there have been
// no lookups yet.
func (c *ResultCache) HitRate() float64 {
h := c.hits.Load()
m := c.misses.Load()
total := h + m
if total == 0 {
return 0
}
return float64(h) / float64(total)
}
// Get returns a cached result if present and still valid (paths unchanged).
// Returns ok=false on miss or staleness; staleness causes the entry to be
// dropped from the cache.
func (c *ResultCache) Get(key string) (*ToolResult, bool) {
c.mu.RLock()
entry, ok := c.entries[key]
c.mu.RUnlock()
if !ok {
c.misses.Add(1)
return nil, false
}
// Re-stat all dependency paths.
for _, p := range entry.paths {
want := entry.mtimes[p]
info, err := os.Stat(p)
if err != nil || !info.ModTime().Equal(want) {
// Stale: drop and report miss.
c.dropKey(key)
c.misses.Add(1)
return nil, false
}
}
c.hits.Add(1)
return entry.result, true
}
// Put stores a result for the given key, recording the mtimes of the paths it
// depends on so future lookups can validate freshness.
//
// Resolved paths are absolute filesystem paths.
func (c *ResultCache) Put(key string, result *ToolResult, resolvedPaths []string) {
mtimes := make(map[string]time.Time, len(resolvedPaths))
for _, p := range resolvedPaths {
info, err := os.Stat(p)
if err != nil {
// If we cannot stat a dependency, skip caching: we would not be
// able to validate freshness later.
return
}
mtimes[p] = info.ModTime()
}
c.mu.Lock()
c.entries[key] = cacheEntry{
result: result,
paths: append([]string(nil), resolvedPaths...),
mtimes: mtimes,
createdAt: time.Now(),
}
for _, p := range resolvedPaths {
set, ok := c.pathIdx[p]
if !ok {
set = make(map[string]struct{})
c.pathIdx[p] = set
}
set[key] = struct{}{}
}
c.mu.Unlock()
c.stores.Add(1)
}
// InvalidatePath drops every cache entry that depends on the given path. It
// also walks parent directories so that operations like delete_dir or move on
// a containing directory invalidate everything below them.
//
// path should be an absolute filesystem path.
func (c *ResultCache) InvalidatePath(path string) {
if path == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
// Direct match.
if set, ok := c.pathIdx[path]; ok {
for k := range set {
c.dropKeyLocked(k)
}
}
// Prefix match: anything under `path` (treat as directory) is also stale.
prefix := path
if !strings.HasSuffix(prefix, string(filepath.Separator)) {
prefix += string(filepath.Separator)
}
for p, set := range c.pathIdx {
if strings.HasPrefix(p, prefix) {
for k := range set {
c.dropKeyLocked(k)
}
}
}
}
// Clear removes every entry from the cache.
func (c *ResultCache) Clear() {
c.mu.Lock()
c.entries = make(map[string]cacheEntry)
c.pathIdx = make(map[string]map[string]struct{})
c.mu.Unlock()
}
func (c *ResultCache) dropKey(key string) {
c.mu.Lock()
c.dropKeyLocked(key)
c.mu.Unlock()
}
func (c *ResultCache) dropKeyLocked(key string) {
entry, ok := c.entries[key]
if !ok {
return
}
delete(c.entries, key)
for _, p := range entry.paths {
if set, ok := c.pathIdx[p]; ok {
delete(set, key)
if len(set) == 0 {
delete(c.pathIdx, p)
}
}
}
c.invalids.Add(1)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"fmt"
"strings"
)
const (
maxDiffInputLines = 5000 // skip diff when combined line count exceeds this
diffLookahead = 10 // lines to look ahead when finding common lines
)
// LineStats computes the number of added and removed lines between old and new content.
func LineStats(oldContent, newContent string) (added, removed int) {
oldLines := splitLines(oldContent)
newLines := splitLines(newContent)
oldSet := make(map[string]int, len(oldLines))
for _, l := range oldLines {
oldSet[l]++
}
newSet := make(map[string]int, len(newLines))
for _, l := range newLines {
newSet[l]++
}
for line, count := range oldSet {
nc := newSet[line]
if count > nc {
removed += count - nc
}
}
for line, count := range newSet {
oc := oldSet[line]
if count > oc {
added += count - oc
}
}
return added, removed
}
// FileActionType returns the action type based on whether the file existed before.
func FileActionType(existed bool) string {
if existed {
return "Update"
}
return "Add"
}
// SimpleDiff generates a compact diff string showing changed lines.
// Returns at most maxLines of output. Uses a simple line-by-line comparison.
func SimpleDiff(oldContent, newContent, path string, maxLines int) string {
oldLines := splitLines(oldContent)
newLines := splitLines(newContent)
if len(oldLines)+len(newLines) > maxDiffInputLines {
return fmt.Sprintf("(diff too large: %d → %d lines)", len(oldLines), len(newLines))
}
// Find changed regions using simple forward scan
type chunk struct {
oldStart int
removed []string
added []string
}
var chunks []chunk
i, j := 0, 0
for i < len(oldLines) && j < len(newLines) {
if oldLines[i] == newLines[j] {
i++
j++
continue
}
// Found a difference — scan forward to find next common line
c := chunk{oldStart: i + 1}
oi, nj := i, j
for oi < len(oldLines) && !containsFrom(newLines, nj, oldLines[oi]) {
c.removed = append(c.removed, oldLines[oi])
oi++
}
for nj < len(newLines) && (oi >= len(oldLines) || newLines[nj] != oldLines[oi]) {
c.added = append(c.added, newLines[nj])
nj++
}
chunks = append(chunks, c)
i, j = oi, nj
}
// Remaining lines
if i < len(oldLines) || j < len(newLines) {
c := chunk{oldStart: i + 1}
for i < len(oldLines) {
c.removed = append(c.removed, oldLines[i])
i++
}
for j < len(newLines) {
c.added = append(c.added, newLines[j])
j++
}
chunks = append(chunks, c)
}
if len(chunks) == 0 {
return ""
}
var b strings.Builder
lines := 0
for _, c := range chunks {
if lines >= maxLines {
remaining := 0
for _, cc := range chunks {
remaining += len(cc.removed) + len(cc.added)
}
b.WriteString(fmt.Sprintf("... (%d more lines)\n", remaining))
break
}
b.WriteString(fmt.Sprintf("@@ line %d @@\n", c.oldStart))
lines++
for _, l := range c.removed {
if lines >= maxLines {
b.WriteString("...\n")
break
}
b.WriteString("-" + l + "\n")
lines++
}
for _, l := range c.added {
if lines >= maxLines {
b.WriteString("...\n")
break
}
b.WriteString("+" + l + "\n")
lines++
}
}
return b.String()
}
func containsFrom(lines []string, start int, target string) bool {
end := start + diffLookahead
if end > len(lines) {
end = len(lines)
}
for k := start; k < end; k++ {
if lines[k] == target {
return true
}
}
return false
}
func splitLines(s string) []string {
if s == "" {
return nil
}
lines := strings.Split(s, "\n")
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
return lines
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"fmt"
"os"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
)
const editDiffMaxLines = 30 // max lines in edit tool diff output
// EditFileTool performs find-and-replace editing in a file.
type EditFileTool struct {
workDir string
forbiddenPatterns []string
}
// NewEditFileTool creates a new edit_file tool.
func NewEditFileTool(workDir string, forbiddenPatterns []string) *EditFileTool {
return &EditFileTool{workDir: workDir, forbiddenPatterns: forbiddenPatterns}
}
func (t *EditFileTool) Name() string { return NameEditFile }
func (t *EditFileTool) Description() string { return "Edit a file by replacing old_str with new_str" }
func (t *EditFileTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"path": {Type: "string", Description: "File path relative to work directory"},
"old_str": {Type: "string", Description: "Text to find and replace"},
"new_str": {Type: "string", Description: "Replacement text"},
},
Required: []string{"path", "old_str", "new_str"},
}
}
// ResolvePaths returns the absolute path edited, used by the registry for
// cache invalidation.
func (t *EditFileTool) ResolvePaths(ctx context.Context, params map[string]any) []string {
p, ok := params["path"].(string)
if !ok || p == "" {
return nil
}
resolved, err := SafePath(ctx, t.workDir, p)
if err != nil {
return nil
}
return []string{resolved}
}
func (t *EditFileTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
path, ok := params["path"].(string)
if !ok {
return nil, &ToolError{Msg: "path must be a string"}
}
oldStr, ok := params["old_str"].(string)
if !ok {
return nil, &ToolError{Msg: "old_str must be a string"}
}
newStr, ok := params["new_str"].(string)
if !ok {
return nil, &ToolError{Msg: "new_str must be a string"}
}
if path == "" || oldStr == "" {
return nil, &ToolError{Msg: "path and old_str are required"}
}
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
if reason := IsPathBlocked(ctx, path, resolved, t.forbiddenPatterns); reason != "" {
return nil, &ToolError{Msg: reason}
}
fi, err := os.Stat(resolved)
if err != nil {
return nil, fmt.Errorf("stat file: %w", err)
}
data, err := os.ReadFile(resolved)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
content := string(data)
count := strings.Count(content, oldStr)
if count == 0 {
return nil, &ToolError{Msg: "old_str not found in file"}
}
newContent := strings.Replace(content, oldStr, newStr, 1)
if err := os.WriteFile(resolved, []byte(newContent), fi.Mode().Perm()); err != nil {
return nil, fmt.Errorf("write file: %w", err)
}
added, removed := LineStats(content, newContent)
diff := SimpleDiff(content, newContent, path, editDiffMaxLines)
msg := fmt.Sprintf("replaced 1 occurrence in %s", path)
if count > 1 {
msg = fmt.Sprintf("replaced 1 of %d occurrences in %s (warning: %d more remain)", count, path, count-1)
}
return &ToolResult{
Output: msg,
Meta: map[string]any{
"action": "Update",
"added": added,
"removed": removed,
"diff": diff,
},
}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"fmt"
"io/fs"
"path/filepath"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
)
// FindFilesTool finds files matching a glob pattern.
type FindFilesTool struct {
workDir string
forbidden []string
}
// NewFindFilesTool creates a new find_files tool.
func NewFindFilesTool(workDir string, forbidden []string) *FindFilesTool {
return &FindFilesTool{workDir: workDir, forbidden: forbidden}
}
func (t *FindFilesTool) Name() string { return NameFindFiles }
func (t *FindFilesTool) Description() string { return "Find files matching a glob pattern" }
func (t *FindFilesTool) IsReadOnly() bool { return true }
func (t *FindFilesTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"pattern": {Type: "string", Description: "Glob pattern (e.g. '**/*.go')"},
"root": {Type: "string", Description: "Root directory for search (default: work dir)"},
},
Required: []string{"pattern"},
}
}
func (t *FindFilesTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
pattern, ok := params["pattern"].(string)
if !ok || pattern == "" {
return nil, &ToolError{Msg: "pattern is required"}
}
var root string
if v, ok := params["root"].(string); ok {
root = v
}
searchRoot := t.workDir
if root != "" {
resolved, err := SafePath(ctx, t.workDir, root)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
searchRoot = resolved
}
var matches []string
err := filepath.WalkDir(searchRoot, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return filepath.SkipDir
}
rel, relErr := filepath.Rel(t.workDir, path)
if relErr != nil {
return filepath.SkipDir
}
if d.IsDir() {
if IsForbiddenDir(ctx, rel, t.forbidden) {
return filepath.SkipDir
}
return nil
}
if IsForbidden(ctx, rel, t.forbidden) {
return nil
}
if matched, matchErr := filepath.Match(pattern, filepath.Base(path)); matchErr == nil && matched {
matches = append(matches, rel)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk dir: %w", err)
}
if len(matches) == 0 {
return &ToolResult{Output: "no files found"}, nil
}
return &ToolResult{Output: strings.Join(matches, "\n")}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"bufio"
"context"
"fmt"
"io/fs"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
)
// GrepTool searches for a pattern in files.
type GrepTool struct {
workDir string
forbidden []string
}
// NewGrepTool creates a new grep tool.
func NewGrepTool(workDir string, forbidden []string) *GrepTool {
return &GrepTool{workDir: workDir, forbidden: forbidden}
}
func (t *GrepTool) Name() string { return NameGrep }
func (t *GrepTool) Description() string { return "Search for a regex pattern in files" }
func (t *GrepTool) IsReadOnly() bool { return true }
func (t *GrepTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"pattern": {Type: "string", Description: "Regex pattern to search for"},
"path": {Type: "string", Description: "File or directory to search in"},
"recursive": {Type: "string", Description: "If 'true', search recursively", Enum: []string{"true", "false"}},
},
Required: []string{"pattern"},
}
}
func (t *GrepTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
pattern, ok := params["pattern"].(string)
if !ok || pattern == "" {
return nil, &ToolError{Msg: "pattern is required"}
}
var path string
if v, ok2 := params["path"].(string); ok2 {
path = v
}
var recursive string
if v, ok2 := params["recursive"].(string); ok2 {
recursive = v
}
re, err := regexp.Compile(pattern)
if err != nil {
return nil, &ToolError{Msg: fmt.Sprintf("invalid regex: %s", err)}
}
searchPath := t.workDir
if path != "" {
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
searchPath = resolved
}
var results []string
maxResults := 100
searchFile := func(filePath string) {
if len(results) >= maxResults {
return
}
f, err := os.Open(filePath)
if err != nil {
return
}
defer f.Close()
rel, relErr := filepath.Rel(t.workDir, filePath)
if relErr != nil {
rel = filePath
}
scanner := bufio.NewScanner(f)
lineNum := 0
for scanner.Scan() {
lineNum++
line := scanner.Text()
if re.MatchString(line) {
results = append(results, fmt.Sprintf("%s:%d:%s", rel, lineNum, line))
if len(results) >= maxResults {
return
}
}
}
// scanner.Err() intentionally ignored: partial results are acceptable for grep.
}
info, err := os.Stat(searchPath)
if err != nil {
return nil, fmt.Errorf("stat path: %w", err)
}
switch {
case !info.IsDir():
searchFile(searchPath)
case recursive == boolTrue:
if walkErr := filepath.WalkDir(searchPath, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return filepath.SkipDir
}
if len(results) >= maxResults {
return filepath.SkipAll
}
rel, relErr := filepath.Rel(t.workDir, p)
if relErr != nil {
return filepath.SkipDir
}
if d.IsDir() {
if IsForbiddenDir(ctx, rel, t.forbidden) {
return filepath.SkipDir
}
return nil
}
if IsForbidden(ctx, rel, t.forbidden) {
return nil
}
searchFile(p)
return nil
}); walkErr != nil {
return nil, fmt.Errorf("walk dir: %w", walkErr)
}
default:
entries, readErr := os.ReadDir(searchPath)
if readErr != nil {
return nil, fmt.Errorf("read dir: %w", readErr)
}
for _, e := range entries {
if e.IsDir() || len(results) >= maxResults {
continue
}
name := e.Name()
if IsForbidden(ctx, name, t.forbidden) {
continue
}
searchFile(filepath.Join(searchPath, name))
}
}
if len(results) == 0 {
return &ToolResult{Output: "no matches found"}, nil
}
return &ToolResult{Output: strings.Join(results, "\n")}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
)
// ListDirTool lists files in a directory.
type ListDirTool struct {
workDir string
forbidden []string
}
// NewListDirTool creates a new list_dir tool.
func NewListDirTool(workDir string, forbidden []string) *ListDirTool {
return &ListDirTool{workDir: workDir, forbidden: forbidden}
}
func (t *ListDirTool) Name() string { return NameListDir }
func (t *ListDirTool) Description() string { return "List files and directories in a path" }
func (t *ListDirTool) IsReadOnly() bool { return true }
func (t *ListDirTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"path": {Type: "string", Description: "Directory path relative to work directory"},
"recursive": {Type: "string", Description: "If 'true', list recursively", Enum: []string{"true", "false"}},
},
Required: []string{"path"},
}
}
func (t *ListDirTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
path, ok := params["path"].(string)
if !ok || path == "" {
path = "."
}
var recursive string
if v, ok2 := params["recursive"].(string); ok2 {
recursive = v
}
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
var entries []string
if recursive == "true" {
entries, err = t.walkRecursive(ctx, resolved)
} else {
entries, err = t.listFlat(ctx, resolved)
}
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return &ToolResult{Output: fmt.Sprintf("Directory does not exist: %s", path)}, nil
}
return nil, err
}
return &ToolResult{Output: strings.Join(entries, "\n")}, nil
}
func (t *ListDirTool) walkRecursive(ctx context.Context, root string) ([]string, error) {
var entries []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return filepath.SkipDir
}
rel, relErr := filepath.Rel(t.workDir, p)
if relErr != nil {
return filepath.SkipDir
}
switch {
case d.IsDir() && IsForbiddenDir(ctx, rel, t.forbidden):
return filepath.SkipDir
case d.IsDir():
entries = append(entries, rel+"/")
case !IsForbidden(ctx, rel, t.forbidden):
entries = append(entries, rel)
}
return nil
})
if err != nil {
return nil, fmt.Errorf("walk dir: %w", err)
}
return entries, nil
}
func (t *ListDirTool) listFlat(ctx context.Context, resolved string) ([]string, error) {
dirEntries, err := os.ReadDir(resolved)
if err != nil {
return nil, fmt.Errorf("read dir: %w", err)
}
entries := make([]string, 0, len(dirEntries))
for _, e := range dirEntries {
name := e.Name()
switch {
case e.IsDir() && IsForbiddenDir(ctx, name, t.forbidden):
continue
case e.IsDir():
name += "/"
case IsForbidden(ctx, name, t.forbidden):
continue
}
entries = append(entries, name)
}
return entries, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"github.com/raoptimus/kodrun/internal/llm"
)
const (
dirPermission = 0o755
movePathCap = 2 // from + to paths
boolTrue = "true"
)
// stringParam safely extracts a string value from a map[string]any.
func stringParam(m map[string]any, key string) string {
if v, ok := m[key].(string); ok {
return v
}
return ""
}
// boolParam safely extracts a bool value from a map[string]any.
func boolParam(m map[string]any, key string) bool {
v, ok := m[key].(bool)
return ok && v
}
// DeleteFileTool deletes a file.
type DeleteFileTool struct {
workDir string
forbiddenPatterns []string
}
// NewDeleteFileTool creates a new delete_file tool.
func NewDeleteFileTool(workDir string, forbiddenPatterns []string) *DeleteFileTool {
return &DeleteFileTool{workDir: workDir, forbiddenPatterns: forbiddenPatterns}
}
func (t *DeleteFileTool) Name() string { return NameDeleteFile }
func (t *DeleteFileTool) Description() string { return "Delete a file" }
func (t *DeleteFileTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"path": {Type: "string", Description: "File path relative to work directory"},
},
Required: []string{"path"},
}
}
func (t *DeleteFileTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
path, ok := params["path"].(string)
if !ok || path == "" {
return nil, &ToolError{Msg: "path is required"}
}
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
if reason := IsPathBlocked(ctx, path, resolved, t.forbiddenPatterns); reason != "" {
return nil, &ToolError{Msg: reason}
}
if err := os.Remove(resolved); err != nil {
if errors.Is(err, os.ErrNotExist) {
return &ToolResult{Output: fmt.Sprintf("File does not exist: %s", path)}, nil
}
return nil, fmt.Errorf("delete file: %w", err)
}
return &ToolResult{
Output: fmt.Sprintf("deleted %s", path),
Meta: map[string]any{"action": "Delete"},
}, nil
}
// CreateDirTool creates a directory.
type CreateDirTool struct {
workDir string
forbiddenPatterns []string
}
// NewCreateDirTool creates a new create_dir tool.
func NewCreateDirTool(workDir string, forbiddenPatterns []string) *CreateDirTool {
return &CreateDirTool{workDir: workDir, forbiddenPatterns: forbiddenPatterns}
}
func (t *CreateDirTool) Name() string { return NameCreateDir }
func (t *CreateDirTool) Description() string { return "Create a directory (with parents)" }
func (t *CreateDirTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"path": {Type: "string", Description: "Directory path relative to work directory"},
},
Required: []string{"path"},
}
}
func (t *CreateDirTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
path, ok := params["path"].(string)
if !ok || path == "" {
return nil, &ToolError{Msg: "path is required"}
}
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
if reason := IsPathBlocked(ctx, path, resolved, t.forbiddenPatterns); reason != "" {
return nil, &ToolError{Msg: reason}
}
if err := os.MkdirAll(resolved, dirPermission); err != nil {
return nil, fmt.Errorf("create dir: %w", err)
}
return &ToolResult{
Output: fmt.Sprintf("created %s", path),
Meta: map[string]any{"action": "Add"},
}, nil
}
// ResolvePaths returns the absolute path of the file being deleted.
func (t *DeleteFileTool) ResolvePaths(ctx context.Context, params map[string]any) []string {
p := stringParam(params, "path")
if p == "" {
return nil
}
resolved, err := SafePath(ctx, t.workDir, p)
if err != nil {
return nil
}
return []string{resolved}
}
// MoveFileTool moves/renames a file.
type MoveFileTool struct {
workDir string
forbiddenPatterns []string
}
// NewMoveFileTool creates a new move_file tool.
func NewMoveFileTool(workDir string, forbiddenPatterns []string) *MoveFileTool {
return &MoveFileTool{workDir: workDir, forbiddenPatterns: forbiddenPatterns}
}
func (t *MoveFileTool) Name() string { return NameMoveFile }
func (t *MoveFileTool) Description() string { return "Move or rename a file" }
func (t *MoveFileTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"from": {Type: "string", Description: "Source file path"},
"to": {Type: "string", Description: "Destination file path"},
},
Required: []string{"from", "to"},
}
}
// ResolvePaths returns the absolute paths affected by the move (both source
// and destination so the cache invalidates entries on either side).
func (t *MoveFileTool) ResolvePaths(ctx context.Context, params map[string]any) []string {
from := stringParam(params, "from")
to := stringParam(params, "to")
out := make([]string, 0, movePathCap)
if from != "" {
if r, err := SafePath(ctx, t.workDir, from); err == nil {
out = append(out, r)
}
}
if to != "" {
if r, err := SafePath(ctx, t.workDir, to); err == nil {
out = append(out, r)
}
}
return out
}
func (t *MoveFileTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
from := stringParam(params, "from")
to := stringParam(params, "to")
if from == "" || to == "" {
return nil, &ToolError{Msg: "from and to are required"}
}
resolvedFrom, err := SafePath(ctx, t.workDir, from)
if err != nil {
return nil, fmt.Errorf("resolve source path: %w", err)
}
resolvedTo, err := SafePath(ctx, t.workDir, to)
if err != nil {
return nil, fmt.Errorf("resolve destination path: %w", err)
}
if reason := IsPathBlocked(ctx, from, resolvedFrom, t.forbiddenPatterns); reason != "" {
return nil, &ToolError{Msg: reason}
}
if reason := IsPathBlocked(ctx, to, resolvedTo, t.forbiddenPatterns); reason != "" {
return nil, &ToolError{Msg: reason}
}
// Ensure the destination directory exists.
if dir := filepath.Dir(resolvedTo); dir != "" {
if err := os.MkdirAll(dir, dirPermission); err != nil {
return nil, fmt.Errorf("create destination dir: %w", err)
}
}
// Use git mv when the project is a git repository to preserve file
// history. Fall back to os.Rename when git is not available.
if isGitRepo(t.workDir) {
cmd := exec.CommandContext(ctx, "git", "mv", resolvedFrom, resolvedTo)
cmd.Dir = t.workDir
if out, err := cmd.CombinedOutput(); err != nil {
return nil, fmt.Errorf("git mv: %s (%w)", string(out), err)
}
} else {
if err := os.Rename(resolvedFrom, resolvedTo); err != nil {
return nil, fmt.Errorf("rename: %w", err)
}
}
return &ToolResult{
Output: fmt.Sprintf("moved %s → %s", from, to),
Meta: map[string]any{"action": "Rename"},
}, nil
}
// isGitRepo checks whether workDir is inside a git repository by looking for
// a .git directory or file (submodule). Walks up to 10 parent directories.
func isGitRepo(workDir string) bool {
dir := workDir
for range 10 {
if info, err := os.Stat(filepath.Join(dir, ".git")); err == nil {
_ = info
return true
}
parent := filepath.Dir(dir)
if parent == dir {
break
}
dir = parent
}
return false
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"errors"
"fmt"
"os"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
)
const defaultMaxLines = 500
// ReadFileTool reads a file's contents with optional offset/limit pagination.
type ReadFileTool struct {
workDir string
forbiddenPatterns []string
maxLines int
}
// NewReadFileTool creates a new read_file tool.
func NewReadFileTool(workDir string, forbiddenPatterns []string, maxLines int) *ReadFileTool {
if maxLines <= 0 {
maxLines = defaultMaxLines
}
return &ReadFileTool{workDir: workDir, forbiddenPatterns: forbiddenPatterns, maxLines: maxLines}
}
func (t *ReadFileTool) Name() string { return NameReadFile }
func (t *ReadFileTool) Description() string { return "Read the contents of a file" }
func (t *ReadFileTool) IsReadOnly() bool { return true }
func (t *ReadFileTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"path": {Type: "string", Description: "File path relative to work directory"},
"offset": {Type: "integer", Description: "Start line (0-based). Default: 0"},
"limit": {Type: "integer", Description: "Max lines to read. Default: 500"},
},
Required: []string{"path"},
}
}
func (t *ReadFileTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
path := stringParam(params, "path")
if path == "" {
return nil, &ToolError{Msg: "path is required"}
}
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
if reason := IsPathBlocked(ctx, path, resolved, t.forbiddenPatterns); reason != "" {
return nil, &ToolError{Msg: reason}
}
info, err := os.Stat(resolved)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return &ToolResult{Output: fmt.Sprintf("File does not exist: %s", path)}, nil
}
return nil, fmt.Errorf("read file: %w", err)
}
if info.IsDir() {
return nil, &ToolError{Msg: fmt.Sprintf("%s is a directory, use list_dir(path=%q) instead", path, path)}
}
data, err := os.ReadFile(resolved)
if err != nil {
return nil, fmt.Errorf("read file: %w", err)
}
lines := strings.Split(string(data), "\n")
totalLines := len(lines)
offset := toInt(params["offset"], 0)
limit := toInt(params["limit"], t.maxLines)
if offset < 0 {
offset = 0
}
if offset >= totalLines {
return nil, &ToolError{Msg: fmt.Sprintf("offset %d beyond file end (%d lines)", offset, totalLines)}
}
end := min(offset+limit, totalLines)
truncated := end < totalLines
var buf strings.Builder
for i := offset; i < end; i++ {
fmt.Fprintf(&buf, "%4d | %s\n", i+1, lines[i])
}
output := buf.String()
if truncated {
output += fmt.Sprintf(
"\n[truncated] Showing lines %d-%d of %d total.\n"+
"Use read_file with offset/limit to read other sections.\n"+
"Example: read_file(path=%q, offset=%d, limit=%d)\n",
offset+1, end, totalLines,
path, end, limit,
)
}
return &ToolResult{
Output: output,
Meta: map[string]any{
"total_lines": totalLines,
"offset": offset,
"limit": limit,
"truncated": truncated,
},
}, nil
}
// CachePolicy declares read_file results as cacheable, keyed by path/offset/limit
// and invalidated by any write to the same path.
func (t *ReadFileTool) CachePolicy() CachePolicy {
return CachePolicy{
Cacheable: true,
PathParams: []string{"path"},
Invalidators: []string{"write_file", "edit_file", "delete_file", "move_file"},
}
}
// ResolvePaths returns the absolute filesystem path the call depends on.
func (t *ReadFileTool) ResolvePaths(ctx context.Context, params map[string]any) []string {
path := stringParam(params, "path")
if path == "" {
return nil
}
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil
}
return []string{resolved}
}
// toInt extracts an integer from a param value with a fallback default.
func toInt(v any, def int) int {
switch n := v.(type) {
case float64:
return int(n)
case int:
return n
case int64:
return int(n)
default:
return def
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"errors"
"fmt"
"os/exec"
"path/filepath"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/projectlang"
)
const (
// diffContextLines is the number of context lines around each diff hunk.
diffContextLines = 20
// minDiffStatusLineLen is the minimum length of a git diff --stat status line
// (e.g. " M file.go" has a 3-char prefix plus at least 1 char for the path).
minDiffStatusLineLen = 4
)
// codeExtensions maps project language to file extensions that are relevant
// for code review. Common config extensions are shared across all languages.
var codeExtensions = map[projectlang.Language]map[string]bool{
projectlang.LangGo: {
".go": true, ".mod": true,
".yaml": true, ".yml": true, ".toml": true, ".json": true,
".sql": true, ".proto": true, ".graphql": true,
".sh": true, ".bash": true,
".dockerfile": true,
},
projectlang.LangPython: {
".py": true, ".pyi": true, ".pyx": true,
".yaml": true, ".yml": true, ".toml": true, ".json": true, ".cfg": true, ".ini": true,
".sql": true, ".graphql": true,
".sh": true, ".bash": true,
".dockerfile": true,
},
projectlang.LangJSTS: {
".js": true, ".jsx": true, ".ts": true, ".tsx": true, ".mjs": true, ".cjs": true,
".vue": true, ".svelte": true,
".yaml": true, ".yml": true, ".json": true,
".css": true, ".scss": true, ".less": true,
".html": true, ".graphql": true,
".sql": true, ".sh": true, ".bash": true,
".dockerfile": true,
},
}
// fallbackCodeExtensions is used when language is unknown.
var fallbackCodeExtensions = map[string]bool{
".go": true, ".py": true, ".js": true, ".ts": true, ".jsx": true, ".tsx": true,
".java": true, ".rs": true, ".c": true, ".cpp": true, ".h": true,
".yaml": true, ".yml": true, ".toml": true, ".json": true,
".sql": true, ".proto": true, ".graphql": true,
".sh": true, ".bash": true, ".dockerfile": true,
}
// ReadChangedFilesTool returns the unified diff with extended context for all
// git-changed source code files. This gives code-review specialists the
// changed lines plus surrounding context without reading entire files.
type ReadChangedFilesTool struct {
workDir string
langState *projectlang.State
}
// NewReadChangedFilesTool creates a new read_changed_files tool.
func NewReadChangedFilesTool(workDir string, langState *projectlang.State) *ReadChangedFilesTool {
return &ReadChangedFilesTool{workDir: workDir, langState: langState}
}
func (t *ReadChangedFilesTool) Name() string { return NameReadChangedFiles }
func (t *ReadChangedFilesTool) IsReadOnly() bool { return true }
func (t *ReadChangedFilesTool) Description() string {
return "Read diff with context for all git-changed source code files. " +
"Returns unified diff with surrounding context lines. " +
"Use read_file(path) if you need the full content of a specific file."
}
func (t *ReadChangedFilesTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{},
}
}
func (t *ReadChangedFilesTool) Execute(ctx context.Context, _ map[string]any) (*ToolResult, error) {
files, err := t.changedFiles(ctx)
if err != nil {
return nil, fmt.Errorf("git diff: %w", err)
}
if len(files) == 0 {
return &ToolResult{Output: "No changed code files found."}, nil
}
// Try git diff HEAD first (unstaged + staged vs HEAD), then --cached
// (staged only, for unborn branch), then collect untracked files that
// have no diff at all.
uCtx := fmt.Sprintf("-U%d", diffContextLines)
args := []string{"diff", uCtx, "HEAD", "--"}
args = append(args, files...)
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = t.workDir
out, err := cmd.Output()
if err != nil {
// Fallback for unborn branch.
args[2] = "--cached"
cmd = exec.CommandContext(ctx, "git", args...)
cmd.Dir = t.workDir
out, err = cmd.Output()
if err != nil {
return nil, fmt.Errorf("git diff: %w", err)
}
}
diff := strings.TrimSpace(string(out))
// For untracked files (shown as ?? in porcelain), git diff HEAD produces
// nothing. Generate a synthetic diff via --no-index so the reviewer sees
// the full content of new files.
if diff == "" || !allFilesInDiff(diff, files) {
for _, f := range files {
if diff != "" && strings.Contains(diff, "diff --git a/"+f+" ") {
continue
}
nCmd := exec.CommandContext(ctx, "git", "diff", "--no-index", uCtx, "/dev/null", f)
nCmd.Dir = t.workDir
nOut, nErr := nCmd.Output()
if nErr != nil {
// git diff --no-index returns exit code 1 when files differ; ignore that.
var exitErr *exec.ExitError
if !errors.As(nErr, &exitErr) {
continue
}
}
if len(nOut) > 0 {
if diff != "" {
diff += "\n"
}
diff += strings.TrimSpace(string(nOut))
}
}
}
if diff == "" {
return &ToolResult{Output: "No changes in source code files."}, nil
}
return &ToolResult{
Output: diff,
Meta: map[string]any{
"files": len(files),
"file_list": files,
},
}, nil
}
// allFilesInDiff returns true when every file in the list appears as a
// "diff --git a/<file>" header in the unified diff output.
func allFilesInDiff(diff string, files []string) bool {
for _, f := range files {
if !strings.Contains(diff, "diff --git a/"+f+" ") {
return false
}
}
return true
}
// CachePolicy implements the Cacheable interface.
func (t *ReadChangedFilesTool) CachePolicy() CachePolicy {
return CachePolicy{
Cacheable: true,
Invalidators: []string{"write_file", "edit_file", "delete_file", "move_file"},
}
}
// changedFiles returns the list of changed code files (modified, staged,
// untracked), filtered by language-specific extension whitelist and excluding
// hidden directories. Uses `git status --porcelain=v1` to catch all git
// states including untracked files that `git diff --name-only HEAD` misses.
func (t *ReadChangedFilesTool) changedFiles(ctx context.Context) ([]string, error) {
cmd := exec.CommandContext(ctx, "git", "status", "--porcelain=v1")
cmd.Dir = t.workDir
out, err := cmd.Output()
if err != nil {
return nil, err
}
allowed := fallbackCodeExtensions
if t.langState != nil {
if exts, ok := codeExtensions[t.langState.Current()]; ok {
allowed = exts
}
}
seen := make(map[string]bool)
lines := strings.Split(strings.TrimSpace(string(out)), "\n")
fileList := make([]string, 0, len(lines))
for _, line := range lines {
if len(line) < minDiffStatusLineLen {
continue
}
path := strings.TrimSpace(line[3:])
// Handle rename: "old -> new"
if idx := strings.Index(path, " -> "); idx >= 0 {
path = path[idx+4:]
}
if path == "" || seen[path] {
continue
}
if strings.HasPrefix(path, ".") {
continue
}
ext := strings.ToLower(filepath.Ext(path))
if !allowed[ext] {
continue
}
seen[path] = true
fileList = append(fileList, path)
}
return fileList, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"bytes"
"context"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/raoptimus/kodrun/internal/llm"
)
const extBash = NameBash
// FileStatTool returns file metadata without reading its contents.
type FileStatTool struct {
workDir string
forbiddenPatterns []string
}
// NewFileStatTool creates a new file_stat tool.
func NewFileStatTool(workDir string, forbiddenPatterns []string) *FileStatTool {
return &FileStatTool{workDir: workDir, forbiddenPatterns: forbiddenPatterns}
}
func (t *FileStatTool) Name() string { return NameFileStat }
func (t *FileStatTool) Description() string {
return "Get file metadata (size, lines, modified date) without reading contents"
}
func (t *FileStatTool) IsReadOnly() bool { return true }
func (t *FileStatTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"path": {Type: "string", Description: "File path relative to work directory"},
},
Required: []string{"path"},
}
}
func (t *FileStatTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
path := stringParam(params, "path")
if path == "" {
return nil, &ToolError{Msg: "path is required"}
}
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
if reason := IsPathBlocked(ctx, path, resolved, t.forbiddenPatterns); reason != "" {
return nil, &ToolError{Msg: reason}
}
start := time.Now()
info, err := os.Stat(resolved)
if err != nil {
return nil, fmt.Errorf("stat file: %w", err)
}
if info.IsDir() {
return nil, &ToolError{Msg: fmt.Sprintf("%s is a directory, not a file", path)}
}
totalLines := 0
data, err := os.ReadFile(resolved)
if err == nil {
totalLines = bytes.Count(data, []byte{'\n'})
if len(data) > 0 && data[len(data)-1] != '\n' {
totalLines++
}
}
duration := time.Since(start)
ext := strings.TrimPrefix(filepath.Ext(path), ".")
lang := langFromExt(ext)
output := fmt.Sprintf("path: %s\nsize_bytes: %d\ntotal_lines: %d\nmodified: %s\next: %s\nlanguage: %s",
path, info.Size(), totalLines, info.ModTime().Format(time.RFC3339), ext, lang)
return &ToolResult{
Output: output,
Meta: map[string]any{
"size_bytes": info.Size(),
"total_lines": totalLines,
"ext": ext,
"language": lang,
"duration": duration.String(),
},
}, nil
}
// CachePolicy declares file_stat results as cacheable, keyed by path
// and invalidated by any write to the same path.
func (t *FileStatTool) CachePolicy() CachePolicy {
return CachePolicy{
Cacheable: true,
PathParams: []string{"path"},
Invalidators: []string{"write_file", "edit_file", "delete_file", "move_file"},
}
}
// ResolvePaths returns the absolute filesystem path the call depends on.
func (t *FileStatTool) ResolvePaths(ctx context.Context, params map[string]any) []string {
path := stringParam(params, "path")
if path == "" {
return nil
}
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil
}
return []string{resolved}
}
func langFromExt(ext string) string {
switch strings.ToLower(ext) {
case "go":
return "Go"
case "py":
return "Python"
case "js":
return "JavaScript"
case "ts":
return "TypeScript"
case "tsx":
return "TypeScript (JSX)"
case "jsx":
return "JavaScript (JSX)"
case "rs":
return "Rust"
case "java":
return "Java"
case "rb":
return "Ruby"
case "c", "h":
return "C"
case "cpp", "cc", "cxx", "hpp":
return "C++"
case "cs":
return "C#"
case "yaml", "yml":
return "YAML"
case "json":
return "JSON"
case "md":
return "Markdown"
case "sql":
return "SQL"
case "sh", extBash:
return "Shell"
case "proto":
return "Protobuf"
default:
return ext
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/raoptimus/kodrun/internal/llm"
)
const (
writeDiffMaxLines = 30 // max lines in write tool diff output
newFilePermission = 0o600 // permissions for newly created files
)
// WriteFileTool writes content to a file.
type WriteFileTool struct {
workDir string
forbiddenPatterns []string
}
// NewWriteFileTool creates a new write_file tool.
func NewWriteFileTool(workDir string, forbiddenPatterns []string) *WriteFileTool {
return &WriteFileTool{workDir: workDir, forbiddenPatterns: forbiddenPatterns}
}
func (t *WriteFileTool) Name() string { return NameWriteFile }
func (t *WriteFileTool) Description() string {
return "Write content to a file, creating directories as needed"
}
func (t *WriteFileTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"path": {Type: "string", Description: "File path relative to work directory"},
"content": {Type: "string", Description: "File content to write"},
},
Required: []string{"path", "content"},
}
}
// ResolvePaths returns the absolute path that this write affects, used by the
// registry to invalidate dependent cache entries.
func (t *WriteFileTool) ResolvePaths(ctx context.Context, params map[string]any) []string {
p := stringParam(params, "path")
if p == "" {
return nil
}
resolved, err := SafePath(ctx, t.workDir, p)
if err != nil {
return nil
}
return []string{resolved}
}
func (t *WriteFileTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
path := stringParam(params, "path")
content := stringParam(params, "content")
if path == "" {
return nil, &ToolError{Msg: "path is required"}
}
resolved, err := SafePath(ctx, t.workDir, path)
if err != nil {
return nil, fmt.Errorf("resolve path: %w", err)
}
if reason := IsPathBlocked(ctx, path, resolved, t.forbiddenPatterns); reason != "" {
return nil, &ToolError{Msg: reason}
}
// Read old content for diff
var oldContent string
existed := false
if data, readErr := os.ReadFile(resolved); readErr == nil {
oldContent = string(data)
existed = true
}
dir := filepath.Dir(resolved)
if err := os.MkdirAll(dir, dirPermission); err != nil {
return nil, fmt.Errorf("create directory: %w", err)
}
if err := os.WriteFile(resolved, []byte(content), newFilePermission); err != nil {
return nil, fmt.Errorf("write file: %w", err)
}
action := FileActionType(existed)
added, removed := LineStats(oldContent, content)
meta := map[string]any{
"action": action,
"added": added,
"removed": removed,
}
if existed {
meta["diff"] = SimpleDiff(oldContent, content, path, writeDiffMaxLines)
}
return &ToolResult{
Output: fmt.Sprintf("wrote %d bytes to %s", len(content), path),
Meta: meta,
}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strconv"
"strings"
"time"
"github.com/raoptimus/kodrun/internal/llm"
)
const maxGitOutputBytes = 16 * 1024
// gitStatusTool runs git status and shows the current branch.
type gitStatusTool struct {
workDir string
}
func NewGitStatusTool(workDir string) *gitStatusTool {
return &gitStatusTool{workDir: workDir}
}
func (t *gitStatusTool) Name() string { return NameGitStatus }
func (t *gitStatusTool) Description() string { return "Show git status and current branch" }
func (t *gitStatusTool) IsReadOnly() bool { return true }
func (t *gitStatusTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{},
}
}
func (t *gitStatusTool) Execute(ctx context.Context, _ map[string]any) (*ToolResult, error) {
branchCmd := exec.CommandContext(ctx, "git", "branch", "--show-current")
branchCmd.Dir = t.workDir
var branchOut bytes.Buffer
branchCmd.Stdout = &branchOut
if err := branchCmd.Run(); err != nil {
// Not a git repo or other error — proceed without branch info.
branchOut.Reset()
}
statusCmd := exec.CommandContext(ctx, "git", "status", "--porcelain=v1")
statusCmd.Dir = t.workDir
var stdout, stderr bytes.Buffer
statusCmd.Stdout = &stdout
statusCmd.Stderr = &stderr
start := time.Now()
err := statusCmd.Run()
duration := time.Since(start)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return &ToolResult{
Output: stderr.String(),
Meta: map[string]any{"exit_code": exitErr.ExitCode(), "duration": duration.String()},
}, nil
}
return nil, fmt.Errorf("git status: %w", err)
}
output := fmt.Sprintf("Branch: %s\n%s", strings.TrimSpace(branchOut.String()), stdout.String())
return &ToolResult{
Output: output,
Meta: map[string]any{"exit_code": 0, "duration": duration.String()},
}, nil
}
// gitDiffTool runs git diff with optional staged/ref parameters.
type gitDiffTool struct {
workDir string
}
func NewGitDiffTool(workDir string) *gitDiffTool {
return &gitDiffTool{workDir: workDir}
}
func (t *gitDiffTool) Name() string { return NameGitDiff }
func (t *gitDiffTool) IsReadOnly() bool { return true }
func (t *gitDiffTool) Description() string {
return "Show git diff (optionally staged or against a ref)"
}
func (t *gitDiffTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"staged": {Type: "string", Description: "Show staged changes", Enum: []string{"true", "false"}},
"ref": {Type: "string", Description: "Commit or branch ref to diff against"},
},
}
}
func (t *gitDiffTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
args := []string{"diff"}
if staged := stringParam(params, "staged"); staged == boolTrue {
args = append(args, "--staged")
}
if ref := stringParam(params, "ref"); ref != "" {
args = append(args, ref)
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = t.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
start := time.Now()
err := cmd.Run()
duration := time.Since(start)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return &ToolResult{
Output: stderr.String(),
Meta: map[string]any{"exit_code": exitErr.ExitCode(), "duration": duration.String()},
}, nil
}
return nil, fmt.Errorf("git diff: %w", err)
}
output := stdout.String()
if len(output) > maxGitOutputBytes {
output = output[:maxGitOutputBytes] + "\n... [truncated]"
}
return &ToolResult{
Output: output,
Meta: map[string]any{"exit_code": 0, "duration": duration.String()},
}, nil
}
// gitLogTool runs git log --oneline.
type gitLogTool struct {
workDir string
}
func NewGitLogTool(workDir string) *gitLogTool {
return &gitLogTool{workDir: workDir}
}
func (t *gitLogTool) Name() string { return NameGitLog }
func (t *gitLogTool) Description() string { return "Show recent git commits" }
func (t *gitLogTool) IsReadOnly() bool { return true }
func (t *gitLogTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"count": {Type: "string", Description: "Number of commits to show (default: 10)"},
"path": {Type: "string", Description: "File path to filter log"},
},
}
}
func (t *gitLogTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
count := "10"
if c := stringParam(params, "count"); c != "" {
if _, err := strconv.Atoi(c); err == nil {
count = c
}
}
args := []string{"log", "--oneline", "-n", count}
if path := stringParam(params, "path"); path != "" {
args = append(args, "--", path)
}
cmd := exec.CommandContext(ctx, "git", args...)
cmd.Dir = t.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
start := time.Now()
err := cmd.Run()
duration := time.Since(start)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
return &ToolResult{
Output: stderr.String(),
Meta: map[string]any{"exit_code": exitErr.ExitCode(), "duration": duration.String()},
}, nil
}
return nil, fmt.Errorf("git log: %w", err)
}
return &ToolResult{
Output: stdout.String(),
Meta: map[string]any{"exit_code": 0, "duration": duration.String()},
}, nil
}
// gitCommitTool stages files and creates a git commit.
type gitCommitTool struct {
workDir string
}
func NewGitCommitTool(workDir string) *gitCommitTool {
return &gitCommitTool{workDir: workDir}
}
func (t *gitCommitTool) Name() string { return NameGitCommit }
func (t *gitCommitTool) Description() string { return "Stage files and create a git commit" }
func (t *gitCommitTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"message": {Type: "string", Description: "Commit message"},
"files": {Type: "string", Description: "Space-separated files to stage, or \".\" for all"},
},
Required: []string{"message"},
}
}
func (t *gitCommitTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
message := stringParam(params, "message")
if message == "" {
return nil, &ToolError{Msg: "message is required"}
}
files := stringParam(params, "files")
if files == "" {
files = "."
}
// Stage files.
addArgs := append([]string{"add"}, strings.Fields(files)...)
addCmd := exec.CommandContext(ctx, "git", addArgs...)
addCmd.Dir = t.workDir
var addStderr bytes.Buffer
addCmd.Stderr = &addStderr
if err := addCmd.Run(); err != nil {
return nil, &ToolError{Msg: fmt.Sprintf("git add failed: %s", addStderr.String())}
}
// Commit.
commitCmd := exec.CommandContext(ctx, "git", "commit", "-m", message)
commitCmd.Dir = t.workDir
var stdout, stderr bytes.Buffer
commitCmd.Stdout = &stdout
commitCmd.Stderr = &stderr
start := time.Now()
err := commitCmd.Run()
duration := time.Since(start)
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n"
}
output += stderr.String()
}
return &ToolResult{
Output: output,
Meta: map[string]any{"exit_code": exitErr.ExitCode(), "duration": duration.String()},
}, nil
}
return nil, fmt.Errorf("git commit: %w", err)
}
return &ToolResult{
Output: stdout.String(),
Meta: map[string]any{"exit_code": 0, "duration": duration.String()},
}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"path/filepath"
"sort"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
)
// goStructureTool extracts the structural outline of Go source files
// (types, functions, constants, variables) with line numbers — without
// reading full function bodies. Analogous to "go doc" but for any *.go file.
type goStructureTool struct {
workDir string
}
func (t *goStructureTool) Name() string { return NameGoStructure }
func (t *goStructureTool) IsReadOnly() bool { return true }
func (t *goStructureTool) Description() string {
return "Show Go file/package structure: types (with fields), interfaces, functions, constants, variables — with line numbers. Like go doc but for any .go file."
}
func (t *goStructureTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"path": {Type: "string", Description: "Path to a .go file or directory (package)"},
"exported_only": {Type: "boolean", Description: "Show only exported symbols (default: false)"},
"comments": {Type: "boolean", Description: "Include doc comments (default: false)"},
},
Required: []string{"path"},
}
}
func (t *goStructureTool) Execute(_ context.Context, params map[string]any) (*ToolResult, error) {
rawPath := stringParam(params, "path")
if rawPath == "" {
return nil, &ToolError{Msg: "path is required"}
}
exportedOnly := boolParam(params, "exported_only")
comments := boolParam(params, "comments")
absPath := rawPath
if !filepath.IsAbs(absPath) {
absPath = filepath.Join(t.workDir, absPath)
}
info, err := os.Stat(absPath)
if err != nil {
return nil, fmt.Errorf("path not found: %s: %w", absPath, err)
}
var files []string
if info.IsDir() {
entries, err := os.ReadDir(absPath)
if err != nil {
return nil, fmt.Errorf("read directory: %w", err)
}
for _, e := range entries {
name := e.Name()
if strings.HasSuffix(name, ".go") && !strings.HasSuffix(name, "_test.go") {
files = append(files, filepath.Join(absPath, name))
}
}
sort.Strings(files)
} else {
if !strings.HasSuffix(absPath, ".go") {
return nil, &ToolError{Msg: "path must be a .go file or directory"}
}
files = []string{absPath}
}
if len(files) == 0 {
return &ToolResult{Output: "no .go files found"}, nil
}
var buf strings.Builder
for i, f := range files {
if i > 0 {
buf.WriteByte('\n')
}
relPath, relErr := filepath.Rel(t.workDir, f)
if relErr != nil || relPath == "" || strings.HasPrefix(relPath, "..") {
relPath = f
}
out, err := extractStructure(f, exportedOnly, comments)
if err != nil {
fmt.Fprintf(&buf, "=== %s ===\n# error: %s\n", relPath, err)
continue
}
fmt.Fprintf(&buf, "=== %s ===\n%s", relPath, out)
}
return &ToolResult{
Output: buf.String(),
}, nil
}
// extractStructure parses a single Go file and returns its structural outline.
func extractStructure(filename string, exportedOnly, includeComments bool) (string, error) {
fset := token.NewFileSet()
mode := parser.ParseComments
f, err := parser.ParseFile(fset, filename, nil, mode)
if err != nil {
return "", fmt.Errorf("parse error: %w", err)
}
var buf strings.Builder
// package declaration
line := fset.Position(f.Package).Line
fmt.Fprintf(&buf, "%4d: package %s\n", line, f.Name.Name)
for _, decl := range f.Decls {
switch d := decl.(type) {
case *ast.GenDecl:
formatGenDecl(&buf, fset, d, exportedOnly, includeComments)
case *ast.FuncDecl:
formatFuncDecl(&buf, fset, d, exportedOnly, includeComments)
}
}
return buf.String(), nil
}
// formatGenDecl handles const, var, type declarations.
func formatGenDecl(buf *strings.Builder, fset *token.FileSet, d *ast.GenDecl, exportedOnly, includeComments bool) {
for _, spec := range d.Specs {
switch s := spec.(type) {
case *ast.TypeSpec:
if exportedOnly && !ast.IsExported(s.Name.Name) {
continue
}
formatTypeSpec(buf, fset, d, s, exportedOnly, includeComments)
case *ast.ValueSpec:
formatValueSpec(buf, fset, d, s, exportedOnly, includeComments)
}
}
}
// formatTypeSpec formats a type declaration (struct, interface, alias).
func formatTypeSpec(buf *strings.Builder, fset *token.FileSet, gen *ast.GenDecl, s *ast.TypeSpec, exportedOnly, includeComments bool) {
line := fset.Position(s.Pos()).Line
if includeComments {
writeDocComment(buf, fset, docForTypeSpec(gen, s))
}
switch st := s.Type.(type) {
case *ast.StructType:
fmt.Fprintf(buf, "%4d: type %s struct {\n", line, s.Name.Name)
if st.Fields != nil {
for _, field := range st.Fields.List {
formatStructField(buf, fset, field, exportedOnly, includeComments)
}
}
endLine := fset.Position(st.Fields.Closing).Line
fmt.Fprintf(buf, "%4d: }\n", endLine)
case *ast.InterfaceType:
fmt.Fprintf(buf, "%4d: type %s interface {\n", line, s.Name.Name)
if st.Methods != nil {
for _, m := range st.Methods.List {
formatInterfaceMethod(buf, fset, m, exportedOnly, includeComments)
}
}
endLine := fset.Position(st.Methods.Closing).Line
fmt.Fprintf(buf, "%4d: }\n", endLine)
default:
// type alias or other
fmt.Fprintf(buf, "%4d: type %s %s\n", line, s.Name.Name, typeString(s.Type))
}
}
// formatStructField formats a single struct field.
func formatStructField(buf *strings.Builder, fset *token.FileSet, field *ast.Field, exportedOnly, includeComments bool) {
if len(field.Names) == 0 {
// embedded field
name := typeString(field.Type)
if exportedOnly && !ast.IsExported(name) {
return
}
line := fset.Position(field.Pos()).Line
if includeComments {
writeDocComment(buf, fset, field.Doc)
}
fmt.Fprintf(buf, "%4d: %s\n", line, name)
return
}
for _, n := range field.Names {
if exportedOnly && !ast.IsExported(n.Name) {
continue
}
line := fset.Position(n.Pos()).Line
if includeComments {
writeDocComment(buf, fset, field.Doc)
}
fmt.Fprintf(buf, "%4d: %s %s\n", line, n.Name, typeString(field.Type))
}
}
// formatInterfaceMethod formats a single interface method or embedded type.
func formatInterfaceMethod(buf *strings.Builder, fset *token.FileSet, m *ast.Field, exportedOnly, includeComments bool) {
line := fset.Position(m.Pos()).Line
if len(m.Names) == 0 {
// embedded interface
name := typeString(m.Type)
if exportedOnly && !ast.IsExported(name) {
return
}
if includeComments {
writeDocComment(buf, fset, m.Doc)
}
fmt.Fprintf(buf, "%4d: %s\n", line, name)
return
}
for _, n := range m.Names {
if exportedOnly && !ast.IsExported(n.Name) {
continue
}
if includeComments {
writeDocComment(buf, fset, m.Doc)
}
ft, ok := m.Type.(*ast.FuncType)
if !ok {
fmt.Fprintf(buf, "%4d: %s\n", line, n.Name)
continue
}
fmt.Fprintf(buf, "%4d: %s%s\n", line, n.Name, funcSignature(ft))
}
}
// formatValueSpec formats const/var declarations.
func formatValueSpec(buf *strings.Builder, fset *token.FileSet, gen *ast.GenDecl, s *ast.ValueSpec, exportedOnly, includeComments bool) {
keyword := "var"
if gen.Tok == token.CONST {
keyword = "const"
}
for _, n := range s.Names {
if exportedOnly && !ast.IsExported(n.Name) {
continue
}
line := fset.Position(n.Pos()).Line
if includeComments {
writeDocComment(buf, fset, docForValueSpec(gen, s))
}
if s.Type != nil {
fmt.Fprintf(buf, "%4d: %s %s %s\n", line, keyword, n.Name, typeString(s.Type))
} else {
fmt.Fprintf(buf, "%4d: %s %s\n", line, keyword, n.Name)
}
}
}
// formatFuncDecl formats a function or method declaration (signature only).
func formatFuncDecl(buf *strings.Builder, fset *token.FileSet, d *ast.FuncDecl, exportedOnly, includeComments bool) {
if exportedOnly && !ast.IsExported(d.Name.Name) {
return
}
line := fset.Position(d.Pos()).Line
if includeComments {
writeDocComment(buf, fset, d.Doc)
}
var recv string
if d.Recv != nil && len(d.Recv.List) > 0 {
r := d.Recv.List[0]
recvType := typeString(r.Type)
if len(r.Names) > 0 {
recv = fmt.Sprintf("(%s %s) ", r.Names[0].Name, recvType)
} else {
recv = fmt.Sprintf("(%s) ", recvType)
}
}
fmt.Fprintf(buf, "%4d: func %s%s%s\n", line, recv, d.Name.Name, funcSignature(d.Type))
}
// funcSignature returns the parameter and result signature of a function type.
func funcSignature(ft *ast.FuncType) string {
var buf strings.Builder
buf.WriteByte('(')
if ft.Params != nil {
buf.WriteString(fieldListString(ft.Params))
}
buf.WriteByte(')')
if ft.Results != nil && len(ft.Results.List) > 0 {
results := fieldListString(ft.Results)
if len(ft.Results.List) == 1 && len(ft.Results.List[0].Names) == 0 {
buf.WriteByte(' ')
buf.WriteString(results)
} else {
buf.WriteString(" (")
buf.WriteString(results)
buf.WriteByte(')')
}
}
return buf.String()
}
// fieldListString converts a field list to a string representation.
func fieldListString(fl *ast.FieldList) string {
var parts []string
for _, f := range fl.List {
ts := typeString(f.Type)
if len(f.Names) == 0 {
parts = append(parts, ts)
} else {
names := make([]string, len(f.Names))
for i, n := range f.Names {
names[i] = n.Name
}
parts = append(parts, strings.Join(names, ", ")+" "+ts)
}
}
return strings.Join(parts, ", ")
}
// typeString returns a string representation of a type expression.
func typeString(expr ast.Expr) string {
switch t := expr.(type) {
case *ast.Ident:
return t.Name
case *ast.SelectorExpr:
return typeString(t.X) + "." + t.Sel.Name
case *ast.StarExpr:
return "*" + typeString(t.X)
case *ast.ArrayType:
if t.Len == nil {
return "[]" + typeString(t.Elt)
}
return "[...]" + typeString(t.Elt)
case *ast.MapType:
return "map[" + typeString(t.Key) + "]" + typeString(t.Value)
case *ast.InterfaceType:
if t.Methods == nil || len(t.Methods.List) == 0 {
return "interface{}"
}
return "interface{ ... }"
case *ast.FuncType:
return "func" + funcSignature(t)
case *ast.ChanType:
switch t.Dir {
case ast.SEND:
return "chan<- " + typeString(t.Value)
case ast.RECV:
return "<-chan " + typeString(t.Value)
default:
return "chan " + typeString(t.Value)
}
case *ast.Ellipsis:
return "..." + typeString(t.Elt)
case *ast.ParenExpr:
return "(" + typeString(t.X) + ")"
case *ast.StructType:
return "struct{ ... }"
case *ast.IndexExpr:
return typeString(t.X) + "[" + typeString(t.Index) + "]"
case *ast.IndexListExpr:
indices := make([]string, len(t.Indices))
for i, idx := range t.Indices {
indices[i] = typeString(idx)
}
return typeString(t.X) + "[" + strings.Join(indices, ", ") + "]"
default:
return "?"
}
}
// writeDocComment writes a doc comment block with line numbers.
func writeDocComment(buf *strings.Builder, fset *token.FileSet, cg *ast.CommentGroup) {
if cg == nil {
return
}
for _, c := range cg.List {
line := fset.Position(c.Pos()).Line
fmt.Fprintf(buf, "%4d: %s\n", line, c.Text)
}
}
// docForTypeSpec returns the doc comment for a type spec, falling back to
// the GenDecl doc if the spec has no doc of its own.
func docForTypeSpec(gen *ast.GenDecl, s *ast.TypeSpec) *ast.CommentGroup {
if s.Doc != nil {
return s.Doc
}
if len(gen.Specs) == 1 {
return gen.Doc
}
return nil
}
// docForValueSpec returns the doc comment for a value spec, falling back to
// the GenDecl doc if the spec has no doc of its own.
func docForValueSpec(gen *ast.GenDecl, s *ast.ValueSpec) *ast.CommentGroup {
if s.Doc != nil {
return s.Doc
}
if len(gen.Specs) == 1 {
return gen.Doc
}
return nil
}
// CachePolicy implements the Cacheable interface. go_structure parses
// Go source files, so results are keyed by path and invalidated by
// file-modifying tools.
func (t *goStructureTool) CachePolicy() CachePolicy {
return CachePolicy{
Cacheable: true,
PathParams: []string{"path"},
Invalidators: []string{"write_file", "edit_file", "delete_file", "move_file"},
}
}
// ResolvePaths implements PathResolver. It resolves the path parameter
// to an absolute filesystem path for mtime-based cache invalidation.
func (t *goStructureTool) ResolvePaths(_ context.Context, params map[string]any) []string {
p := stringParam(params, "path")
if p == "" {
return nil
}
if !filepath.IsAbs(p) {
p = filepath.Join(t.workDir, p)
}
return []string{p}
}
// NewGoStructureTool creates a go_structure tool.
func NewGoStructureTool(workDir string) *goStructureTool {
return &goStructureTool{workDir: workDir}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"bytes"
"context"
"errors"
"fmt"
"maps"
"os/exec"
"strings"
"time"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/rag"
)
// goTool is the base for all Go command tools.
type goTool struct {
workDir string
name string
description string
command string
defaultArgs []string
schema llm.JSONSchema
}
func (t *goTool) Name() string { return t.name }
func (t *goTool) Description() string { return t.description }
func (t *goTool) Schema() llm.JSONSchema { return t.schema }
func (t *goTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
const extraArgsCap = 4
args := make([]string, 0, len(t.defaultArgs)+extraArgsCap)
args = append(args, t.defaultArgs...)
if packages, ok := params["packages"].(string); ok && packages != "" {
args = append(args, packages)
} else if len(t.defaultArgs) > 0 && t.defaultArgs[len(t.defaultArgs)-1] != "tidy" {
args = append(args, "./...")
}
if flags, ok := params["flags"].(string); ok && flags != "" {
for f := range strings.FieldsSeq(flags) {
if isForbiddenFlag(f) {
return nil, &ToolError{Msg: fmt.Sprintf("flag %q is not allowed", f)}
}
args = append(args, f)
}
}
if run, ok := params["run"].(string); ok && run != "" {
args = append(args, "-run", run)
}
start := time.Now()
cmd := exec.CommandContext(ctx, t.command, args...)
cmd.Dir = t.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
duration := time.Since(start)
exitCode := 0
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
return nil, fmt.Errorf("run %s: %w", t.command, err)
}
}
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n"
}
output += stderr.String()
}
return &ToolResult{
Output: output,
Meta: map[string]any{
"exit_code": exitCode,
"duration": duration.String(),
},
}, nil
}
// dangerousPatterns lists shell command patterns that indicate destructive or risky operations.
var dangerousPatterns = []string{
"rm -rf", "rm -r", "rm -f",
"chmod", "chown",
"curl", "wget",
"kill", "pkill",
"dd ", "mkfs", "fdisk",
"sudo",
}
// IsDangerousCommand checks if a shell command contains dangerous patterns.
func IsDangerousCommand(cmd string) bool {
lower := strings.ToLower(cmd)
for _, p := range dangerousPatterns {
if strings.Contains(lower, p) {
return true
}
}
return strings.Contains(cmd, "> /etc/") || strings.Contains(cmd, ">> /etc/")
}
// forbiddenGoFlags lists flags that could execute arbitrary code via go toolchain.
var forbiddenGoFlags = []string{"-exec", "-toolexec", "-overlay"}
func isForbiddenFlag(flag string) bool {
for _, f := range forbiddenGoFlags {
if flag == f || strings.HasPrefix(flag, f+"=") {
return true
}
}
return false
}
func goToolSchema(extraProps map[string]llm.JSONSchema) llm.JSONSchema {
props := map[string]llm.JSONSchema{
"packages": {Type: "string", Description: "Go packages (default: ./...)"},
"flags": {Type: "string", Description: "Additional flags"},
}
maps.Copy(props, extraProps)
return llm.JSONSchema{
Type: "object",
Properties: props,
}
}
// NewGoBuildTool creates a go_build tool.
func NewGoBuildTool(workDir string) *goTool {
return &goTool{
workDir: workDir,
name: NameGoBuild,
description: "Run go build",
command: "go",
defaultArgs: []string{"build", "-o", ".build"},
schema: goToolSchema(nil),
}
}
// NewGoTestTool creates a go_test tool.
func NewGoTestTool(workDir string) *goTool {
return &goTool{
workDir: workDir,
name: NameGoTest,
description: "Run go test",
command: "go",
defaultArgs: []string{"test"},
schema: goToolSchema(map[string]llm.JSONSchema{
"run": {Type: "string", Description: "Test name pattern (-run)"},
}),
}
}
// NewGoVetTool creates a go_vet tool.
func NewGoVetTool(workDir string) *goTool {
return &goTool{
workDir: workDir,
name: NameGoVet,
description: "Run go vet",
command: "go",
defaultArgs: []string{"vet"},
schema: goToolSchema(nil),
}
}
// NewGoFmtTool creates a go_fmt tool.
func NewGoFmtTool(workDir string) *goTool {
return &goTool{
workDir: workDir,
name: NameGoFmt,
description: "Run gofmt -w on files",
command: "gofmt",
defaultArgs: []string{"-w"},
schema: llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"path": {Type: "string", Description: "File or directory to format (default: .)"},
},
},
}
}
// NewGoLintTool creates a go_lint tool.
func NewGoLintTool(workDir string) *goTool {
return &goTool{
workDir: workDir,
name: NameGoLint,
description: "Run golangci-lint",
command: "golangci-lint",
defaultArgs: []string{"run"},
schema: goToolSchema(map[string]llm.JSONSchema{
"config": {Type: "string", Description: "Path to lint config"},
}),
}
}
// NewGoModTidyTool creates a go_mod_tidy tool.
func NewGoModTidyTool(workDir string) *goTool {
return &goTool{
workDir: workDir,
name: NameGoModTidy,
description: "Run go mod tidy",
command: "go",
defaultArgs: []string{"mod", "tidy"},
schema: llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{},
},
}
}
// NewGoGetTool creates a go_get tool.
func NewGoGetTool(workDir string) *goTool {
return &goTool{
workDir: workDir,
name: NameGoGet,
description: "Run go get to add or update a dependency",
command: "go",
defaultArgs: []string{"get"},
schema: llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"packages": {Type: "string", Description: "Package path(s) to install, e.g. github.com/pkg/errors@latest"},
},
Required: []string{"packages"},
},
}
}
// GoDocIndexer is the interface for indexing and searching go doc output
// in the godoc RAG sub-index. When nil, go_doc returns full output without
// indexing or search.
type GoDocIndexer interface {
Build(ctx context.Context, chunks []rag.Chunk) (int, error)
Save() error
Search(ctx context.Context, query string, topK int) ([]rag.SearchResult, error)
}
// goDocTool wraps goTool and optionally indexes output into RAG.
type goDocTool struct {
goTool
indexer GoDocIndexer // nil when RAG is disabled
}
// IsReadOnly marks go_doc as a read-only tool. The wrapped goTool is generic
// and cannot make this assertion on its own, so we override here.
func (t *goDocTool) IsReadOnly() bool { return true }
// godocSearchTopK is the default number of results for godoc semantic search.
const godocSearchTopK = 5
// NewGoDocTool creates a go_doc tool. If indexer is non-nil (RAG enabled),
// the tool indexes go doc output into the godoc RAG sub-index and supports
// semantic search over previously indexed documentation via the query parameter.
func NewGoDocTool(workDir string, indexer GoDocIndexer) *goDocTool {
return &goDocTool{
goTool: goTool{
workDir: workDir,
name: NameGoDoc,
description: "Run go doc to view/index package documentation, or search previously indexed Go docs by query",
command: "go",
defaultArgs: []string{"doc"},
schema: llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"packages": {Type: "string", Description: "Package or symbol to get documentation for, e.g. fmt.Println or encoding/json.Decoder"},
"flags": {Type: "string", Description: "Additional flags, e.g. -all for full docs"},
"query": {Type: "string", Description: "Semantic search query over previously indexed Go docs, e.g. 'format string verbs' or 'json decoder options'"},
},
},
},
indexer: indexer,
}
}
const goDocPreviewBytes = 500
func (t *goDocTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
pkgPath := stringParam(params, "packages")
query := stringParam(params, "query")
// Search mode: query without packages.
if query != "" && pkgPath == "" {
return t.searchGodoc(ctx, query)
}
if pkgPath == "" {
return nil, &ToolError{Msg: "either packages or query is required"}
}
result, err := t.goTool.Execute(ctx, params)
if err != nil {
return nil, err
}
// No indexer — return full output as before.
if t.indexer == nil {
return result, nil
}
chunks := rag.ChunkGoDoc(pkgPath, result.Output, rag.MaxChunkBytes)
if len(chunks) == 0 {
return result, nil
}
n, err := t.indexer.Build(ctx, chunks)
if err != nil {
return nil, fmt.Errorf("index godoc: %w", err)
}
if n == 0 {
return result, nil
}
if err := t.indexer.Save(); err != nil {
return nil, fmt.Errorf("save godoc index: %w", err)
}
// Return a short preview + instruction to search.
preview := result.Output
if len(preview) > goDocPreviewBytes {
preview = preview[:goDocPreviewBytes] + "\n... (truncated)"
}
result.Output = fmt.Sprintf("Indexed documentation for %s (%d chunks, %d new). Use go_doc with query parameter to search indexed docs.\n\n%s",
pkgPath, len(chunks), n, preview)
if result.Meta == nil {
result.Meta = make(map[string]any)
}
result.Meta["indexed_chunks"] = len(chunks)
return result, nil
}
// searchGodoc performs semantic search over previously indexed Go documentation.
func (t *goDocTool) searchGodoc(ctx context.Context, query string) (*ToolResult, error) {
if t.indexer == nil {
return &ToolResult{
Output: "RAG is disabled. Run go_doc with packages parameter to view documentation directly.",
}, nil
}
results, err := t.indexer.Search(ctx, query, godocSearchTopK)
if err != nil {
return nil, fmt.Errorf("doc search: %w", err)
}
if len(results) == 0 {
return &ToolResult{
Output: "No Go documentation found. Use go_doc with packages parameter first to index a package, then search with query.",
}, nil
}
var b strings.Builder
b.WriteString("[GO DOCUMENTATION]\n\n")
for i, r := range results {
fmt.Fprintf(&b, "--- Doc %d (%.2f) %s:%d-%d ---\n%s\n\n",
i+1, r.Score, r.Chunk.FilePath, r.Chunk.StartLine, r.Chunk.EndLine, r.Chunk.Content)
}
return &ToolResult{
Output: b.String(),
Meta: map[string]any{
"results": len(results),
},
}, nil
}
// CachePolicy implements the Cacheable interface. go_doc results are
// cacheable regardless of mode (package docs or search). The RAG indexing
// side-effect is idempotent so caching the result is safe.
func (t *goDocTool) CachePolicy() CachePolicy {
return CachePolicy{
Cacheable: true,
}
}
// RegisterGoTools registers Go-specific tools (build/test/vet/fmt/lint/etc.).
// The indexer is optional: when non-nil (RAG enabled) go_doc indexes its
// output into the godoc RAG sub-index and supports semantic search via the
// query parameter.
func RegisterGoTools(reg *Registry, workDir string, indexer GoDocIndexer) {
reg.Register(NewGoBuildTool(workDir))
reg.Register(NewGoTestTool(workDir))
reg.Register(NewGoVetTool(workDir))
reg.Register(NewGoFmtTool(workDir))
reg.Register(NewGoLintTool(workDir))
reg.Register(NewGoModTidyTool(workDir))
reg.Register(NewGoGetTool(workDir))
reg.Register(NewGoDocTool(workDir, indexer))
reg.Register(NewGoStructureTool(workDir))
}
// BashTool executes arbitrary shell commands.
type BashTool struct {
workDir string
}
func (t *BashTool) Name() string { return extBash }
func (t *BashTool) Description() string { return "Execute a shell command" }
func (t *BashTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"command": {Type: "string", Description: "Shell command to execute"},
},
Required: []string{"command"},
}
}
func (t *BashTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
command := stringParam(params, "command")
if command == "" {
return nil, &ToolError{Msg: "command is required"}
}
cmd := exec.CommandContext(ctx, "sh", "-c", command)
cmd.Dir = t.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
start := time.Now()
err := cmd.Run()
duration := time.Since(start)
exitCode := 0
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
return nil, fmt.Errorf("run bash: %w", err)
}
}
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n"
}
output += stderr.String()
}
return &ToolResult{
Output: output,
Meta: map[string]any{
"exit_code": exitCode,
"duration": duration.String(),
},
}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
"github.com/raoptimus/kodrun/internal/llm"
)
const defaultArgsDesc = "Additional arguments"
// jstsTool is the base for JavaScript/TypeScript ecosystem command tools.
type jstsTool struct {
workDir string
name string
description string
command string
defaultArgs []string
schema llm.JSONSchema
}
func (t *jstsTool) Name() string { return t.name }
func (t *jstsTool) Description() string { return t.description }
func (t *jstsTool) Schema() llm.JSONSchema { return t.schema }
func (t *jstsTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
const extraArgsCap = 4
args := make([]string, 0, len(t.defaultArgs)+extraArgsCap)
args = append(args, t.defaultArgs...)
if extra, ok := params["args"].(string); ok && extra != "" {
args = append(args, strings.Fields(extra)...)
}
start := time.Now()
cmd := exec.CommandContext(ctx, t.command, args...)
cmd.Dir = t.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
duration := time.Since(start)
exitCode := 0
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
return nil, fmt.Errorf("run %s: %w", t.command, err)
}
}
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n"
}
output += stderr.String()
}
return &ToolResult{
Output: output,
Meta: map[string]any{
"exit_code": exitCode,
"duration": duration.String(),
},
}, nil
}
func jstsToolSchema(argsDesc string) llm.JSONSchema {
if argsDesc == "" {
argsDesc = defaultArgsDesc
}
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"args": {Type: "string", Description: argsDesc},
},
}
}
// RegisterJSTSTools registers JavaScript/TypeScript ecosystem tools.
func RegisterJSTSTools(reg *Registry, workDir string) {
reg.Register(&jstsTool{
workDir: workDir, name: NameNpmInstall,
description: "Run npm install",
command: "npm", defaultArgs: []string{"install"},
schema: jstsToolSchema("Package(s) or flags"),
})
reg.Register(&jstsTool{
workDir: workDir, name: NameNpmRun,
description: "Run an npm script (npm run <script>)",
command: "npm", defaultArgs: []string{"run"},
schema: jstsToolSchema("Script name and arguments"),
})
reg.Register(&jstsTool{
workDir: workDir, name: NameNpmTest,
description: "Run npm test",
command: "npm", defaultArgs: []string{"test"},
schema: jstsToolSchema(defaultArgsDesc),
})
reg.Register(&jstsTool{
workDir: workDir, name: NameTSC,
description: "Run TypeScript compiler",
command: "tsc", defaultArgs: []string{"--noEmit"},
schema: jstsToolSchema("tsc arguments, e.g. -p tsconfig.json"),
})
reg.Register(&jstsTool{
workDir: workDir, name: NameESLint,
description: "Run eslint",
command: "eslint", defaultArgs: []string{"."},
schema: jstsToolSchema("eslint arguments"),
})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/pkg/errors"
)
// SafePath resolves a path relative to workDir and ensures it doesn't escape.
func SafePath(_ context.Context, workDir, path string) (string, error) {
if filepath.IsAbs(path) {
return "", errors.Errorf("absolute paths not allowed: %s", path)
}
resolved := filepath.Join(workDir, path)
resolved = filepath.Clean(resolved)
// Ensure the resolved path is under workDir
rel, err := filepath.Rel(workDir, resolved)
if err != nil {
return "", errors.WithMessage(err, "resolve path")
}
if strings.HasPrefix(rel, "..") {
return "", errors.Errorf("path traversal not allowed: %s", path)
}
// Resolve symlinks and re-check to prevent symlink-based traversal.
if realPath, err := filepath.EvalSymlinks(resolved); err == nil {
realWorkDir := workDir
if rwd, e := filepath.EvalSymlinks(workDir); e == nil {
realWorkDir = rwd
}
realRel, relErr := filepath.Rel(realWorkDir, realPath)
if relErr != nil || strings.HasPrefix(realRel, "..") {
return "", errors.Errorf("symlink escapes work directory: %s", path)
}
} else if !os.IsNotExist(err) {
return "", errors.WithMessage(err, "resolve symlink")
}
return resolved, nil
}
// IsForbiddenDir checks if a directory should be skipped during Walk.
// Hidden directories (starting with ".") are always forbidden.
// Additionally matches patterns like ".git/**" against directory name.
func IsForbiddenDir(_ context.Context, relPath string, patterns []string) bool {
dirName := filepath.Base(relPath)
if strings.HasPrefix(dirName, ".") {
return true
}
for _, pattern := range patterns {
if dirPattern, ok := strings.CutSuffix(pattern, "/**"); ok {
if matched, err := filepath.Match(dirPattern, dirName); err == nil && matched {
return true
}
}
}
return false
}
// HasHiddenPathComponent returns true if any directory component in path
// starts with ".". It does NOT check the final filename component —
// only the directory segments leading to it.
func HasHiddenPathComponent(path string) bool {
dir := filepath.Dir(path)
for _, part := range strings.Split(filepath.ToSlash(dir), "/") {
if part == "." || part == "" {
continue
}
if strings.HasPrefix(part, ".") {
return true
}
}
return false
}
// IsPathBlocked returns a non-empty reason if the path should be blocked.
// It checks both forbidden patterns and hidden directory components.
func IsPathBlocked(ctx context.Context, path, resolved string, patterns []string) string {
if IsForbidden(ctx, path, patterns) || IsForbidden(ctx, resolved, patterns) {
return fmt.Sprintf("access to %s is forbidden", path)
}
if HasHiddenPathComponent(path) {
return fmt.Sprintf("access to %s is forbidden: path contains hidden directory", path)
}
return ""
}
// IsForbidden checks if a path matches any forbidden patterns.
func IsForbidden(_ context.Context, path string, patterns []string) bool {
for _, pattern := range patterns {
if matched, err := filepath.Match(pattern, filepath.Base(path)); err == nil && matched {
return true
}
// Also check full relative path for glob patterns
if matched, err := filepath.Match(pattern, path); err == nil && matched {
return true
}
}
return false
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"bytes"
"context"
"errors"
"fmt"
"os/exec"
"strings"
"time"
"github.com/raoptimus/kodrun/internal/llm"
)
// pythonTool is the base for Python ecosystem command tools.
type pythonTool struct {
workDir string
name string
description string
command string
defaultArgs []string
schema llm.JSONSchema
}
func (t *pythonTool) Name() string { return t.name }
func (t *pythonTool) Description() string { return t.description }
func (t *pythonTool) Schema() llm.JSONSchema { return t.schema }
func (t *pythonTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
const extraArgsCap = 4
args := make([]string, 0, len(t.defaultArgs)+extraArgsCap)
args = append(args, t.defaultArgs...)
if extra, ok := params["args"].(string); ok && extra != "" {
args = append(args, strings.Fields(extra)...)
}
start := time.Now()
cmd := exec.CommandContext(ctx, t.command, args...)
cmd.Dir = t.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
err := cmd.Run()
duration := time.Since(start)
exitCode := 0
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
exitCode = exitErr.ExitCode()
} else {
return nil, fmt.Errorf("run %s: %w", t.command, err)
}
}
output := stdout.String()
if stderr.Len() > 0 {
if output != "" {
output += "\n"
}
output += stderr.String()
}
return &ToolResult{
Output: output,
Meta: map[string]any{
"exit_code": exitCode,
"duration": duration.String(),
},
}, nil
}
func pythonToolSchema(argsDesc string) llm.JSONSchema {
if argsDesc == "" {
argsDesc = "Additional arguments"
}
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"args": {Type: "string", Description: argsDesc},
},
}
}
// RegisterPythonTools registers Python-specific tools.
func RegisterPythonTools(reg *Registry, workDir string) {
reg.Register(&pythonTool{
workDir: workDir, name: NamePythonRun,
description: "Run a Python script (python <args>)",
command: "python", defaultArgs: nil,
schema: pythonToolSchema("Script path and arguments, e.g. main.py --flag"),
})
reg.Register(&pythonTool{
workDir: workDir, name: NamePytest,
description: "Run pytest", command: "pytest", defaultArgs: nil,
schema: pythonToolSchema("pytest arguments, e.g. -k test_name -q"),
})
reg.Register(&pythonTool{
workDir: workDir, name: NamePipInstall,
description: "Install a Python package via pip",
command: "pip", defaultArgs: []string{"install"},
schema: pythonToolSchema("Package(s) to install"),
})
reg.Register(&pythonTool{
workDir: workDir, name: NameRuff,
description: "Run ruff linter/formatter",
command: "ruff", defaultArgs: []string{"check"},
schema: pythonToolSchema("ruff arguments, e.g. --fix path/"),
})
reg.Register(&pythonTool{
workDir: workDir, name: NameBlack,
description: "Run black formatter",
command: "black", defaultArgs: []string{"."},
schema: pythonToolSchema("black arguments"),
})
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"fmt"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/rag"
)
// RAGSearcher is implemented by both *rag.Index and *rag.MultiIndex.
type RAGSearcher interface {
Search(ctx context.Context, query string, topK int) ([]rag.SearchResult, error)
}
// RAGSearchTool searches the RAG index for relevant code/docs.
type RAGSearchTool struct {
index RAGSearcher
topK int
}
// NewRAGSearchTool creates a new search_docs tool. The index argument
// can be either *rag.Index or *rag.MultiIndex.
func NewRAGSearchTool(index RAGSearcher, topK int) *RAGSearchTool {
return &RAGSearchTool{index: index, topK: topK}
}
func (t *RAGSearchTool) Name() string { return NameSearchDocs }
func (t *RAGSearchTool) IsReadOnly() bool { return true }
func (t *RAGSearchTool) Description() string {
return "Search project files using semantic similarity (RAG). Use for finding relevant code, docs, or examples."
}
func (t *RAGSearchTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"query": {Type: "string", Description: "Search query describing what you're looking for"},
},
Required: []string{"query"},
}
}
func (t *RAGSearchTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
query := stringParam(params, "query")
if query == "" {
return nil, &ToolError{Msg: "query is required"}
}
results, err := t.index.Search(ctx, query, t.topK)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
if len(results) == 0 {
return &ToolResult{Output: "No relevant results found. Try a different query or run /reindex."}, nil
}
var b strings.Builder
for i, r := range results {
fmt.Fprintf(&b, "--- Result %d (%.2f) %s:%d-%d ---\n%s\n\n",
i+1, r.Score, r.Chunk.FilePath, r.Chunk.StartLine, r.Chunk.EndLine, r.Chunk.Content)
}
return &ToolResult{
Output: b.String(),
Meta: map[string]any{
"results": len(results),
},
}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"github.com/raoptimus/kodrun/internal/projectlang"
"github.com/raoptimus/kodrun/internal/rules"
"github.com/raoptimus/kodrun/internal/snippets"
)
// RegisterCoreTools registers language-neutral tools: file ops, grep/find,
// git, and bash. These are always available regardless of project language.
func RegisterCoreTools(reg *Registry, workDir string, forbidden []string, maxReadLines int, langState *projectlang.State) {
reg.Register(NewFileStatTool(workDir, forbidden))
reg.Register(NewReadFileTool(workDir, forbidden, maxReadLines))
reg.Register(NewWriteFileTool(workDir, forbidden))
reg.Register(NewEditFileTool(workDir, forbidden))
reg.Register(NewListDirTool(workDir, forbidden))
reg.Register(NewFindFilesTool(workDir, forbidden))
reg.Register(NewGrepTool(workDir, forbidden))
reg.Register(NewDeleteFileTool(workDir, forbidden))
reg.Register(NewCreateDirTool(workDir, forbidden))
reg.Register(NewMoveFileTool(workDir, forbidden))
reg.Register(NewReadChangedFilesTool(workDir, langState))
reg.Register(NewGitStatusTool(workDir))
reg.Register(NewGitDiffTool(workDir))
reg.Register(NewGitLogTool(workDir))
reg.Register(NewGitCommitTool(workDir))
reg.Register(&BashTool{workDir: workDir})
}
// RegisterLanguageTools registers tools specific to the given language.
// It is safe to call multiple times: tools.Registry.Register is idempotent
// (last write wins on the same name). Unknown languages are a no-op.
// The indexer is optional: when non-nil, Go tools index go doc output into RAG.
func RegisterLanguageTools(reg *Registry, lang projectlang.Language, workDir string, indexer GoDocIndexer) {
switch lang {
case projectlang.LangGo:
RegisterGoTools(reg, workDir, indexer)
case projectlang.LangPython:
RegisterPythonTools(reg, workDir)
case projectlang.LangJSTS:
RegisterJSTSTools(reg, workDir)
}
}
// RegisterConfig groups everything Register needs. Build it once at startup
// and pass by pointer; this avoids the 13-positional-argument shape that
// used to break every call site whenever a new dependency was added.
type RegisterConfig struct {
WorkDir string
Forbidden []string
MaxReadLines int
Loader *rules.Loader
SnippetLoader *snippets.Loader
Scope rules.Scope
UseRuleTool bool
UseSnippetTool bool
RAGEnabled bool
Lang projectlang.Language
Indexer GoDocIndexer
LangState *projectlang.State
}
// Register registers core tools, the language-specific tool set, and the
// optional rule/snippet tools when RAG is disabled.
func Register(reg *Registry, cfg *RegisterConfig) {
RegisterCoreTools(reg, cfg.WorkDir, cfg.Forbidden, cfg.MaxReadLines, cfg.LangState)
RegisterLanguageTools(reg, cfg.Lang, cfg.WorkDir, cfg.Indexer)
if cfg.Loader != nil && cfg.UseRuleTool && !cfg.RAGEnabled {
reg.Register(NewRuleTool(cfg.Loader, cfg.Scope))
}
if cfg.SnippetLoader != nil && cfg.UseSnippetTool && !cfg.RAGEnabled {
st := NewSnippetTool(cfg.SnippetLoader)
st.SetTechStack(cfg.LangState.EnsureTechDetected().Strings())
reg.Register(st)
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"fmt"
"maps"
"sort"
"strings"
"sync"
"github.com/raoptimus/kodrun/internal/llm"
)
// Tool is the interface for all agent tools.
type Tool interface {
Name() string
Description() string
Schema() llm.JSONSchema
Execute(ctx context.Context, params map[string]any) (*ToolResult, error)
}
// ReadOnlyMarker is implemented by tools that do not modify the filesystem,
// shell state, or external systems. Tools that implement this interface and
// return true are exposed to plan/chat/verifier modes; absent or false means
// the tool is treated as a write tool.
type ReadOnlyMarker interface {
IsReadOnly() bool
}
// ToolResult represents the successful outcome of a tool execution.
type ToolResult struct {
Output string `json:"output"`
Meta map[string]any `json:"meta,omitempty"`
}
// ToolError is a tool-level execution failure (e.g. file not found,
// invalid arguments). Callers use errors.As to distinguish it from
// framework errors.
type ToolError struct {
Msg string
}
func (e *ToolError) Error() string { return e.Msg }
// Registry manages available tools.
type Registry struct {
mu sync.RWMutex
tools map[string]Tool
// cache is an optional shared result cache. When set, calls to Cacheable
// tools are served from cache when fresh, and write-tool calls invalidate
// dependent entries.
cache *ResultCache
// invalidatedBy maps a write-tool name to the set of read-tool names whose
// CachePolicy lists it in Invalidators. Built lazily on Register and used by
// Execute to know whether a write-tool call should poke the cache.
invalidatedBy map[string]map[string]struct{}
}
// NewRegistry creates a new tool registry.
func NewRegistry() *Registry {
return &Registry{
tools: make(map[string]Tool),
invalidatedBy: make(map[string]map[string]struct{}),
}
}
// WithCache attaches a shared result cache to the registry. Pass nil to
// disable caching. Returns the registry for chaining.
func (r *Registry) WithCache(c *ResultCache) *Registry {
r.mu.Lock()
r.cache = c
r.mu.Unlock()
return r
}
// Cache returns the attached result cache, or nil if caching is disabled.
func (r *Registry) Cache() *ResultCache {
r.mu.RLock()
defer r.mu.RUnlock()
return r.cache
}
// Register adds a tool to the registry.
func (r *Registry) Register(t Tool) {
r.mu.Lock()
defer r.mu.Unlock()
r.tools[t.Name()] = t
if c, ok := t.(Cacheable); ok {
policy := c.CachePolicy()
for _, inv := range policy.Invalidators {
set, exists := r.invalidatedBy[inv]
if !exists {
set = make(map[string]struct{})
r.invalidatedBy[inv] = set
}
set[t.Name()] = struct{}{}
}
}
}
// Execute runs a tool by name with the given parameters.
//
// When a result cache is attached, Execute serves cacheable read-tool results
// from the cache when fresh, stores new results, and invalidates dependent
// entries on successful write-tool calls.
func (r *Registry) Execute(ctx context.Context, name string, params map[string]any) (*ToolResult, error) {
r.mu.RLock()
t, ok := r.tools[name]
cache := r.cache
invalidates := r.invalidatedBy[name]
r.mu.RUnlock()
if !ok {
return nil, &ToolError{Msg: fmt.Sprintf("unknown tool: %s", name)}
}
// Cache lookup for cacheable tools.
var (
cacheKey string
resolvedPaths []string
policy CachePolicy
cacheable bool
)
if cache != nil {
if c, ok := t.(Cacheable); ok {
policy = c.CachePolicy()
if policy.Cacheable {
cacheable = true
cacheKey = buildCacheKey(name, policy, params)
resolvedPaths = resolveParamPaths(ctx, t, policy, params)
if hit, found := cache.Get(cacheKey); found {
return &ToolResult{
Output: hit.Output,
Meta: cloneMetaWithCacheHit(hit.Meta),
}, nil
}
}
}
}
result, err := t.Execute(ctx, params)
// Store on success.
if cache != nil && cacheable && err == nil {
cache.Put(cacheKey, result, resolvedPaths)
}
// Write-tool invalidation: if this tool is referenced as an invalidator by
// any cached read-tool, drop entries depending on the affected paths.
if cache != nil && len(invalidates) > 0 && err == nil {
for _, p := range writeToolPaths(ctx, t, params) {
cache.InvalidatePath(p)
}
}
return result, err
}
// buildCacheKey produces a deterministic key for a tool call. Tools may
// override key construction via CachePolicy.KeyFunc.
func buildCacheKey(name string, policy CachePolicy, params map[string]any) string {
if policy.KeyFunc != nil {
return name + ":" + policy.KeyFunc(params)
}
// Fallback: sort param keys and concatenate.
keys := make([]string, 0, len(params))
for k := range params {
keys = append(keys, k)
}
sort.Strings(keys)
parts := make([]string, 0, len(keys)*2+1)
parts = append(parts, name)
for _, k := range keys {
parts = append(parts, fmt.Sprintf("%s=%v", k, params[k]))
}
return strings.Join(parts, "|")
}
// resolveParamPaths returns absolute filesystem paths for all PathParams of a
// cacheable tool. Tools that need custom resolution can implement
// PathResolver. Passing ctx lets symlink/walk-heavy resolvers honour
// cancellation when the parent Execute context is cancelled.
func resolveParamPaths(ctx context.Context, t Tool, policy CachePolicy, params map[string]any) []string {
if pr, ok := t.(PathResolver); ok {
return pr.ResolvePaths(ctx, params)
}
// Best-effort: extract string params named in PathParams. Without a
// workdir resolver here, we trust that tools storing in CachePolicy provide
// either a custom resolver or absolute params.
out := make([]string, 0, len(policy.PathParams))
for _, k := range policy.PathParams {
if s, ok := params[k].(string); ok && s != "" {
out = append(out, s)
}
}
return out
}
// writeToolPaths returns the absolute filesystem paths a write-tool will
// affect. Write tools should implement PathResolver to participate in
// invalidation; otherwise this falls back to common params.
func writeToolPaths(ctx context.Context, t Tool, params map[string]any) []string {
if pr, ok := t.(PathResolver); ok {
return pr.ResolvePaths(ctx, params)
}
if s, ok := params["path"].(string); ok && s != "" {
return []string{s}
}
return nil
}
// cloneMetaWithCacheHit returns a shallow copy of meta with "cache_hit" set
// to true. A new map is always returned to avoid mutating the cached entry.
func cloneMetaWithCacheHit(meta map[string]any) map[string]any {
out := make(map[string]any, len(meta)+1)
maps.Copy(out, meta)
out["cache_hit"] = true
return out
}
// PathResolver is implemented by tools whose params reference filesystem
// paths. The cache uses it to compute mtime keys (for cacheable read-tools)
// and invalidation keys (for write-tools).
//
// Implementations should return absolute paths after applying any workdir
// resolution they perform internally.
type PathResolver interface {
ResolvePaths(ctx context.Context, params map[string]any) []string
}
// ToolDefs returns Ollama tool definitions for all registered tools.
func (r *Registry) ToolDefs() []llm.ToolDef {
r.mu.RLock()
defer r.mu.RUnlock()
defs := make([]llm.ToolDef, 0, len(r.tools))
for _, t := range r.tools {
defs = append(defs, llm.ToolDef{
Type: "function",
Function: llm.ToolFuncDef{
Name: t.Name(),
Description: t.Description(),
Parameters: t.Schema(),
},
})
}
return defs
}
// Names returns all registered tool names.
func (r *Registry) Names() []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(r.tools))
for name := range r.tools {
names = append(names, name)
}
sort.Strings(names)
return names
}
// ToolDefsFiltered returns tool definitions only for tools in the allowed set.
func (r *Registry) ToolDefsFiltered(allowed map[string]bool) []llm.ToolDef {
r.mu.RLock()
defer r.mu.RUnlock()
defs := make([]llm.ToolDef, 0, len(allowed))
for _, t := range r.tools {
if allowed[t.Name()] {
defs = append(defs, llm.ToolDef{
Type: "function",
Function: llm.ToolFuncDef{
Name: t.Name(),
Description: t.Description(),
Parameters: t.Schema(),
},
})
}
}
return defs
}
// ReadOnlyTools returns the set of registered tool names that report
// IsReadOnly() == true. Tools that do not implement ReadOnlyMarker are
// considered write tools and excluded. Returns an empty map when the
// registry is nil so callers in unit tests don't have to special-case it.
func (r *Registry) ReadOnlyTools() map[string]bool {
if r == nil {
return map[string]bool{}
}
r.mu.RLock()
defer r.mu.RUnlock()
out := make(map[string]bool, len(r.tools))
for name, t := range r.tools {
if m, ok := t.(ReadOnlyMarker); ok && m.IsReadOnly() {
out[name] = true
}
}
return out
}
// NamesFiltered returns tool names that are in the allowed set.
func (r *Registry) NamesFiltered(allowed map[string]bool) []string {
r.mu.RLock()
defer r.mu.RUnlock()
names := make([]string, 0, len(allowed))
for name := range r.tools {
if allowed[name] {
names = append(names, name)
}
}
sort.Strings(names)
return names
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"fmt"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/rules"
)
// RuleTool provides access to project rules via the get_rule tool call.
type RuleTool struct {
loader *rules.Loader
scope rules.Scope
}
// NewRuleTool creates a new get_rule tool.
func NewRuleTool(loader *rules.Loader, scope rules.Scope) *RuleTool {
return &RuleTool{loader: loader, scope: scope}
}
func (t *RuleTool) Name() string { return NameGetRule }
func (t *RuleTool) IsReadOnly() bool { return true }
func (t *RuleTool) Description() string {
return "Get full content of a project rule with referenced documentation"
}
func (t *RuleTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"name": {Type: "string", Description: "Rule name from the catalog (e.g. service, model, repository)"},
},
Required: []string{"name"},
}
}
func (t *RuleTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
name := stringParam(params, "name")
if name == "" {
return nil, &ToolError{Msg: "name is required"}
}
content, err := t.loader.GetRuleContent(ctx, name, t.scope)
if err != nil {
return nil, &ToolError{Msg: fmt.Sprintf("rule not found: %s", name)}
}
return &ToolResult{Output: content}, nil
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/snippets"
)
const (
snippetActionMatch = "match"
snippetModeAND = "AND"
)
type matchResult struct {
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags"`
Related []string `json:"related,omitempty"`
Placeholders map[string]string `json:"placeholders,omitempty"`
Sections []string `json:"sections,omitempty"`
Content string `json:"content,omitempty"`
}
type listResult struct {
Name string `json:"name"`
Description string `json:"description"`
Tags []string `json:"tags"`
Paths []string `json:"paths"`
Related []string `json:"related,omitempty"`
}
type matchResponse struct {
Matches []matchResult `json:"matches"`
Hint string `json:"hint,omitempty"`
Fallback string `json:"fallback,omitempty"`
Tip string `json:"tip,omitempty"`
}
// SnippetTool exposes project snippets to the agent.
type SnippetTool struct {
loader *snippets.Loader
techStack []string
}
// NewSnippetTool creates a new snippets tool.
func NewSnippetTool(loader *snippets.Loader) *SnippetTool {
return &SnippetTool{loader: loader}
}
// SetTechStack sets the detected project technology stack for filtering
// snippets with requires.
func (t *SnippetTool) SetTechStack(stack []string) {
t.techStack = stack
}
func (t *SnippetTool) Name() string { return NameSnippets }
func (t *SnippetTool) IsReadOnly() bool { return true }
func (t *SnippetTool) Description() string {
return "MUST call before writing, modifying, or reviewing code — returns code template snippets. " +
"Pass file paths to match against snippet globs. Actions: match (default), list, tags."
}
func (t *SnippetTool) Schema() llm.JSONSchema {
stringArray := &llm.JSONSchema{
Items: &llm.JSONSchema{Type: "string"},
}
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"action": {Type: "string", Description: "match (default), list, or tags", Enum: []string{snippetActionMatch, "list", "tags"}},
"paths": {Type: "array", Description: "File paths to match against snippet globs", Items: stringArray.Items},
"tags": {Type: "array", Description: "Filter snippets by tags", Items: stringArray.Items},
"tag_mode": {Type: "string", Description: "How to combine tags: and (default) or or", Enum: []string{"and", "or"}},
"query": {Type: "string", Description: "Text search in snippet names, descriptions, and tags"},
"section": {Type: "string", Description: "Only include sections whose heading contains this substring"},
"full": {Type: "boolean", Description: "Return full snippet content including prose"},
},
}
}
func (t *SnippetTool) Execute(_ context.Context, params map[string]any) (*ToolResult, error) {
if t.loader == nil {
return nil, &ToolError{Msg: "snippets loader is not configured"}
}
action := stringParam(params, "action")
if action == "" {
action = snippetActionMatch
}
all := filterByRequires(t.loader.Snippets(), t.techStack)
switch action {
case snippetActionMatch:
return t.match(all, params)
case "list":
return t.list(all, params)
case "tags":
return t.tags(all)
default:
return nil, &ToolError{Msg: "unknown action: " + action}
}
}
func (t *SnippetTool) match(all []snippets.Snippet, params map[string]any) (*ToolResult, error) {
query := stringParam(params, "query")
section := stringParam(params, "section")
tagMode := stringParam(params, "tag_mode")
full := boolParam(params, "full")
tags := extractStringSlice(params, "tags")
paths := extractStringSlice(params, "paths")
if len(paths) == 0 && len(tags) == 0 && query == "" {
return nil, &ToolError{Msg: "at least one of paths, tags, or query is required"}
}
out := snippets.MatchWithOpts(all, &snippets.MatchOpts{
Paths: paths,
Tags: tags,
TagMode: tagMode,
Query: query,
Section: section,
})
results := make([]matchResult, len(out.Snippets))
for i := range out.Snippets {
s := &out.Snippets[i]
results[i] = matchResult{
Name: s.Name,
Description: s.Description,
Tags: s.Tags,
Related: s.Related,
Placeholders: s.Placeholders,
Sections: snippets.SectionHeadings(s),
}
if full {
results[i].Content = s.Content
} else {
results[i].Content = snippets.CompactContent(s)
}
}
resp := matchResponse{Matches: results}
switch {
case len(results) == 0:
resp.Hint = buildNoMatchHint(all, paths, tags, tagMode)
case out.FilenameFallback:
resp.Fallback = "No path globs matched. Results inferred from filename tokens; verify relevance."
default:
resp.Tip = buildMatchTip(results)
}
return marshalToolResult(resp)
}
func (t *SnippetTool) list(all []snippets.Snippet, params map[string]any) (*ToolResult, error) {
tags := extractStringSlice(params, "tags")
tagMode := stringParam(params, "tag_mode")
filtered := snippets.FilterByTags(all, tags, tagMode)
results := make([]listResult, len(filtered))
for i := range filtered {
results[i] = listResult{
Name: filtered[i].Name,
Description: filtered[i].Description,
Tags: filtered[i].Tags,
Paths: filtered[i].Paths,
Related: filtered[i].Related,
}
}
return marshalToolResult(results)
}
func (t *SnippetTool) tags(all []snippets.Snippet) (*ToolResult, error) {
return marshalToolResult(snippets.GroupByTags(all))
}
func marshalToolResult(v any) (*ToolResult, error) {
data, err := json.Marshal(v)
if err != nil {
return nil, fmt.Errorf("marshal result: %w", err)
}
return &ToolResult{Output: string(data)}, nil
}
func extractStringSlice(args map[string]any, key string) []string {
raw, ok := args[key].([]any)
if !ok {
return nil
}
out := make([]string, 0, len(raw))
for _, item := range raw {
if s, ok := item.(string); ok {
out = append(out, s)
}
}
return out
}
func buildNoMatchHint(all []snippets.Snippet, paths, tags []string, tagMode string) string {
var parts []string
if len(paths) > 0 {
parts = append(parts, fmt.Sprintf("No snippets matched paths %v.", paths))
var tagOnly int
seen := make(map[string]struct{})
var tagOnlyTags []string
for i := range all {
if len(all[i].Paths) != 0 {
continue
}
tagOnly++
for _, tag := range all[i].Tags {
if _, ok := seen[tag]; ok {
continue
}
seen[tag] = struct{}{}
tagOnlyTags = append(tagOnlyTags, tag)
}
}
if tagOnly > 0 {
parts = append(parts, fmt.Sprintf("%d snippets have no path globs (tag-only). Try tags from: %s", tagOnly, strings.Join(tagOnlyTags, ", ")))
}
}
if len(tags) > 0 {
modeLabel := snippetModeAND
if strings.EqualFold(tagMode, "or") {
modeLabel = "OR"
}
parts = append(parts, fmt.Sprintf("No snippets matched tags %v (%s mode). Use snippets(action=\"tags\") to inspect available tags.", tags, modeLabel))
if modeLabel == snippetModeAND && len(tags) > 1 {
parts = append(parts, "Tip: try tag_mode=\"or\".")
}
}
if len(parts) == 0 {
return "No matching snippets found. Use snippets(action=\"list\") or snippets(action=\"tags\") to explore."
}
return strings.Join(parts, " ")
}
// filterByRequires removes snippets whose Requires are not satisfied by the
// detected project tech stack. A nil techStack means detection was not
// configured — all snippets are returned. A non-nil (even empty) techStack
// means detection ran and snippets with unmet requires are excluded.
func filterByRequires(all []snippets.Snippet, techStack []string) []snippets.Snippet {
if techStack == nil {
return all
}
techSet := make(map[string]bool, len(techStack))
for _, t := range techStack {
techSet[t] = true
}
filtered := make([]snippets.Snippet, 0, len(all))
for i := range all {
met := true
for _, r := range all[i].Requires {
if !techSet[r] {
met = false
break
}
}
if met {
filtered = append(filtered, all[i])
}
}
return filtered
}
func buildMatchTip(results []matchResult) string {
var hasPlaceholders bool
relatedSet := make(map[string]struct{})
for i := range results {
if len(results[i].Placeholders) > 0 {
hasPlaceholders = true
}
for _, rel := range results[i].Related {
relatedSet[rel] = struct{}{}
}
}
var parts []string
if hasPlaceholders {
parts = append(parts, "Replace <Placeholder> tokens with project-specific names using the placeholders field.")
}
if len(relatedSet) > 0 {
names := make([]string, 0, len(relatedSet))
for name := range relatedSet {
names = append(names, name)
}
parts = append(parts, "To get related conventions, call snippets(query=\"<name>\") for: "+strings.Join(names, ", "))
}
return strings.Join(parts, " ")
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tools
import (
"context"
"fmt"
"io"
"net/http"
"strings"
"time"
htmltomd "github.com/JohannesKaufmann/html-to-markdown/v2"
"github.com/raoptimus/kodrun/internal/llm"
"github.com/raoptimus/kodrun/internal/rag"
)
const (
webFetchTimeout = 30 * time.Second
webFetchMaxBodySize = 10 << 20 // 10 MB
webFetchChunkSize = 40 // lines per chunk
webFetchChunkOverlap = 5
webFetchMaxOutput = 4000 // bytes, for truncated fallback
webFetchTopMatches = 5 // text-match fallback results
)
// WebIndexer is the interface for indexing and searching web content via RAG.
type WebIndexer interface {
Build(ctx context.Context, chunks []rag.Chunk) (int, error)
Search(ctx context.Context, query string, topK int) ([]rag.SearchResult, error)
}
// WebFetchTool downloads a web page, converts HTML to markdown, and returns
// relevant content. When a WebIndexer is provided (RAG enabled), the content
// is embedded into a session-scoped index and searched semantically. Without
// RAG, a simple text-match fallback is used.
type WebFetchTool struct {
indexer WebIndexer // nil when RAG is disabled
topK int
}
// NewWebFetchTool creates a new web_fetch tool. The indexer is optional:
// pass nil when RAG is disabled.
func NewWebFetchTool(indexer WebIndexer, topK int) *WebFetchTool {
if topK <= 0 {
topK = webFetchTopMatches
}
return &WebFetchTool{indexer: indexer, topK: topK}
}
func (t *WebFetchTool) Name() string { return NameWebFetch }
func (t *WebFetchTool) IsReadOnly() bool { return true }
func (t *WebFetchTool) Description() string {
return "Fetch a web page, convert HTML to markdown, and return relevant content. " +
"With RAG enabled the content is indexed for semantic search; without RAG a keyword search is used."
}
func (t *WebFetchTool) Schema() llm.JSONSchema {
return llm.JSONSchema{
Type: "object",
Properties: map[string]llm.JSONSchema{
"url": {Type: "string", Description: "URL of the web page to fetch"},
"query": {Type: "string", Description: "Optional search query to find relevant sections in the page"},
},
Required: []string{"url"},
}
}
func (t *WebFetchTool) Execute(ctx context.Context, params map[string]any) (*ToolResult, error) {
rawURL := stringParam(params, "url")
if rawURL == "" {
return nil, &ToolError{Msg: "url is required"}
}
query := stringParam(params, "query")
markdown, err := t.fetchAndConvert(ctx, rawURL)
if err != nil {
return nil, fmt.Errorf("fetch: %w", err)
}
if len(strings.TrimSpace(markdown)) == 0 {
return &ToolResult{Output: "Page returned empty content."}, nil
}
source := "web://" + rawURL
chunks := rag.ChunkText(source, markdown, webFetchChunkSize, webFetchChunkOverlap)
if t.indexer != nil {
return t.executeWithRAG(ctx, chunks, query)
}
return t.executeWithTextMatch(chunks, query), nil
}
func (t *WebFetchTool) fetchAndConvert(ctx context.Context, rawURL string) (string, error) {
client := &http.Client{Timeout: webFetchTimeout}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, http.NoBody)
if err != nil {
return "", fmt.Errorf("create request: %w", err)
}
req.Header.Set("User-Agent", "kodrun/1.0")
resp, err := client.Do(req)
if err != nil {
return "", fmt.Errorf("http get: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("http status %d", resp.StatusCode)
}
limited := io.LimitReader(resp.Body, webFetchMaxBodySize)
markdownBytes, err := htmltomd.ConvertReader(limited)
if err != nil {
return "", fmt.Errorf("convert html to markdown: %w", err)
}
return string(markdownBytes), nil
}
func (t *WebFetchTool) executeWithRAG(ctx context.Context, chunks []rag.Chunk, query string) (*ToolResult, error) {
if _, err := t.indexer.Build(ctx, chunks); err != nil {
return nil, fmt.Errorf("index: %w", err)
}
if query == "" {
// No query — return first chunks as summary.
return t.summarizeChunks(chunks), nil
}
results, err := t.indexer.Search(ctx, query, t.topK)
if err != nil {
return nil, fmt.Errorf("search: %w", err)
}
if len(results) == 0 {
return &ToolResult{Output: "No relevant results found for the query."}, nil
}
return t.formatSearchResults(results), nil
}
func (t *WebFetchTool) executeWithTextMatch(chunks []rag.Chunk, query string) *ToolResult {
if query == "" {
return t.summarizeChunks(chunks)
}
words := strings.Fields(strings.ToLower(query))
var matched []rag.Chunk
for _, c := range chunks {
lower := strings.ToLower(c.Content)
for _, w := range words {
if strings.Contains(lower, w) {
matched = append(matched, c)
break
}
}
}
if len(matched) == 0 {
return &ToolResult{Output: "No matching content found for the query."}
}
if len(matched) > webFetchTopMatches {
matched = matched[:webFetchTopMatches]
}
var b strings.Builder
for i, c := range matched {
fmt.Fprintf(&b, "--- Match %d (lines %d-%d) ---\n%s\n\n", i+1, c.StartLine, c.EndLine, c.Content)
}
return &ToolResult{
Output: b.String(),
Meta: map[string]any{"matches": len(matched)},
}
}
func (t *WebFetchTool) summarizeChunks(chunks []rag.Chunk) *ToolResult {
var b strings.Builder
for _, c := range chunks {
if b.Len()+len(c.Content) > webFetchMaxOutput {
remaining := webFetchMaxOutput - b.Len()
if remaining > 0 {
b.WriteString(c.Content[:remaining])
}
b.WriteString("\n\n[truncated]")
break
}
b.WriteString(c.Content)
b.WriteByte('\n')
}
return &ToolResult{
Output: b.String(),
Meta: map[string]any{"truncated": b.Len() >= webFetchMaxOutput},
}
}
func (t *WebFetchTool) formatSearchResults(results []rag.SearchResult) *ToolResult {
var b strings.Builder
for i, r := range results {
fmt.Fprintf(&b, "--- Result %d (%.2f, lines %d-%d) ---\n%s\n\n",
i+1, r.Score, r.Chunk.StartLine, r.Chunk.EndLine, r.Chunk.Content)
}
return &ToolResult{
Output: b.String(),
Meta: map[string]any{"results": len(results)},
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tui
import (
"bufio"
"os"
"path/filepath"
"strings"
)
const (
historyFile = ".kodrun/history"
dirPermission = 0o755
filePermission = 0o644
)
// LoadHistory reads command history from .kodrun/history file.
// Deduplicates entries keeping the last occurrence and limits to maxSize.
func LoadHistory(workDir string, maxSize int) []string {
path := filepath.Join(workDir, historyFile)
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var lines []string
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line != "" {
lines = append(lines, line)
}
}
lines = dedup(lines)
if len(lines) > maxSize {
lines = lines[len(lines)-maxSize:]
}
return lines
}
// dedup removes duplicate entries keeping the last occurrence of each.
func dedup(lines []string) []string {
seen := make(map[string]struct{}, len(lines))
result := make([]string, 0, len(lines))
// Iterate from end to preserve last occurrence.
for i := len(lines) - 1; i >= 0; i-- {
if _, ok := seen[lines[i]]; ok {
continue
}
seen[lines[i]] = struct{}{}
result = append(result, lines[i])
}
// Reverse to restore chronological order.
for i, j := 0, len(result)-1; i < j; i, j = i+1, j-1 {
result[i], result[j] = result[j], result[i]
}
return result
}
// appendCount tracks writes since last trim to avoid trimming on every append.
var appendCount int
// AppendHistory adds an entry to the history file.
func AppendHistory(workDir, entry string, maxSize int) {
entry = strings.TrimSpace(entry)
if entry == "" {
return
}
path := filepath.Join(workDir, historyFile)
// Ensure .kodrun/ directory exists
dir := filepath.Dir(path)
if err := os.MkdirAll(dir, dirPermission); err != nil {
return
}
f, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, filePermission)
if err != nil {
return
}
defer f.Close()
if _, err := f.WriteString(entry + "\n"); err != nil {
return
}
// Periodically trim and deduplicate the file.
appendCount++
if appendCount%50 == 0 {
TrimHistory(workDir, maxSize)
}
}
// TrimHistory deduplicates and truncates the history file to maxSize entries.
func TrimHistory(workDir string, maxSize int) {
path := filepath.Join(workDir, historyFile)
lines := LoadHistory(workDir, maxSize)
if len(lines) == 0 {
return
}
f, err := os.Create(path)
if err != nil {
return
}
defer f.Close()
for _, line := range lines {
if _, err := f.WriteString(line + "\n"); err != nil {
return
}
}
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tui
// Locale provides localized UI strings.
type Locale struct {
lang string
strings map[string]string
}
// NewLocale creates a locale for the given language code.
// Falls back to English for unknown languages.
func NewLocale(lang string) *Locale {
strings, ok := translations[lang]
if !ok {
strings = translations["en"]
}
return &Locale{lang: lang, strings: strings}
}
// Get returns the localized string for the given key.
// Returns the key itself if not found.
func (l *Locale) Get(key string) string {
if s, ok := l.strings[key]; ok {
return s
}
// Fallback to English.
if s, ok := translations["en"][key]; ok {
return s
}
return key
}
var translations = map[string]map[string]string{
"en": {
// Placeholders
"placeholder.plan": "Describe task for planning...",
"placeholder.edit": "Enter task or /command...",
"placeholder.chat": "Ask a question or discuss...",
"placeholder.constraint": "Describe constraint...",
// Confirm
"confirm.opt.allow_once": "Yes, proceed",
"confirm.opt.allow_session": "Yes, don't ask again for this command",
"confirm.opt.edit": "Edit / add constraint",
"confirm.opt.deny": "No, deny",
"confirm.card.tool": "Tool:",
"confirm.card.preview": "preview:",
"confirm.card.danger": "WARNING: dangerous command",
"confirm.hint": "↑↓ navigate · enter confirm · esc cancel",
"confirm.enter_constraint": " ↩ enter constraint (Esc to cancel):",
"confirm.augment": " ↩ augment: ",
// Status
"status.switched_edit": "Switched to edit mode (send message to start)",
"status.switched_plan": "Switched to plan mode (think enabled)",
"status.switched_chat": "Switched to chat mode",
"status.cancelled": "cancelled",
"status.task_running": "task already running, press Esc to cancel",
"status.mouse_on": "mouse capture enabled",
"status.mouse_off": "mouse capture disabled",
"status.context_dump": "── context dump ──",
"status.context_end": "── end context ──",
"status.init": "Initializing...",
"status.working": "Working",
// Labels
"label.plan_mode": "plan mode on",
"label.edit_mode": "accept edits on",
"label.chat_mode": "chat mode on",
"label.shift_tab": "(shift+tab)",
"label.context_left": "%d%% left",
// Events
"event.fix": "[fix] ",
"event.error": "[error] ",
"event.compact": "[compact] ",
"event.done": "[done] ",
// Summary
"summary.files": "\n Files: ",
"summary.lines": "\n Lines: +%d -%d",
"summary.added": "%d added",
"summary.modified": "%d modified",
"summary.deleted": "%d deleted",
"summary.renamed": "%d renamed",
"summary.tool_calls": "%d tool calls",
"summary.avg_tks": "%.1f avg tk/s",
"summary.peak_ctx": "peak %d%% ctx",
// Avatars
"avatar.user": "You",
"avatar.agent": "Agent",
// Diff
"diff.more_lines": " ... (%d more lines)",
// Plan confirm
"plan_confirm.auto_accept": "Yes, auto-accept edits",
"plan_confirm.manual_approve": "Yes, manually approve edits",
"plan_confirm.augment": "Type here to tell Agent what to change",
"plan_confirm.hint": "↑↓ navigate · enter confirm · esc cancel",
"plan_confirm.enter_feedback": " ↩ enter feedback (Esc to cancel):",
"plan_confirm.feedback": " ↩ feedback: ",
// Step confirm
"step_confirm.header": "Execute this step?",
"step_confirm.allow": "Execute",
"step_confirm.skip": "Skip",
"step_confirm.deny": "Cancel all",
"step_confirm.hint": "↑↓ navigate · enter confirm",
// Group
"group.more_tools": " +%d more tool uses (ctrl+o to expand)",
},
"ru": {
// Placeholders
"placeholder.plan": "Опишите задачу для планирования...",
"placeholder.edit": "Введите задачу или /команду...",
"placeholder.chat": "Задайте вопрос или обсудите...",
"placeholder.constraint": "Опишите ограничение...",
// Confirm
"confirm.opt.allow_once": "Да, выполнить",
"confirm.opt.allow_session": "Да, больше не спрашивать для этой команды",
"confirm.opt.edit": "Изменить / добавить ограничение",
"confirm.opt.deny": "Нет, отказать",
"confirm.card.tool": "Инструмент:",
"confirm.card.preview": "предпросмотр:",
"confirm.card.danger": "ВНИМАНИЕ: опасная команда",
"confirm.hint": "↑↓ навигация · enter подтвердить · esc отмена",
"confirm.enter_constraint": " ↩ введите ограничение (Esc для отмены):",
"confirm.augment": " ↩ дополнение: ",
// Status
"status.switched_edit": "Режим правок (отправьте сообщение для начала)",
"status.switched_plan": "Режим плана (think включён)",
"status.switched_chat": "Режим чата",
"status.cancelled": "отменено",
"status.task_running": "задача выполняется, нажмите Esc для отмены",
"status.mouse_on": "захват мыши включён",
"status.mouse_off": "захват мыши выключен",
"status.context_dump": "── дамп контекста ──",
"status.context_end": "── конец контекста ──",
"status.init": "Инициализация...",
"status.working": "Работаю",
// Labels
"label.plan_mode": "режим плана",
"label.edit_mode": "режим правок",
"label.chat_mode": "режим чата",
"label.shift_tab": "(shift+tab)",
"label.context_left": "%d%% свободно",
// Events
"event.fix": "[исправление] ",
"event.error": "[ошибка] ",
"event.compact": "[компакт] ",
"event.done": "[готово] ",
// Summary
"summary.files": "\n Файлы: ",
"summary.lines": "\n Строки: +%d -%d",
"summary.added": "%d добавлено",
"summary.modified": "%d изменено",
"summary.deleted": "%d удалено",
"summary.renamed": "%d переименовано",
"summary.tool_calls": "%d вызовов",
"summary.avg_tks": "%.1f сред. tk/с",
"summary.peak_ctx": "пик %d%% ctx",
// Avatars
"avatar.user": "Вы",
"avatar.agent": "Агент",
// Diff
"diff.more_lines": " ... (ещё %d строк)",
// Plan confirm
"plan_confirm.auto_accept": "Да, авто-принять правки",
"plan_confirm.manual_approve": "Да, подтверждать правки вручную",
"plan_confirm.augment": "Написать что изменить",
"plan_confirm.hint": "↑↓ навигация · enter подтвердить · esc отмена",
"plan_confirm.enter_feedback": " ↩ введите замечание (Esc для отмены):",
"plan_confirm.feedback": " ↩ замечание: ",
// Step confirm
"step_confirm.header": "Выполнить этот шаг?",
"step_confirm.allow": "Выполнить",
"step_confirm.skip": "Пропустить",
"step_confirm.deny": "Отменить всё",
"step_confirm.hint": "↑↓ навигация · enter подтвердить",
// Group
"group.more_tools": " +%d вызовов (ctrl+o развернуть)",
},
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tui
import (
"strings"
"sync"
"github.com/charmbracelet/glamour"
"github.com/charmbracelet/glamour/ansi"
)
const mdIndent = 2 // indent / margin for markdown rendering
func boolPtr(b bool) *bool { return &b }
func stringPtr(s string) *string { return &s }
func uintPtr(u uint) *uint { return &u }
// kodrunStyle is a minimal glamour style that works on both light and dark
// terminals. No background colors. Headings are bold.
// Inline code is bold (no colored background). Lists use bullet markers.
var kodrunStyle = ansi.StyleConfig{
Document: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
BlockPrefix: "\n",
},
Margin: uintPtr(0),
},
Heading: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Bold: boolPtr(true),
BlockSuffix: "\n",
},
},
H1: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Bold: boolPtr(true),
},
},
H2: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Bold: boolPtr(true),
},
},
H3: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Bold: boolPtr(true),
},
},
Paragraph: ansi.StyleBlock{},
Code: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Bold: boolPtr(true),
},
},
CodeBlock: ansi.StyleCodeBlock{
StyleBlock: ansi.StyleBlock{
Margin: uintPtr(mdIndent),
},
Chroma: &ansi.Chroma{},
},
Strong: ansi.StylePrimitive{
Bold: boolPtr(true),
},
Emph: ansi.StylePrimitive{
Italic: boolPtr(true),
},
List: ansi.StyleList{
LevelIndent: mdIndent,
StyleBlock: ansi.StyleBlock{
Margin: uintPtr(0),
},
},
Item: ansi.StylePrimitive{
BlockPrefix: "• ",
},
Enumeration: ansi.StylePrimitive{
BlockPrefix: ". ",
},
BlockQuote: ansi.StyleBlock{
StylePrimitive: ansi.StylePrimitive{
Italic: boolPtr(true),
},
Indent: uintPtr(1),
IndentToken: stringPtr("│ "),
},
Link: ansi.StylePrimitive{
Underline: boolPtr(true),
},
LinkText: ansi.StylePrimitive{
Bold: boolPtr(true),
},
Task: ansi.StyleTask{
Ticked: "[x] ",
Unticked: "[ ] ",
},
Table: ansi.StyleTable{},
}
// markdownRenderer renders markdown text for terminal display.
// Lazily initialized with the given width.
type markdownRenderer struct {
mu sync.Mutex
renderer *glamour.TermRenderer
width int
}
// render converts markdown text to styled terminal output.
// Falls back to raw text on error.
func (m *markdownRenderer) render(text string, width int) string {
m.mu.Lock()
defer m.mu.Unlock()
if m.renderer == nil || m.width != width {
r, err := glamour.NewTermRenderer(
glamour.WithStyles(kodrunStyle),
glamour.WithWordWrap(width),
)
if err != nil {
return text
}
m.renderer = r
m.width = width
}
out, err := m.renderer.Render(text)
if err != nil {
return text
}
// glamour adds trailing newlines; trim them for inline use.
return strings.TrimRight(out, "\n")
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tui
import (
"fmt"
"os"
"path/filepath"
"runtime/debug"
"strings"
"time"
"github.com/charmbracelet/bubbles/textarea"
"github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"github.com/muesli/reflow/wordwrap"
"github.com/muesli/reflow/wrap"
"github.com/raoptimus/kodrun/internal/agent"
)
const (
maxVisibleItems = 5
maxLogLines = 10000 // trim logs to prevent unbounded memory growth
inputPrompt = " > "
paddingConfirmNorm = 4
paddingConfirmSel = 2
halfPageDivisor = 2
scrollStep = 3
menuPadding = 2 // extra lines around menu options
layoutExtraLines = 5 // fixed layout lines (gaps, toolbar, etc.)
logTrimDivisor = 5 // trim 1/5 of logs when exceeding max
percentMultiplier = 100 // for percentage calculations
diffPreviewLines = 20 // max lines in diff preview
kilo = 1000 // 1K tokens
kibi = 1024 // 1Ki context units
paddingLeftDefault = 4 // default PaddingLeft for styles
spinnerFrameCount = 10 // number of frames in the braille spinner
dividerLabelPad = 2 // spaces around phase divider label
dividerMinPad = 4 // minimum total padding for phase divider
dividerHalf = 2 // divisor for centering divider label
pickerScrollHalf = 2 // divisor for centering picker scroll window
)
var (
titleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12"))
agentStyle = lipgloss.NewStyle()
toolStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("4"))
toolBoldStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4"))
fixStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
errorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
confirmHeaderStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("4"))
confirmNormal = lipgloss.NewStyle().PaddingLeft(paddingConfirmNorm)
confirmSelected = lipgloss.NewStyle().PaddingLeft(paddingConfirmSel).Bold(true).Foreground(lipgloss.Color("2"))
confirmHintStyle = lipgloss.NewStyle().PaddingLeft(paddingLeftDefault).Foreground(lipgloss.Color("8"))
augmentStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
tokenStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("5"))
systemStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
diffAddStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
diffDelStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
diffHunkStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("6"))
diffStatStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
toolbarStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
// Left-border accent characters for user/agent messages.
userAccent = lipgloss.NewStyle().Foreground(lipgloss.Color("2")).Render("▎") + " "
agentAccent = lipgloss.NewStyle().Foreground(lipgloss.Color("4")).Render("▎") + " "
completeBorder = lipgloss.NewStyle().Border(lipgloss.RoundedBorder()).BorderForeground(lipgloss.Color("8"))
completeNormal = lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1)
completeSelected = lipgloss.NewStyle().PaddingLeft(1).PaddingRight(1).Bold(true).Reverse(true)
// spinnerFrames contains braille dot characters for animated spinner.
spinnerFrames = [spinnerFrameCount]string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
)
// CommandItem describes a slash command available in autocomplete.
type CommandItem struct {
Name string
Description string
Template string
}
// TaskFunc is a function that runs the agent for a given input.
type TaskFunc func(input string)
// CancelFunc cancels the currently running task.
type CancelFunc func()
// ContextFunc returns the current conversation context formatted as a string.
type ContextFunc func() string
// SetModeFn is a callback to set the agent mode and think flag.
type SetModeFn func(mode agent.Mode, think bool)
// confirmState tracks the sub-state of the confirmation prompt.
type confirmState int
const (
confirmChoose confirmState = iota // Choosing Y/A/E/N
confirmEdit // Typing augment text
)
// confirmOption describes one item in the confirm menu.
type confirmOption struct {
labelKey string
shortcut string
action agent.ConfirmAction
}
var confirmMenuOptions = []confirmOption{
{labelKey: "confirm.opt.allow_once", shortcut: "1", action: agent.ConfirmAllowOnce},
{labelKey: "confirm.opt.allow_session", shortcut: "2", action: agent.ConfirmAllowSession},
{labelKey: "confirm.opt.edit", shortcut: "3", action: agent.ConfirmAugment},
{labelKey: "confirm.opt.deny", shortcut: "4", action: agent.ConfirmDeny},
}
// ConfirmRequest represents a pending confirmation from the agent.
type ConfirmRequest struct {
Payload agent.ConfirmPayload
Result chan agent.ConfirmResult
}
// ConfirmMsg wraps a ConfirmRequest as a tea.Msg.
type ConfirmMsg struct {
Request ConfirmRequest
}
// planConfirmOption describes one item in the plan confirm menu.
type planConfirmOption struct {
labelKey string
shortcut string
action agent.PlanConfirmAction
}
var planConfirmMenuOptions = []planConfirmOption{
{labelKey: "plan_confirm.auto_accept", shortcut: "1", action: agent.PlanAutoAccept},
{labelKey: "plan_confirm.manual_approve", shortcut: "2", action: agent.PlanManualApprove},
{labelKey: "plan_confirm.augment", shortcut: "3", action: agent.PlanAugment},
}
// PlanConfirmRequest represents a pending plan confirmation from the orchestrator.
type PlanConfirmRequest struct {
Plan string
Result chan agent.PlanConfirmResult
}
// PlanConfirmMsg wraps a PlanConfirmRequest as a tea.Msg.
type PlanConfirmMsg struct {
Request PlanConfirmRequest
}
// stepConfirmOption describes one item in the step confirm menu.
type stepConfirmOption struct {
labelKey string
shortcut string
action agent.StepConfirmAction
}
var stepConfirmMenuOptions = []stepConfirmOption{
{labelKey: "step_confirm.allow", shortcut: "1", action: agent.StepAllow},
{labelKey: "step_confirm.skip", shortcut: "2", action: agent.StepSkip},
{labelKey: "step_confirm.deny", shortcut: "3", action: agent.StepDenyAll},
}
// StepConfirmRequest represents a pending step confirmation from the orchestrator.
type StepConfirmRequest struct {
Description string
Result chan agent.StepConfirmAction
}
// StepConfirmMsg wraps a StepConfirmRequest as a tea.Msg.
type StepConfirmMsg struct {
Request StepConfirmRequest
}
// ModelPickerRequest represents a pending model selection from the user.
type ModelPickerRequest struct {
Models []string // available model names
Current string // currently active model
Result chan string // selected model name ("" on cancel)
}
// ModelPickerMsg wraps a ModelPickerRequest as a tea.Msg.
type ModelPickerMsg struct {
Request ModelPickerRequest
}
// EventMsg wraps an agent event as a tea.Msg.
type EventMsg struct {
Event agent.Event
}
// tickMsg is emitted every second while running for timer updates.
type tickMsg time.Time
type focusTarget int
const (
focusInput focusTarget = iota
focusViewport
)
// Model is the bubbletea model for the KodRun TUI.
type Model struct {
viewport viewport.Model
textinput textarea.Model
logs []string
model string
version string
contextSize int
taskFn TaskFunc
cancelFn CancelFunc
events chan agent.Event
ready bool
width int
height int
running bool
workDir string
// RAG indexing progress (background). When ragProgressTotal > 0 the status
// bar shows a percentage indicator. Reset to zero on completion.
ragProgressDone int
ragProgressTotal int
ragProgressLabel string
commands []CommandItem
showComplete bool
filtered []CommandItem
selectedIdx int
// Input history (persisted to .kodrun/history)
inputHistory []string
historyIdx int // -1 = current input, 0..len-1 = history position
currentInput string // saved current input when navigating history
// Confirmation
confirmCh chan ConfirmRequest
pendingConfirm *ConfirmRequest
confirmSt confirmState
confirmIdx int // selected menu item (0-based)
savedInput string
// Plan confirmation (3-option dialog)
planConfirmCh chan PlanConfirmRequest
pendingPlanConfirm *PlanConfirmRequest
planConfirmSt confirmState
planConfirmIdx int
// Step confirmation (per-step in DAG execution)
stepConfirmCh chan StepConfirmRequest
pendingStepConfirm *StepConfirmRequest
stepConfirmIdx int
// Model picker (interactive model selection)
modelPickerCh chan ModelPickerRequest
pendingModelPicker *ModelPickerRequest
modelPickerIdx int
// Group collapsing — legacy single active group (used by Analyze() in
// the planner path and any caller that doesn't set GroupID).
groupActive bool
groupTitle string
groupLogs []string
groupStartIdx int
groupExpanded bool
// Multi-group state: used when events carry a non-empty GroupID
// (parallel specialist reviewers). Each group occupies a contiguous
// range in m.logs which is spliced in place as new events arrive.
groups map[string]*groupState
groupsExpandedAll bool
// Timer and token tracking
taskStart time.Time
pauseStart time.Time // when timer was paused (zero if not paused)
pausedDuration time.Duration // accumulated paused time
totalPrompt int
totalEval int
liveEval int // tokens generated during current inference (reset on EventTokens)
lastTkPerSec float64
// Context usage tracking
contextUsed int
contextTotal int
// Spinner animation frame index (cycles through spinnerFrames).
spinnerFrame int
// Block 6: observability fields populated by orchestrator events.
phase string
cacheHits int64
cacheMisses int64
replans int
// Mode and thinking
mode agent.Mode
think bool
setModeFn SetModeFn
// System prompt tokens
systemPromptTokens int
// Context dump (legacy, unused)
contextFn ContextFunc
// Transcript viewer: full tool output + reasoning.
transcriptLogs []string
transcriptMode bool
focus focusTarget
mouseEnabled bool
maxHistory int
locale *Locale
// Markdown rendering
mdRenderer *markdownRenderer
// Number of log entries occupied by the header block.
headerLineCount int
}
func (m *Model) defaultPlaceholder() string {
switch m.mode {
case agent.ModePlan:
return m.locale.Get("placeholder.plan")
case agent.ModeChat:
return m.locale.Get("placeholder.chat")
default:
return m.locale.Get("placeholder.edit")
}
}
// NewModel creates a new TUI model.
func NewModel(modelName, version string, contextSize int, taskFn TaskFunc, cancelFn CancelFunc, events chan agent.Event, commands []CommandItem, confirmCh chan ConfirmRequest, planConfirmCh chan PlanConfirmRequest, stepConfirmCh chan StepConfirmRequest, modelPickerCh chan ModelPickerRequest, workDir string, mode agent.Mode, think bool, setModeFn SetModeFn, contextFn ContextFunc, lang string, maxHistory int) Model {
locale := NewLocale(lang)
ti := textarea.New()
if mode == agent.ModePlan {
ti.Placeholder = locale.Get("placeholder.plan")
} else {
ti.Placeholder = locale.Get("placeholder.edit")
}
ti.Prompt = inputPrompt
ti.ShowLineNumbers = false
ti.EndOfBufferCharacter = ' '
inputBg := lipgloss.Color("255")
ti.FocusedStyle.Base = lipgloss.NewStyle().Background(inputBg)
ti.FocusedStyle.Prompt = lipgloss.NewStyle().Background(inputBg).Foreground(lipgloss.Color("0")).Bold(true)
ti.FocusedStyle.Placeholder = lipgloss.NewStyle().Background(inputBg).Foreground(lipgloss.Color("240"))
ti.FocusedStyle.Text = lipgloss.NewStyle().Background(inputBg).Foreground(lipgloss.Color("0"))
ti.FocusedStyle.CursorLine = lipgloss.NewStyle().Background(inputBg).Foreground(lipgloss.Color("0"))
ti.BlurredStyle = ti.FocusedStyle
ti.SetPromptFunc(1, func(line int) string {
if line == 0 {
return inputPrompt
}
return " "
})
ti.CharLimit = 2000
ti.SetHeight(1)
ti.MaxHeight = 6
ti.Focus()
if maxHistory <= 0 {
maxHistory = 100
}
history := LoadHistory(workDir, maxHistory)
m := Model{
textinput: ti,
model: modelName,
version: version,
contextSize: contextSize,
taskFn: taskFn,
cancelFn: cancelFn,
events: events,
commands: commands,
confirmCh: confirmCh,
planConfirmCh: planConfirmCh,
stepConfirmCh: stepConfirmCh,
modelPickerCh: modelPickerCh,
workDir: workDir,
historyIdx: -1,
inputHistory: history,
mode: mode,
think: think,
setModeFn: setModeFn,
contextFn: contextFn,
focus: focusInput,
mouseEnabled: true,
maxHistory: maxHistory,
locale: locale,
mdRenderer: &markdownRenderer{},
}
header := m.headerLines()
m.logs = header
m.headerLineCount = len(header)
return m
}
// Init implements tea.Model.
func (m Model) Init() tea.Cmd {
cmds := []tea.Cmd{
m.textinput.Focus(),
m.waitForEvent(),
m.waitForConfirm(),
}
if m.planConfirmCh != nil {
cmds = append(cmds, m.waitForPlanConfirm())
}
if m.stepConfirmCh != nil {
cmds = append(cmds, m.waitForStepConfirm())
}
if m.modelPickerCh != nil {
cmds = append(cmds, m.waitForModelPicker())
}
return tea.Batch(cmds...)
}
func (m *Model) waitForEvent() tea.Cmd {
return func() tea.Msg {
event, ok := <-m.events
if !ok {
return nil
}
return EventMsg{Event: event}
}
}
// executeConfirmAction sends the given action and resets confirm state.
func (m *Model) executeConfirmAction(action agent.ConfirmAction) tea.Cmd {
m.pendingConfirm.Result <- agent.ConfirmResult{Action: action}
if !m.pauseStart.IsZero() {
m.pausedDuration += time.Since(m.pauseStart)
m.pauseStart = time.Time{}
}
m.pendingConfirm = nil
m.focus = focusInput
m.textinput.Focus()
m.recalcViewport()
return m.waitForConfirm()
}
// executeConfirmOption handles selection of a confirm menu option.
func (m *Model) executeConfirmOption(opt confirmOption) tea.Cmd {
if opt.action == agent.ConfirmAugment {
m.confirmSt = confirmEdit
m.savedInput = m.textinput.Value()
m.textinput.SetValue("")
m.textinput.Placeholder = m.locale.Get("placeholder.constraint")
m.textinput.Focus()
m.addLog(augmentStyle.Render(m.locale.Get("confirm.enter_constraint")))
m.recalcViewport()
return nil
}
return m.executeConfirmAction(opt.action)
}
func (m Model) waitForConfirm() tea.Cmd {
return func() tea.Msg {
req, ok := <-m.confirmCh
if !ok {
return nil
}
return ConfirmMsg{Request: req}
}
}
func (m Model) waitForPlanConfirm() tea.Cmd {
return func() tea.Msg {
req, ok := <-m.planConfirmCh
if !ok {
return nil
}
return PlanConfirmMsg{Request: req}
}
}
func (m Model) waitForModelPicker() tea.Cmd {
return func() tea.Msg {
req, ok := <-m.modelPickerCh
if !ok {
return nil
}
return ModelPickerMsg{Request: req}
}
}
// executePlanConfirmAction sends the given plan confirm action and resets state.
func (m *Model) executePlanConfirmAction(action agent.PlanConfirmAction) tea.Cmd {
m.pendingPlanConfirm.Result <- agent.PlanConfirmResult{Action: action}
if !m.pauseStart.IsZero() {
m.pausedDuration += time.Since(m.pauseStart)
m.pauseStart = time.Time{}
}
for _, opt := range planConfirmMenuOptions {
if opt.action == action {
m.addLog(confirmHeaderStyle.Render(fmt.Sprintf(" → %s", m.locale.Get(opt.labelKey))))
break
}
}
m.pendingPlanConfirm = nil
m.focus = focusInput
m.textinput.Focus()
m.recalcViewport()
return m.waitForPlanConfirm()
}
// executePlanConfirmOption handles selection of a plan confirm menu option.
func (m *Model) executePlanConfirmOption(opt planConfirmOption) tea.Cmd {
if opt.action == agent.PlanAugment {
m.planConfirmSt = confirmEdit
m.savedInput = m.textinput.Value()
m.textinput.SetValue("")
m.textinput.Placeholder = m.locale.Get("placeholder.constraint")
m.textinput.Focus()
m.addLog(augmentStyle.Render(m.locale.Get("plan_confirm.enter_feedback")))
m.recalcViewport()
return nil
}
return m.executePlanConfirmAction(opt.action)
}
func (m Model) waitForStepConfirm() tea.Cmd {
return func() tea.Msg {
req, ok := <-m.stepConfirmCh
if !ok {
return nil
}
return StepConfirmMsg{Request: req}
}
}
// executeStepConfirmAction sends the given step confirm action and resets state.
func (m *Model) executeStepConfirmAction(action agent.StepConfirmAction) tea.Cmd {
m.pendingStepConfirm.Result <- action
if !m.pauseStart.IsZero() {
m.pausedDuration += time.Since(m.pauseStart)
m.pauseStart = time.Time{}
}
for _, opt := range stepConfirmMenuOptions {
if opt.action == action {
m.addLog(confirmHeaderStyle.Render(fmt.Sprintf(" → %s", m.locale.Get(opt.labelKey))))
break
}
}
m.pendingStepConfirm = nil
m.focus = focusInput
m.textinput.Focus()
m.recalcViewport()
return m.waitForStepConfirm()
}
func tickEvery() tea.Cmd {
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
return tickMsg(t)
})
}
// Update implements tea.Model.
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch msg := msg.(type) {
case tickMsg:
if m.running {
m.spinnerFrame = (m.spinnerFrame + 1) % spinnerFrameCount
}
if m.running || m.ragProgressTotal > 0 {
cmds = append(cmds, tickEvery())
}
return m, tea.Batch(cmds...)
case tea.KeyMsg:
// Handle model picker
if m.pendingModelPicker != nil {
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
switch msg.Type {
case tea.KeyUp:
if m.modelPickerIdx > 0 {
m.modelPickerIdx--
}
return m, nil
case tea.KeyDown:
if m.modelPickerIdx < len(m.pendingModelPicker.Models)-1 {
m.modelPickerIdx++
}
return m, nil
case tea.KeyEnter:
selected := m.pendingModelPicker.Models[m.modelPickerIdx]
m.pendingModelPicker.Result <- selected
m.pendingModelPicker = nil
m.recalcViewport()
return m, m.waitForModelPicker()
case tea.KeyEsc:
m.pendingModelPicker.Result <- ""
m.pendingModelPicker = nil
m.recalcViewport()
return m, m.waitForModelPicker()
}
return m, nil
}
// Handle confirmation prompt
if m.pendingConfirm != nil {
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
switch m.confirmSt {
case confirmChoose:
switch msg.Type {
case tea.KeyUp:
if m.confirmIdx > 0 {
m.confirmIdx--
}
return m, nil
case tea.KeyDown:
if m.confirmIdx < len(confirmMenuOptions)-1 {
m.confirmIdx++
}
return m, nil
case tea.KeyEnter:
cmd := m.executeConfirmOption(confirmMenuOptions[m.confirmIdx])
return m, cmd
case tea.KeyEsc:
cmd := m.executeConfirmAction(agent.ConfirmDeny)
return m, cmd
default:
switch msg.String() {
case "1":
cmd := m.executeConfirmAction(agent.ConfirmAllowOnce)
return m, cmd
case "2":
cmd := m.executeConfirmAction(agent.ConfirmAllowSession)
return m, cmd
case "3":
cmd := m.executeConfirmOption(confirmMenuOptions[2])
return m, cmd
case "4":
cmd := m.executeConfirmAction(agent.ConfirmDeny)
return m, cmd
}
}
return m, nil
case confirmEdit:
switch msg.Type {
case tea.KeyEsc:
m.textinput.SetValue(m.savedInput)
m.textinput.Placeholder = m.defaultPlaceholder()
m.confirmSt = confirmChoose
return m, nil
case tea.KeyEnter:
text := strings.TrimSpace(m.textinput.Value())
if text == "" {
return m, nil
}
m.pendingConfirm.Result <- agent.ConfirmResult{Action: agent.ConfirmAugment, Augment: text}
m.addLog(augmentStyle.Render(m.locale.Get("confirm.augment") + text))
m.textinput.SetValue(m.savedInput)
m.textinput.Placeholder = m.defaultPlaceholder()
m.pendingConfirm = nil
m.confirmSt = confirmChoose
m.focus = focusInput
m.textinput.Focus()
return m, m.waitForConfirm()
default:
var cmd tea.Cmd
m.textinput, cmd = m.textinput.Update(msg)
m.resizeInput()
return m, cmd
}
}
return m, nil
}
// Handle plan confirmation prompt (3-option)
if m.pendingPlanConfirm != nil {
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
switch m.planConfirmSt {
case confirmChoose:
switch msg.Type {
case tea.KeyUp:
if m.planConfirmIdx > 0 {
m.planConfirmIdx--
}
return m, nil
case tea.KeyDown:
if m.planConfirmIdx < len(planConfirmMenuOptions)-1 {
m.planConfirmIdx++
}
return m, nil
case tea.KeyEnter:
cmd := m.executePlanConfirmOption(planConfirmMenuOptions[m.planConfirmIdx])
return m, cmd
case tea.KeyEsc:
cmd := m.executePlanConfirmAction(agent.PlanDeny)
return m, cmd
default:
switch msg.String() {
case "1":
cmd := m.executePlanConfirmAction(agent.PlanAutoAccept)
return m, cmd
case "2":
cmd := m.executePlanConfirmAction(agent.PlanManualApprove)
return m, cmd
case "3":
cmd := m.executePlanConfirmOption(planConfirmMenuOptions[2])
return m, cmd
}
}
return m, nil
case confirmEdit:
switch msg.Type {
case tea.KeyEsc:
m.textinput.SetValue(m.savedInput)
m.textinput.Placeholder = m.defaultPlaceholder()
m.planConfirmSt = confirmChoose
return m, nil
case tea.KeyEnter:
text := strings.TrimSpace(m.textinput.Value())
if text == "" {
return m, nil
}
m.pendingPlanConfirm.Result <- agent.PlanConfirmResult{Action: agent.PlanAugment, Augment: text}
m.addLog(augmentStyle.Render(m.locale.Get("plan_confirm.feedback") + text))
m.textinput.SetValue(m.savedInput)
m.textinput.Placeholder = m.defaultPlaceholder()
m.pendingPlanConfirm = nil
m.planConfirmSt = confirmChoose
m.focus = focusInput
m.textinput.Focus()
return m, m.waitForPlanConfirm()
default:
var cmd tea.Cmd
m.textinput, cmd = m.textinput.Update(msg)
m.resizeInput()
return m, cmd
}
}
return m, nil
}
// Handle step confirmation prompt (3-option: execute/skip/cancel)
if m.pendingStepConfirm != nil {
if msg.Type == tea.KeyCtrlC {
return m, tea.Quit
}
switch msg.Type {
case tea.KeyUp:
if m.stepConfirmIdx > 0 {
m.stepConfirmIdx--
}
return m, nil
case tea.KeyDown:
if m.stepConfirmIdx < len(stepConfirmMenuOptions)-1 {
m.stepConfirmIdx++
}
return m, nil
case tea.KeyEnter:
cmd := m.executeStepConfirmAction(stepConfirmMenuOptions[m.stepConfirmIdx].action)
return m, cmd
case tea.KeyEsc:
cmd := m.executeStepConfirmAction(agent.StepDenyAll)
return m, cmd
default:
switch msg.String() {
case "1":
cmd := m.executeStepConfirmAction(agent.StepAllow)
return m, cmd
case "2":
cmd := m.executeStepConfirmAction(agent.StepSkip)
return m, cmd
case "3":
cmd := m.executeStepConfirmAction(agent.StepDenyAll)
return m, cmd
}
}
return m, nil
}
if m.showComplete {
switch msg.Type {
case tea.KeyUp:
if m.selectedIdx > 0 {
m.selectedIdx--
}
return m, nil
case tea.KeyDown:
if m.selectedIdx < len(m.filtered)-1 {
m.selectedIdx++
}
return m, nil
case tea.KeyTab, tea.KeyEnter:
if msg.Alt {
break
}
if len(m.filtered) > 0 {
selected := m.filtered[m.selectedIdx]
m.textinput.SetValue("/" + selected.Name + " ")
m.textinput.CursorEnd()
m.showComplete = false
}
return m, nil
case tea.KeyEsc:
m.showComplete = false
return m, nil
}
}
// Return focus to input on any key except viewport navigation keys.
switch msg.Type {
case tea.KeyPgUp, tea.KeyPgDown, tea.KeyCtrlU, tea.KeyCtrlD:
// keep focusViewport
default:
m.focus = focusInput
}
switch msg.Type {
case tea.KeyShiftTab:
switch m.mode {
case agent.ModePlan:
m.mode = agent.ModeEdit
m.think = false
m.textinput.Placeholder = m.locale.Get("placeholder.edit")
m.addLog(systemStyle.Render(m.locale.Get("status.switched_edit")))
case agent.ModeEdit:
m.mode = agent.ModeChat
m.think = true
m.textinput.Placeholder = m.locale.Get("placeholder.chat")
m.addLog(systemStyle.Render(m.locale.Get("status.switched_chat")))
case agent.ModeChat:
m.mode = agent.ModePlan
m.think = true
m.textinput.Placeholder = m.locale.Get("placeholder.plan")
m.addLog(systemStyle.Render(m.locale.Get("status.switched_plan")))
}
if m.setModeFn != nil {
m.setModeFn(m.mode, m.think)
}
return m, nil
case tea.KeyF2:
m.mouseEnabled = !m.mouseEnabled
if m.mouseEnabled {
m.addLog(systemStyle.Render(m.locale.Get("status.mouse_on")))
return m, tea.EnableMouseCellMotion
}
m.addLog(systemStyle.Render(m.locale.Get("status.mouse_off")))
return m, tea.DisableMouse
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEsc:
if m.running {
// Cancel current task, don't quit
if m.cancelFn != nil {
m.cancelFn()
}
m.addLog(systemStyle.Render(m.locale.Get("status.cancelled")))
}
return m, nil
case tea.KeyCtrlJ:
// Ctrl+J inserts a newline (alternative to Shift+Enter).
m.textinput.InsertRune('\n')
m.resizeInput()
return m, nil
case tea.KeyEnter:
if msg.Alt {
m.textinput.InsertRune('\n')
m.resizeInput()
return m, nil
}
m.focus = focusInput
input := strings.TrimSpace(m.textinput.Value())
if input == "" {
break
}
// Handle /exit locally
if input == "/exit" || input == "/quit" {
return m, tea.Quit
}
m.inputHistory = append(m.inputHistory, input)
m.inputHistory = dedup(m.inputHistory)
if len(m.inputHistory) > m.maxHistory {
m.inputHistory = m.inputHistory[len(m.inputHistory)-m.maxHistory:]
}
m.historyIdx = -1
m.currentInput = ""
m.textinput.Reset()
m.textinput.SetHeight(1)
m.textinput.Focus()
m.recalcViewport()
m.addLog(userAccent + input)
// Persist to file
AppendHistory(m.workDir, input, m.maxHistory)
// Don't start a new task if one is already running
if m.running {
m.addLog(systemStyle.Render(m.locale.Get("status.task_running")))
return m, nil
}
m.running = true
m.taskStart = time.Now()
m.pauseStart = time.Time{}
m.pausedDuration = 0
m.totalPrompt = 0
m.totalEval = 0
m.liveEval = 0
m.lastTkPerSec = 0
m.recalcViewport()
go func() {
defer func() {
if r := recover(); r != nil {
select {
case m.events <- agent.Event{Type: agent.EventError, Message: fmt.Sprintf("task panic: %v\n%s", r, debug.Stack())}:
default:
}
select {
case m.events <- agent.Event{Type: agent.EventDone}:
default:
}
}
}()
m.taskFn(input)
}()
return m, tea.Batch(append(cmds, tickEvery())...)
case tea.KeyUp:
if m.focus == focusInput && len(m.inputHistory) > 0 && m.historyIdx < len(m.inputHistory)-1 {
if m.historyIdx == -1 {
m.currentInput = m.textinput.Value()
}
m.historyIdx++
m.textinput.SetValue(m.inputHistory[len(m.inputHistory)-1-m.historyIdx])
m.textinput.CursorEnd()
return m, nil
}
return m, nil
case tea.KeyDown:
if m.focus == focusInput && m.historyIdx > -1 {
m.historyIdx--
if m.historyIdx == -1 {
m.textinput.SetValue(m.currentInput)
} else {
m.textinput.SetValue(m.inputHistory[len(m.inputHistory)-1-m.historyIdx])
}
m.textinput.CursorEnd()
return m, nil
}
return m, nil
case tea.KeyPgUp:
m.focus = focusViewport
m.viewport.ScrollUp(m.viewport.Height / halfPageDivisor)
return m, nil
case tea.KeyPgDown:
m.focus = focusViewport
m.viewport.ScrollDown(m.viewport.Height / halfPageDivisor)
return m, nil
case tea.KeyCtrlU:
m.focus = focusViewport
m.viewport.ScrollUp(m.viewport.Height / halfPageDivisor)
return m, nil
case tea.KeyCtrlD:
m.focus = focusViewport
m.viewport.ScrollDown(m.viewport.Height / halfPageDivisor)
return m, nil
case tea.KeyCtrlL:
header := m.headerLines()
m.logs = header
m.headerLineCount = len(header)
m.transcriptLogs = nil
m.updateViewport()
case tea.KeyCtrlO:
m.transcriptMode = !m.transcriptMode
m.syncViewport()
}
case tea.MouseMsg:
if msg.Button == tea.MouseButtonLeft && msg.Action == tea.MouseActionPress {
if m.mouseInViewport(msg.Y) {
m.focus = focusViewport
} else {
m.focus = focusInput
}
}
switch msg.Button {
case tea.MouseButtonWheelUp:
if !m.mouseInViewport(msg.Y) {
return m, nil
}
m.focus = focusViewport
m.viewport.ScrollUp(scrollStep)
return m, nil
case tea.MouseButtonWheelDown:
if !m.mouseInViewport(msg.Y) {
return m, nil
}
m.focus = focusViewport
m.viewport.ScrollDown(scrollStep)
return m, nil
}
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
m.textinput.SetWidth(max(1, m.width))
if !m.ready {
m.viewport = viewport.New(m.width, m.viewportHeight())
m.viewport.MouseWheelEnabled = false // we handle mouse wheel ourselves
m.ready = true
} else {
m.viewport.Width = m.width
m.viewport.Height = m.viewportHeight()
}
m.rebuildHeader()
m.updateViewport()
case EventMsg:
e := msg.Event
switch e.Type {
case agent.EventAgent:
if e.SystemPromptTokens > 0 {
m.systemPromptTokens = e.SystemPromptTokens
m.totalPrompt = e.SystemPromptTokens
}
if e.Message != "" {
// Transcript gets agent messages too.
m.addTranscriptLog(agentStyle.Render(e.Message))
switch {
case e.GroupID != "" && m.groups[e.GroupID] != nil:
m.appendToGroup(e.GroupID, agentStyle.Render(e.Message))
case m.groupActive:
// Route intermediate agent text into the collapsed group.
m.groupLogs = append(m.groupLogs, agentStyle.Render(e.Message))
m.rebuildGroupView()
default:
prefix := agentAccent
prefixW := ansi.StringWidth(prefix)
avail := max(m.width-prefixW-paddingConfirmSel, diffPreviewLines) // diffPreviewLines reused as minAvailWidth
// Render markdown if the message contains markdown markers.
rendered := m.renderAgentMessage(e.Message, avail)
lines := strings.Split(rendered, "\n")
indent := agentAccent
var buf strings.Builder
for i, line := range lines {
if i > 0 {
buf.WriteByte('\n')
buf.WriteString(indent)
}
buf.WriteString(line)
}
m.addLog(prefix + buf.String())
// Fallback: if the agent emitted a text-form tool call
// (edit_file with old_str/new_str as prose), render it
// as a colored diff so the user sees additions/removals.
if diff := agent.ExtractDiffFromText(e.Message); diff != "" {
m.addDiffLog(diff)
}
}
}
case agent.EventTool:
toolLine := m.formatToolEvent(&e)
// Transcript always gets the full tool output.
m.addTranscriptLog(toolLine)
if e.FullOutput != "" {
m.addTranscriptLog(toolStyle.Render("──────────────────────"))
m.addTranscriptLog(e.FullOutput)
m.addTranscriptLog(toolStyle.Render("──────────────────────"))
}
// Skip cache hits in group display to reduce visual noise
// (e.g. 6 specialists reading the same file during code review).
// The call is still visible in the transcript (ctrl+o).
if e.CacheHit && e.GroupID != "" {
break
}
switch {
case e.GroupID != "" && m.groups[e.GroupID] != nil:
m.appendToGroup(e.GroupID, toolLine)
case m.groupActive:
m.groupLogs = append(m.groupLogs, toolLine)
m.rebuildGroupView()
default:
m.addLog(toolLine)
if e.FileAction != "" && e.Diff != "" {
m.addDiffLog(e.Diff)
}
}
case agent.EventGroupStart:
if e.GroupID != "" {
m.openGroup(e.GroupID, e.Message)
} else {
m.groupActive = true
m.groupTitle = e.Message
m.groupLogs = nil
m.groupExpanded = false
m.addLog(agentAccent + toolStyle.Render(e.Message))
m.groupStartIdx = len(m.logs)
}
case agent.EventGroupEnd:
if e.GroupID != "" {
m.closeGroup(e.GroupID)
} else {
m.groupActive = false
m.rebuildGroupView()
}
case agent.EventGroupTitleUpdate:
if e.GroupID != "" {
if g, ok := m.groups[e.GroupID]; ok {
g.title = e.Message
m.updateGroup(g)
}
}
m.addTranscriptLog(systemStyle.Render("─── " + e.Message + " ───"))
case agent.EventFix:
m.addLog(fixStyle.Render(m.locale.Get("event.fix") + e.Message))
case agent.EventError:
m.addLog(errorStyle.Render(fmt.Sprintf("%s%s: %s", m.locale.Get("event.error"), e.Tool, e.Message)))
case agent.EventTokens:
m.liveEval = 0
m.totalPrompt += e.PromptTokens
m.totalEval += e.EvalTokens
if e.EvalTkPerSec > 0 {
m.lastTkPerSec = e.EvalTkPerSec
}
if e.ContextUsed > 0 {
m.contextUsed = e.ContextUsed
}
if e.ContextTotal > 0 {
m.contextTotal = e.ContextTotal
}
case agent.EventInferenceProgress:
m.liveEval = e.InferenceTokens
if e.InferenceElapsed > 0 && e.InferenceTokens > 0 {
m.lastTkPerSec = float64(e.InferenceTokens) / e.InferenceElapsed.Seconds()
}
if e.InferenceContent != "" {
width := m.width
if width <= 0 {
width = 80
}
for _, line := range strings.Split(e.InferenceContent, "\n") {
wrapped := wrap.String(wordwrap.String(line, width), width)
m.addTranscriptLog(agentStyle.Render(wrapped))
}
}
case agent.EventCompact:
m.addLog(systemStyle.Render(m.locale.Get("event.compact") + e.Message))
if e.ContextUsed > 0 {
m.contextUsed = e.ContextUsed
}
if e.ContextTotal > 0 {
m.contextTotal = e.ContextTotal
}
case agent.EventModeChange:
switch e.Message {
case "edit":
m.mode = agent.ModeEdit
case agent.ModeChat.String():
m.mode = agent.ModeChat
default:
m.mode = agent.ModePlan
}
case agent.EventModelChange:
m.model = e.Message
m.rebuildHeader()
case agent.EventPhase:
m.phase = e.Message
label := strings.ToUpper(e.Message)
pad := m.width - len(label) - dividerLabelPad
if pad < dividerMinPad {
pad = dividerMinPad
}
left := pad / dividerHalf
right := pad - left
divider := strings.Repeat("═", left) + " " + label + " " + strings.Repeat("═", right)
m.addLog(systemStyle.Render(divider))
case agent.EventCacheStats:
m.cacheHits = e.CacheHits
m.cacheMisses = e.CacheMisses
m.addLog(systemStyle.Render(e.Message))
case agent.EventReplan:
m.replans++
m.addLog(systemStyle.Render(fmt.Sprintf("⟳ REPLAN requested: %s", e.Message)))
case agent.EventRAGProgress:
m.ragProgressDone = e.ProgressDone
m.ragProgressTotal = e.ProgressTotal
m.ragProgressLabel = e.ProgressLabel
if e.ProgressTotal == 0 || e.ProgressDone >= e.ProgressTotal {
// Completion or no-op: clear shortly so the bar disappears.
m.ragProgressDone = 0
m.ragProgressTotal = 0
m.ragProgressLabel = ""
}
case agent.EventDone:
elapsed := m.elapsed()
if e.Stats != nil {
m.addLog(tokenStyle.Render(m.renderSummary(e.Stats, elapsed)))
} else {
m.addLog(tokenStyle.Render(fmt.Sprintf("%s%s", m.locale.Get("event.done"), elapsed)))
}
m.running = false
m.textinput.Reset()
m.textinput.SetHeight(1)
m.textinput.Placeholder = m.defaultPlaceholder()
m.textinput.Focus()
m.recalcViewport()
}
cmds = append(cmds, m.waitForEvent())
case ConfirmMsg:
m.pendingConfirm = &msg.Request
m.pauseStart = time.Now()
m.focus = focusInput
m.confirmSt = confirmChoose
m.confirmIdx = 0
m.recalcViewport()
return m, nil
case PlanConfirmMsg:
m.pendingPlanConfirm = &msg.Request
m.pauseStart = time.Now()
m.focus = focusInput
m.planConfirmSt = confirmChoose
m.planConfirmIdx = 0
m.addLog("")
m.addLog("")
m.recalcViewport()
return m, nil
case StepConfirmMsg:
m.pendingStepConfirm = &msg.Request
m.pauseStart = time.Now()
m.focus = focusInput
m.stepConfirmIdx = 0
m.addLog("")
m.addLog(confirmHeaderStyle.Render(m.locale.Get("step_confirm.header")))
// Show step description lines
for _, line := range strings.Split(strings.TrimSpace(msg.Request.Description), "\n") {
m.addLog(" " + line)
}
m.addLog("")
m.recalcViewport()
return m, nil
case ModelPickerMsg:
m.pendingModelPicker = &msg.Request
m.modelPickerIdx = 0
for i, name := range msg.Request.Models {
if name == msg.Request.Current {
m.modelPickerIdx = i
break
}
}
m.recalcViewport()
return m, nil
}
var cmd tea.Cmd
m.textinput, cmd = m.textinput.Update(msg)
cmds = append(cmds, cmd)
m.resizeInput()
m.viewport, cmd = m.viewport.Update(msg)
cmds = append(cmds, cmd)
m.updateComplete()
return m, tea.Batch(cmds...)
}
func (m *Model) updateComplete() {
prev := m.showComplete
val := m.textinput.Value()
if !strings.HasPrefix(val, "/") || len(m.commands) == 0 {
m.showComplete = false
m.filtered = nil
if prev != m.showComplete {
m.recalcViewport()
}
return
}
query := strings.ToLower(strings.TrimPrefix(val, "/"))
if strings.Contains(query, " ") {
m.showComplete = false
m.filtered = nil
if prev != m.showComplete {
m.recalcViewport()
}
return
}
prevCount := len(m.filtered)
m.filtered = m.filtered[:0]
for _, c := range m.commands {
if query == "" || strings.Contains(strings.ToLower(c.Name), query) {
m.filtered = append(m.filtered, c)
}
}
m.showComplete = len(m.filtered) > 0
if m.selectedIdx >= len(m.filtered) {
m.selectedIdx = max(0, len(m.filtered)-1)
}
if prev != m.showComplete || prevCount != len(m.filtered) {
m.recalcViewport()
}
}
// viewportHeight calculates the available viewport height based on current state.
// Base layout: header(3) + \n + viewport + \n + completePopup + timeLine + \n + inputLine + \n + \n + toolbar(1)
func (m *Model) viewportHeight() int {
var inputH int
switch {
case m.pendingModelPicker != nil:
inputH = min(len(m.pendingModelPicker.Models), maxVisibleItems) + menuPadding
case m.pendingStepConfirm != nil:
inputH = len(stepConfirmMenuOptions) + menuPadding
case m.pendingPlanConfirm != nil && m.planConfirmSt == confirmChoose:
// 3 options + blank line + hint line
inputH = len(planConfirmMenuOptions) + menuPadding
case m.pendingConfirm != nil && m.confirmSt == confirmChoose:
// 4 options + blank line + hint line
inputH = len(confirmMenuOptions) + menuPadding
default:
inputH = max(1, m.textinput.Height()) + menuPadding // input + 2 padding lines
}
// viewport + \n + \n + inputH + \n + \n + toolbar(1)
extra := layoutExtraLines + inputH
if m.running {
extra += menuPadding // timeLine: blank line + status line
}
if m.showComplete && len(m.filtered) > 0 {
visible := min(len(m.filtered), maxVisibleItems)
extra += visible + menuPadding // items + top/bottom borders
}
return max(1, m.height-extra)
}
// resizeInput adjusts textarea height to fit content and recalculates viewport.
func (m *Model) resizeInput() {
lines := strings.Count(m.textinput.Value(), "\n") + 1
h := max(1, min(lines, m.textinput.MaxHeight))
if m.textinput.Height() != h {
m.textinput.SetHeight(h)
m.recalcViewport()
}
}
// recalcViewport adjusts viewport height for dynamic elements and refreshes content.
func (m *Model) recalcViewport() {
if !m.ready {
return
}
stickBottom := m.viewport.AtBottom() || (m.running && m.focus == focusInput)
h := m.viewportHeight()
if m.viewport.Height != h {
m.viewport.Height = h
m.updateViewportWithStickBottom(stickBottom)
}
}
// formatToolEvent formats a tool event as a single log line.
func (m Model) formatToolEvent(e *agent.Event) string {
statusIcon := "✓"
if !e.Success {
statusIcon = "✗"
}
statusStr := toolStyle.Render(statusIcon)
if e.FileAction != "" {
name := toolBoldStyle.Render(e.FileAction)
args := toolStyle.Render(fmt.Sprintf("(%s)", e.Message))
line := fmt.Sprintf("%s %s%s", statusStr, name, args)
if e.LinesAdded > 0 || e.LinesRemoved > 0 {
stat := diffAddStyle.Render(fmt.Sprintf("+%d", e.LinesAdded)) + " " +
diffDelStyle.Render(fmt.Sprintf("-%d", e.LinesRemoved))
line += " " + stat
}
return line
}
displayName := toolBoldStyle.Render(agent.ToolDisplayName(e.Tool))
if e.Message == "" {
return fmt.Sprintf("%s %s", statusStr, displayName)
}
args := toolStyle.Render(fmt.Sprintf("(%s)", e.Message))
return fmt.Sprintf("%s %s%s", statusStr, displayName, args)
}
// groupState tracks one collapsible group in the multi-group pipeline.
// Each instance occupies a contiguous range of m.logs starting at
// `startIdx` and spanning `rendered` lines (header + visible body). When a
// new event arrives for the group, its range in m.logs is spliced in place
// and every following group's startIdx is shifted by the line delta.
//
// Concurrency invariant: groupState values (and the Model.groups map) are
// mutated ONLY from within Model.Update, which bubbletea serialises on its
// own goroutine. Agent events arrive as tea.Msg values and are applied
// there — never touch these fields from a background goroutine.
type groupState struct {
id string
title string
logs []string
expanded bool
startIdx int
rendered int
closed bool
}
const groupMaxVisible = 5
// renderGroupLines returns the lines a group currently occupies in m.logs
// (header + body). The body is collapsed to the last groupMaxVisible
// entries unless expanded is true.
func (m *Model) renderGroupLines(g *groupState) []string {
lines := []string{
agentAccent + toolStyle.Render(g.title),
}
total := len(g.logs)
if total == 0 {
return lines
}
if g.expanded || total <= groupMaxVisible {
for _, line := range g.logs {
lines = append(lines, " "+line)
}
return lines
}
hidden := total - groupMaxVisible
lines = append(lines, " "+diffStatStyle.Render(fmt.Sprintf(m.locale.Get("group.more_tools"), hidden)))
for _, line := range g.logs[hidden:] {
lines = append(lines, " "+line)
}
return lines
}
// updateGroup splices the new rendered lines of g back into m.logs at its
// current startIdx and adjusts the startIdx of every following group.
func (m *Model) updateGroup(g *groupState) {
newLines := m.renderGroupLines(g)
oldLen := g.rendered
end := g.startIdx + oldLen
// Splice: m.logs = before + newLines + after
after := append([]string(nil), m.logs[end:]...)
m.logs = append(m.logs[:g.startIdx], newLines...)
m.logs = append(m.logs, after...)
delta := len(newLines) - oldLen
g.rendered = len(newLines)
if delta != 0 {
for _, other := range m.groups {
if other == g {
continue
}
if other.startIdx >= end {
other.startIdx += delta
}
}
}
stickBottom := !m.ready || m.viewport.AtBottom() || (m.running && m.focus == focusInput)
m.updateViewportWithStickBottom(stickBottom)
}
// openGroup registers a new group and appends its header to m.logs.
func (m *Model) openGroup(id, title string) {
if m.groups == nil {
m.groups = map[string]*groupState{}
}
g := &groupState{
id: id,
title: title,
startIdx: len(m.logs),
expanded: m.groupsExpandedAll,
}
m.groups[id] = g
lines := m.renderGroupLines(g)
m.logs = append(m.logs, lines...)
g.rendered = len(lines)
stickBottom := !m.ready || m.viewport.AtBottom() || (m.running && m.focus == focusInput)
m.updateViewportWithStickBottom(stickBottom)
}
// appendToGroup routes a rendered line into an existing group.
func (m *Model) appendToGroup(id, line string) {
g, ok := m.groups[id]
if !ok {
return
}
g.logs = append(g.logs, line)
m.updateGroup(g)
}
// closeGroup marks a group as finished. Its lines remain in m.logs.
func (m *Model) closeGroup(id string) {
if g, ok := m.groups[id]; ok {
g.closed = true
}
}
// rebuildGroupView updates the visible group section in logs.
// Shows the group title + last N tool calls, with "+X more" if collapsed.
func (m *Model) rebuildGroupView() {
const maxVisible = 5
// Remove old group entries from logs
if m.groupStartIdx < len(m.logs) {
m.logs = m.logs[:m.groupStartIdx]
}
total := len(m.groupLogs)
if total == 0 {
stickBottom := !m.ready || m.viewport.AtBottom() || (m.running && m.focus == focusInput)
m.updateViewportWithStickBottom(stickBottom)
return
}
if m.groupExpanded || total <= maxVisible {
// Show all
for _, line := range m.groupLogs {
m.logs = append(m.logs, " "+line)
}
} else {
// Show last maxVisible, hide the rest
hidden := total - maxVisible
m.logs = append(m.logs, diffStatStyle.Render(fmt.Sprintf(m.locale.Get("group.more_tools"), hidden)))
for _, line := range m.groupLogs[hidden:] {
m.logs = append(m.logs, " "+line)
}
}
stickBottom := !m.ready || m.viewport.AtBottom() || (m.running && m.focus == focusInput)
m.updateViewportWithStickBottom(stickBottom)
}
func (m *Model) headerLines() []string {
dimStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
title := titleStyle.Render(fmt.Sprintf("KodRun %s", m.version))
meta := dimStyle.Render(fmt.Sprintf(" · %s (%s ctx)", m.model, formatContextSize(m.contextSize)))
line1 := title + meta
line2 := dimStyle.Render(shortPath(m.workDir))
headerBorder := lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(lipgloss.Color("8")).
PaddingLeft(1).PaddingRight(1)
if m.width > 0 {
headerBorder = headerBorder.Width(m.width - dividerLabelPad)
}
box := headerBorder.Render(line1 + "\n" + line2)
return []string{box, ""}
}
// rebuildHeader replaces the header block at the top of m.logs with a fresh
// render (e.g. after terminal resize changes the available width).
func (m *Model) rebuildHeader() {
header := m.headerLines()
if m.headerLineCount > 0 && m.headerLineCount <= len(m.logs) {
m.logs = append(header, m.logs[m.headerLineCount:]...)
}
m.headerLineCount = len(header)
}
func (m *Model) addLog(line string) {
stickBottom := !m.ready || m.viewport.AtBottom() || (m.running && m.focus == focusInput)
m.logs = append(m.logs, line)
if len(m.logs) > maxLogLines {
trim := maxLogLines / logTrimDivisor
m.logs = append([]string(nil), m.logs[trim:]...)
}
if !m.transcriptMode {
m.updateViewportWithStickBottom(stickBottom)
}
}
func (m *Model) addTranscriptLog(line string) {
stickBottom := !m.ready || m.viewport.AtBottom() || (m.running && m.focus == focusInput)
m.transcriptLogs = append(m.transcriptLogs, line)
if len(m.transcriptLogs) > maxLogLines {
trim := maxLogLines / logTrimDivisor
m.transcriptLogs = append([]string(nil), m.transcriptLogs[trim:]...)
}
if m.transcriptMode {
m.updateViewportWithStickBottom(stickBottom)
}
}
// syncViewport switches the viewport content between normal and transcript buffers.
func (m *Model) syncViewport() {
if m.transcriptMode {
m.viewport.SetContent(strings.Join(m.transcriptLogs, "\n"))
} else {
m.viewport.SetContent(strings.Join(m.logs, "\n"))
}
m.viewport.GotoBottom()
}
// hasMarkdown detects whether text contains markdown formatting worth rendering.
func hasMarkdown(text string) bool {
for _, line := range strings.Split(text, "\n") {
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "## "),
strings.HasPrefix(trimmed, "### "),
strings.HasPrefix(trimmed, "# "),
strings.HasPrefix(trimmed, "- "),
strings.HasPrefix(trimmed, "* "),
strings.HasPrefix(trimmed, "> "),
strings.HasPrefix(trimmed, "```"),
strings.Contains(trimmed, "**"),
strings.Contains(trimmed, "`"):
return true
}
// Numbered list: "1. ..." or "1) ..."
if len(trimmed) > 2 && trimmed[0] >= '1' && trimmed[0] <= '9' &&
(trimmed[1] == '.' || trimmed[1] == ')') {
return true
}
}
return false
}
// renderAgentMessage renders agent output, using glamour for markdown-rich text
// and simple styling for plain text.
func (m Model) renderAgentMessage(text string, width int) string {
if hasMarkdown(text) {
return m.mdRenderer.render(text, width)
}
// Plain text fallback — no markdown detected.
if width > 0 {
wrapped := wrap.String(wordwrap.String(text, width), width)
return agentStyle.Render(wrapped)
}
return agentStyle.Render(text)
}
// addDiffLog renders a diff string with colored lines and appends to logs.
// Limits output to diffPreviewLines visible diff lines.
func (m *Model) addDiffLog(diff string) {
lines := strings.Split(strings.TrimRight(diff, "\n"), "\n")
// Extract file path from the first diff line (--- a/path or +++ b/path).
filePath := extractDiffFilePath(lines)
if filePath != "" {
barWidth := m.width - len(filePath) - dividerMinPad
if barWidth < dividerMinPad {
barWidth = dividerMinPad
}
header := fmt.Sprintf(" ── %s %s", filePath, strings.Repeat("─", barWidth))
m.logs = append(m.logs, diffStatStyle.Render(header))
}
shown := 0
for _, l := range lines {
if shown >= diffPreviewLines {
m.logs = append(m.logs, diffStatStyle.Render(fmt.Sprintf(m.locale.Get("diff.more_lines"), len(lines)-shown)))
break
}
if len(l) == 0 {
continue
}
// Skip --- and +++ header lines (already shown as file path header).
if (strings.HasPrefix(l, "--- ") || strings.HasPrefix(l, "+++ ")) && filePath != "" {
continue
}
var styled string
switch l[0] {
case '+':
styled = diffAddStyle.Render(" " + l)
case '-':
styled = diffDelStyle.Render(" " + l)
case '@':
styled = diffHunkStyle.Render(" " + l)
default:
styled = diffStatStyle.Render(" " + l)
}
m.logs = append(m.logs, styled)
shown++
}
stickBottom := !m.ready || m.viewport.AtBottom() || (m.running && m.focus == focusInput)
m.updateViewportWithStickBottom(stickBottom)
}
// extractDiffFilePath extracts the file path from unified diff headers.
func extractDiffFilePath(lines []string) string {
for _, l := range lines {
if strings.HasPrefix(l, "+++ b/") {
return strings.TrimPrefix(l, "+++ b/")
}
if strings.HasPrefix(l, "+++ ") {
return strings.TrimPrefix(l, "+++ ")
}
// Stop scanning after hunk headers.
if strings.HasPrefix(l, "@@") {
break
}
}
return ""
}
func (m *Model) updateViewport() {
m.updateViewportWithStickBottom(false)
}
func (m *Model) updateViewportWithStickBottom(stickBottom bool) {
if m.transcriptMode {
m.viewport.SetContent(strings.Join(m.transcriptLogs, "\n"))
} else {
m.viewport.SetContent(strings.Join(m.logs, "\n"))
}
if stickBottom {
m.viewport.GotoBottom()
}
}
func (m Model) mouseInViewport(y int) bool {
return y >= 0 && y < m.viewport.Height
}
// View implements tea.Model.
func (m Model) View() string {
if !m.ready {
return m.locale.Get("status.init")
}
var completePopup string
if m.showComplete && len(m.filtered) > 0 {
completePopup = m.renderComplete()
}
toolbar := " " + m.renderToolbar()
var inputLine string
switch {
case m.pendingModelPicker != nil:
inputLine = m.renderModelPicker()
case m.pendingStepConfirm != nil:
inputLine = m.renderStepConfirmMenu()
case m.pendingPlanConfirm != nil && m.planConfirmSt == confirmChoose:
inputLine = m.renderPlanConfirmMenu()
case m.pendingConfirm != nil && m.confirmSt == confirmChoose:
inputLine = m.renderConfirmMenu()
default:
inputLine = m.renderInput()
}
var timeLine string
if m.running {
elapsed := m.elapsed()
spinner := spinnerFrames[m.spinnerFrame]
timeLine = "\n" + toolbarStyle.Render(fmt.Sprintf(" %s %s ⏱ %s · %s tk", spinner, m.locale.Get("status.working"), elapsed, formatTokens(m.totalEval+m.liveEval))) + "\n"
}
if m.ragProgressTotal > 0 {
pct := m.ragProgressDone * percentMultiplier / m.ragProgressTotal
label := m.ragProgressLabel
if label == "" {
label = "indexing"
}
ragLine := toolbarStyle.Render(fmt.Sprintf(" RAG %s %d%% (%d/%d)", label, pct, m.ragProgressDone, m.ragProgressTotal))
// Trailing newline keeps a blank line between the RAG progress line
// and the input prompt below, matching the spacing of other status
// blocks.
if timeLine == "" {
timeLine = "\n" + ragLine + "\n"
} else {
timeLine += ragLine + "\n"
}
}
out := fmt.Sprintf("%s\n%s%s\n%s\n\n%s",
m.viewport.View(),
completePopup,
timeLine,
inputLine,
toolbar,
)
// Pad to exactly m.height lines to prevent bubbletea rendering artifacts.
if lines := strings.Count(out, "\n") + 1; lines < m.height {
out += strings.Repeat("\n", m.height-lines)
}
return out
}
const (
ansiBg255 = "\x1b[48;5;255m" // set background to color 255 (white)
ansiReset = "\x1b[0m"
ansiBgReset = "\x1b[0;48;5;255m" // reset attributes but keep bg 255
)
func (m Model) renderInput() string {
w := m.width
emptyLine := ansiBg255 + strings.Repeat(" ", w) + ansiReset
raw := m.textinput.View()
// Replace all resets inside textarea output with "reset + restore bg",
// so our background color persists through textarea's internal ANSI codes.
raw = strings.ReplaceAll(raw, ansiReset, ansiBgReset)
raw = strings.TrimRight(raw, "\n")
lines := strings.Split(raw, "\n")
for i, line := range lines {
visible := ansi.StringWidth(line)
pad := max(0, w-visible)
lines[i] = ansiBg255 + line + strings.Repeat(" ", pad) + ansiReset
}
return emptyLine + "\n" + strings.Join(lines, "\n") + "\n" + emptyLine
}
func (m Model) renderToolbar() string {
sep := toolbarStyle.Render(" │ ")
modelPart := toolbarStyle.Render(m.model)
modeLabel := m.locale.Get("label.edit_mode")
modeColor := lipgloss.Color("2") // green for edit
switch m.mode {
case agent.ModePlan:
modeLabel = m.locale.Get("label.plan_mode")
modeColor = lipgloss.Color("0") // black for plan
case agent.ModeChat:
modeLabel = m.locale.Get("label.chat_mode")
modeColor = lipgloss.Color("4") // blue for chat
}
modePart := lipgloss.NewStyle().Bold(true).Foreground(modeColor).Render(
strings.ToUpper(modeLabel),
) + " " + toolbarStyle.Render(m.locale.Get("label.shift_tab"))
tokenPart := toolbarStyle.Render(fmt.Sprintf("%s tk", formatTokens(m.totalPrompt+m.totalEval)))
parts := []string{modelPart, modePart, tokenPart}
if m.transcriptMode {
parts = append(parts, toolbarStyle.Render("[Transcript]"))
}
if m.lastTkPerSec > 0 {
parts = append(parts, toolbarStyle.Render(fmt.Sprintf("%.1f tk/s", m.lastTkPerSec)))
}
var pct int
if m.contextTotal > 0 {
pct = percentMultiplier - (m.contextUsed * percentMultiplier / m.contextTotal)
}
parts = append(parts, toolbarStyle.Render(fmt.Sprintf(m.locale.Get("label.context_left"), pct)))
return strings.Join(parts, sep)
}
func (m Model) renderComplete() string {
visible := m.filtered
offset := 0
if len(visible) > maxVisibleItems {
offset = max(0, m.selectedIdx-maxVisibleItems+1)
offset = min(offset, len(visible)-maxVisibleItems)
visible = visible[offset : offset+maxVisibleItems]
}
lines := make([]string, 0, len(visible))
for i, item := range visible {
name := fmt.Sprintf("/%s", item.Name)
line := fmt.Sprintf(" %-16s %s", name, item.Description)
if i+offset == m.selectedIdx {
line = completeSelected.Render(line)
} else {
line = completeNormal.Render(line)
}
lines = append(lines, line)
}
content := strings.Join(lines, "\n")
return completeBorder.Render(content) + "\n"
}
// renderConfirmCard renders the action card shown above the confirm menu:
// tool name, full arguments and (when available) a colored diff/preview.
func (m Model) renderConfirmCard() string {
p := m.pendingConfirm.Payload
lines := make([]string, 0, len(p.ArgKeys)+paddingConfirmNorm)
if p.Danger {
lines = append(lines, errorStyle.Bold(true).Render("⚠ "+m.locale.Get("confirm.card.danger")))
}
header := fmt.Sprintf("%s %s", m.locale.Get("confirm.card.tool"), p.Tool)
lines = append(lines, toolStyle.Bold(true).Render(header))
for _, k := range p.ArgKeys {
v := p.Args[k]
lines = append(lines, diffStatStyle.Render(fmt.Sprintf(" %s: %s", k, v)))
}
if p.Preview != "" {
lines = append(lines, diffStatStyle.Render(" "+m.locale.Get("confirm.card.preview")))
lines = append(lines, renderDiffPreviewLines(p.Preview, diffPreviewLines, m.locale)...)
}
lines = append(lines, "")
return strings.Join(lines, "\n")
}
// renderDiffPreviewLines colorizes a diff string into TUI lines, capped to maxLines.
func renderDiffPreviewLines(diff string, maxLines int, locale *Locale) []string {
all := strings.Split(strings.TrimRight(diff, "\n"), "\n")
out := make([]string, 0, len(all))
shown := 0
for _, l := range all {
if shown >= maxLines {
out = append(out, diffStatStyle.Render(fmt.Sprintf(locale.Get("diff.more_lines"), len(all)-shown)))
break
}
if len(l) == 0 {
continue
}
var styled string
switch l[0] {
case '+':
styled = diffAddStyle.Render(" " + l)
case '-':
styled = diffDelStyle.Render(" " + l)
case '@':
styled = diffHunkStyle.Render(" " + l)
default:
styled = diffStatStyle.Render(" " + l)
}
out = append(out, styled)
shown++
}
return out
}
// renderConfirmMenu renders the action card and the numbered confirm menu
// replacing the input area.
func (m Model) renderConfirmMenu() string {
var lines []string
lines = append(lines, m.renderConfirmCard())
for i, opt := range confirmMenuOptions {
label := fmt.Sprintf("%d. %s", i+1, m.locale.Get(opt.labelKey))
if i == m.confirmIdx {
lines = append(lines, confirmSelected.Render("> "+label))
} else {
lines = append(lines, confirmNormal.Render(label))
}
}
lines = append(lines, "", confirmHintStyle.Render(m.locale.Get("confirm.hint")))
return strings.Join(lines, "\n")
}
// renderPlanConfirmMenu renders the 3-option plan confirm menu.
func (m Model) renderPlanConfirmMenu() string {
var lines []string
for i, opt := range planConfirmMenuOptions {
label := fmt.Sprintf("%d. %s", i+1, m.locale.Get(opt.labelKey))
if i == m.planConfirmIdx {
lines = append(lines, confirmSelected.Render("> "+label))
} else {
lines = append(lines, confirmNormal.Render(label))
}
}
lines = append(lines, "", confirmHintStyle.Render(m.locale.Get("plan_confirm.hint")))
return strings.Join(lines, "\n")
}
func (m Model) renderStepConfirmMenu() string {
var lines []string
for i, opt := range stepConfirmMenuOptions {
label := fmt.Sprintf("%d. %s", i+1, m.locale.Get(opt.labelKey))
if i == m.stepConfirmIdx {
lines = append(lines, confirmSelected.Render("> "+label))
} else {
lines = append(lines, confirmNormal.Render(label))
}
}
lines = append(lines, "", confirmHintStyle.Render(m.locale.Get("step_confirm.hint")))
return strings.Join(lines, "\n")
}
func (m Model) renderModelPicker() string {
models := m.pendingModelPicker.Models
current := m.pendingModelPicker.Current
// Scrollable window around selected index
start := 0
if len(models) > maxVisibleItems {
start = m.modelPickerIdx - maxVisibleItems/pickerScrollHalf
if start < 0 {
start = 0
}
if start+maxVisibleItems > len(models) {
start = len(models) - maxVisibleItems
}
}
end := start + min(len(models), maxVisibleItems)
var lines []string
lines = append(lines, confirmHeaderStyle.Render("Select model:"))
for i := start; i < end; i++ {
name := models[i]
marker := " "
if name == current {
marker = "* "
}
label := marker + name
if i == m.modelPickerIdx {
lines = append(lines, confirmSelected.Render("> "+label))
} else {
lines = append(lines, confirmNormal.Render(" "+label))
}
}
lines = append(lines, "", confirmHintStyle.Render("↑/↓ navigate · Enter select · Esc cancel"))
return strings.Join(lines, "\n")
}
func (m Model) renderSummary(s *agent.SessionStats, elapsed time.Duration) string {
var b strings.Builder
fmt.Fprintf(&b, "%s%s", m.locale.Get("event.done"), elapsed)
// File stats
var fileParts []string
if s.FilesAdded > 0 {
fileParts = append(fileParts, fmt.Sprintf(m.locale.Get("summary.added"), s.FilesAdded))
}
if s.FilesModified > 0 {
fileParts = append(fileParts, fmt.Sprintf(m.locale.Get("summary.modified"), s.FilesModified))
}
if s.FilesDeleted > 0 {
fileParts = append(fileParts, fmt.Sprintf(m.locale.Get("summary.deleted"), s.FilesDeleted))
}
if s.FilesRenamed > 0 {
fileParts = append(fileParts, fmt.Sprintf(m.locale.Get("summary.renamed"), s.FilesRenamed))
}
if len(fileParts) > 0 {
fmt.Fprintf(&b, "%s%s", m.locale.Get("summary.files"), strings.Join(fileParts, ", "))
}
// Line stats
if s.LinesAdded > 0 || s.LinesRemoved > 0 {
fmt.Fprintf(&b, m.locale.Get("summary.lines"), s.LinesAdded, s.LinesRemoved)
}
// Tool calls and performance
var perfParts []string
if s.ToolCalls > 0 {
perfParts = append(perfParts, fmt.Sprintf(m.locale.Get("summary.tool_calls"), s.ToolCalls))
}
if s.AvgTkPerSec > 0 {
perfParts = append(perfParts, fmt.Sprintf(m.locale.Get("summary.avg_tks"), s.AvgTkPerSec))
}
if s.PeakContextPct > 0 {
perfParts = append(perfParts, fmt.Sprintf(m.locale.Get("summary.peak_ctx"), s.PeakContextPct))
}
if len(perfParts) > 0 {
fmt.Fprintf(&b, "\n %s", strings.Join(perfParts, " · "))
}
// Timing breakdown (populated when iterations > 0).
if s.Iterations > 0 && s.TotalLLMTime > 0 {
total := s.TotalLLMTime + s.TotalToolTime
var llmPct, toolPct int
if total > 0 {
llmPct = int(s.TotalLLMTime * 100 / total)
toolPct = int(s.TotalToolTime * 100 / total)
}
fmt.Fprintf(&b, "\n Perf: LLM %s (%d%%) · Tools %s (%d%%) · %d iter",
s.TotalLLMTime.Truncate(time.Millisecond), llmPct,
s.TotalToolTime.Truncate(time.Millisecond), toolPct,
s.Iterations)
}
return b.String()
}
// elapsed returns the task duration excluding paused time.
func (m Model) elapsed() time.Duration {
d := time.Since(m.taskStart) - m.pausedDuration
if !m.pauseStart.IsZero() {
d -= time.Since(m.pauseStart)
}
return d.Truncate(time.Second)
}
func shortPath(dir string) string {
home, err := os.UserHomeDir()
if err != nil || home == "" {
return dir
}
rel, err := filepath.Rel(home, dir)
if err != nil || strings.HasPrefix(rel, "..") {
return dir
}
return "~/" + rel
}
func formatContextSize(n int) string {
if n >= kibi && n%kibi == 0 {
return fmt.Sprintf("%dk", n/kibi)
}
if n >= kilo {
return fmt.Sprintf("%.1fk", float64(n)/kibi)
}
return fmt.Sprintf("%d", n)
}
func formatTokens(n int) string {
if n < kilo {
return fmt.Sprintf("%d", n)
}
return fmt.Sprintf("%.1fK", float64(n)/kilo)
}
/**
* This file is part of the raoptimus/kodrun library
*
* @copyright Copyright (c) Evgeniy Urvantsev
* @license https://github.com/raoptimus/kodrun/blob/master/LICENSE
* @link https://github.com/raoptimus/kodrun
*/
package tui
// CellPos represents a position in the content (line and column, visual width).
type CellPos struct {
Line int
Col int // visual column (display width, not rune index)
}
// Selection tracks the current text selection state.
type Selection struct {
Active bool // true while dragging
Start CellPos // drag start position
End CellPos // current/final position
HasRange bool // true if Start != End
}
// Normalized returns start and end ordered so that start <= end.
func (s Selection) Normalized() (start, end CellPos) {
a, b := s.Start, s.End
if a.Line > b.Line || (a.Line == b.Line && a.Col > b.Col) {
a, b = b, a
}
return a, b
}
// Contains checks whether the given line/col falls within the selection.
func (s Selection) Contains(line, col int) bool {
if !s.HasRange {
return false
}
start, end := s.Normalized()
if line < start.Line || line > end.Line {
return false
}
if start.Line == end.Line {
return col >= start.Col && col < end.Col
}
if line == start.Line {
return col >= start.Col
}
if line == end.Line {
return col < end.Col
}
return true
}