diff options
| author | Paul Buetow <paul@buetow.org> | 2025-08-19 21:57:38 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-08-19 21:57:38 +0300 |
| commit | 9f59e7acd647f9adc0903e9c9655c04495f13a53 (patch) | |
| tree | edef52111c7fa87227b3d3d14b3716f458a8e5ed | |
| parent | ef188388102b0377ed506b8767536233575965bb (diff) | |
lsp: replace time throttle with in-flight guard; improve short-prefix heuristic\n\n- Prevent overlapping LLM requests via llmBusy guard\n- Remove time-based throttle and option plumbing\n- Short-prefix heuristic now skips over trailing whitespace and clamps index\n- Add tests for busy guard and trailing-space allowance
| -rw-r--r-- | internal/hexailsp/run.go | 2 | ||||
| -rw-r--r-- | internal/lsp/completion_throttle_test.go | 34 | ||||
| -rw-r--r-- | internal/lsp/handlers.go | 52 | ||||
| -rw-r--r-- | internal/lsp/server.go | 30 |
4 files changed, 78 insertions, 40 deletions
diff --git a/internal/hexailsp/run.go b/internal/hexailsp/run.go index dd12600..8721a60 100644 --- a/internal/hexailsp/run.go +++ b/internal/hexailsp/run.go @@ -107,7 +107,5 @@ func makeServerOptions(cfg appconfig.App, logContext bool, client llm.Client) ls CodingTemperature: cfg.CodingTemperature, Client: client, TriggerCharacters: cfg.TriggerCharacters, - // Optional; when zero, server uses a sensible default - MinCompletionIntervalMs: 0, } } diff --git a/internal/lsp/completion_throttle_test.go b/internal/lsp/completion_throttle_test.go index 2de8edb..f986562 100644 --- a/internal/lsp/completion_throttle_test.go +++ b/internal/lsp/completion_throttle_test.go @@ -5,7 +5,6 @@ import ( "context" "log" "testing" - "time" "hexai/internal/llm" ) @@ -35,24 +34,22 @@ func TestDefaultTriggerChars_DoesNotIncludeSemicolonOrQuestion(t *testing.T) { } } -func TestTryLLMCompletion_ThrottleSkipsRapidCalls(t *testing.T) { - // Build server with long min interval and set last completion to now +func TestTryLLMCompletion_BusySkipsConcurrent(t *testing.T) { s := &Server{ maxTokens: 32 } - s.minCompletionInterval = time.Hour - s.lastLLMCompletion = time.Now() fake := &countingLLM{} s.llmClient = fake - // Position with adequate prefix to avoid prefix heuristic from skipping + // Simulate another LLM request in flight + s.llmBusy = true p := CompletionParams{ Position: Position{ Line: 0, Character: 3 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } items, ok := s.tryLLMCompletion(p, "", "foo", "", "", "", false, "") if !ok { - t.Fatalf("expected ok=true even when throttled") + t.Fatalf("expected ok=true when busy guard skips") } if len(items) != 0 { - t.Fatalf("expected zero items when throttled, got %d", len(items)) + t.Fatalf("expected zero items when busy, got %d", len(items)) } if fake.calls != 0 { - t.Fatalf("LLM Chat should not be called when throttled; calls=%d", fake.calls) + t.Fatalf("LLM Chat should not be called when busy; calls=%d", fake.calls) } } @@ -60,9 +57,9 @@ func TestTryLLMCompletion_MinPrefixSkipsEarly(t *testing.T) { s := &Server{ maxTokens: 32 } fake := &countingLLM{} s.llmClient = fake - // Only 1 identifier character before cursor - p := CompletionParams{ Position: Position{ Line: 0, Character: 1 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } - items, ok := s.tryLLMCompletion(p, "", "a", "", "", "", false, "") + // Zero identifier characters before cursor + p := CompletionParams{ Position: Position{ Line: 0, Character: 0 }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } + items, ok := s.tryLLMCompletion(p, "", "", "", "", "", false, "") if !ok { t.Fatalf("expected ok=true when skipped by min-prefix heuristic") } @@ -73,3 +70,16 @@ func TestTryLLMCompletion_MinPrefixSkipsEarly(t *testing.T) { t.Fatalf("LLM Chat should not be called when min-prefix not met; calls=%d", fake.calls) } } + +func TestTryLLMCompletion_AllowsAfterTrailingSpace(t *testing.T) { + s := &Server{ maxTokens: 32 } + fake := &countingLLM{} + s.llmClient = fake + line := "type Matrix " + // Cursor after trailing space + p := CompletionParams{ Position: Position{ Line: 0, Character: len(line) }, TextDocument: TextDocumentIdentifier{URI: "file://x.go"} } + items, ok := s.tryLLMCompletion(p, "", line, "", "", "", false, "") + if !ok || len(items) == 0 || fake.calls == 0 { + t.Fatalf("expected completion allowed after trailing space; ok=%v len=%d calls=%d", ok, len(items), fake.calls) + } +} diff --git a/internal/lsp/handlers.go b/internal/lsp/handlers.go index 95656df..b9922f9 100644 --- a/internal/lsp/handlers.go +++ b/internal/lsp/handlers.go @@ -452,26 +452,42 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun defer cancel() inParams := inParamList(current, p.Position.Character) - // Heuristic 1: Require a minimal typed identifier prefix to avoid early triggers + // Heuristic 1: Require a minimal typed identifier prefix to avoid early triggers, + // but allow immediate completion after structural trigger chars like '.', ':', '/'. if !inParams { - start := computeWordStart(current, p.Position.Character) - if p.Position.Character-start < 2 { // fewer than 2 identifier chars - return []CompletionItem{}, true - } - } - // Heuristic 2: Throttle LLM calls to avoid rapid-fire requests - if s.minCompletionInterval > 0 { - s.mu.Lock() - tooSoon := time.Since(s.lastLLMCompletion) < s.minCompletionInterval - // Preemptively update timestamp to coalesce bursts - if !tooSoon { - s.lastLLMCompletion = time.Now() + // Determine the effective cursor index within current line, clamped, and + // skip over trailing spaces/tabs to support cases like "type Matrix| " + // where the cursor is after a space following an identifier. + idx := p.Position.Character + if idx > len(current) { idx = len(current) } + // Structural triggers allow no prefix + allowNoPrefix := false + if idx > 0 { + ch := current[idx-1] + if ch == '.' || ch == ':' || ch == '/' || ch == '_' { + allowNoPrefix = true + } } - s.mu.Unlock() - if tooSoon { - return []CompletionItem{}, true + if !allowNoPrefix { + // Walk left over whitespace + j := idx + for j > 0 { + c := current[j-1] + if c == ' ' || c == '\t' { j--; continue } + break + } + start := computeWordStart(current, j) + if j-start < 1 { // require at least 1 identifier char + logging.Logf("lsp ", "completion skip=short-prefix line=%d char=%d current=%q", p.Position.Line, p.Position.Character, trimLen(current)) + return []CompletionItem{}, true + } } } + // Concurrency guard: if another LLM request is running, skip this one. + if !s.tryStartLLM() { + logging.Logf("lsp ", "completion skip=busy another LLM request in flight") + return []CompletionItem{}, true + } sysPrompt, userPrompt := buildPrompts(inParams, p, above, current, below, funcCtx) messages := []llm.Message{ {Role: "system", Content: sysPrompt}, @@ -492,7 +508,9 @@ func (s *Server) tryLLMCompletion(p CompletionParams, above, current, below, fun if s.codingTemperature != nil { opts = append(opts, llm.WithTemperature(*s.codingTemperature)) } - text, err := s.llmClient.Chat(ctx, messages, opts...) + logging.Logf("lsp ", "completion llm=requesting model=%s", s.llmClient.DefaultModel()) + text, err := s.llmClient.Chat(ctx, messages, opts...) + defer s.endLLM() if err != nil { logging.Logf("lsp ", "llm completion error: %v", err) // Log updated averages after this request (even if failed) diff --git a/internal/lsp/server.go b/internal/lsp/server.go index 474020c..7773dd1 100644 --- a/internal/lsp/server.go +++ b/internal/lsp/server.go @@ -32,9 +32,8 @@ type Server struct { triggerChars []string // If set, used as the LSP coding temperature for all LLM calls codingTemperature *float64 - // Throttling for LLM-powered completion - lastLLMCompletion time.Time - minCompletionInterval time.Duration + // Concurrency guard: prevent overlapping LLM requests (esp. completions) + llmBusy bool // LLM request stats llmReqTotal int64 llmSentBytesTotal int64 @@ -54,7 +53,6 @@ type ServerOptions struct { Client llm.Client TriggerCharacters []string CodingTemperature *float64 - MinCompletionIntervalMs int } func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) *Server { @@ -89,14 +87,28 @@ func NewServer(r io.Reader, w io.Writer, logger *log.Logger, opts ServerOptions) s.triggerChars = append([]string{}, opts.TriggerCharacters...) } s.codingTemperature = opts.CodingTemperature - if opts.MinCompletionIntervalMs <= 0 { - s.minCompletionInterval = 900 * time.Millisecond - } else { - s.minCompletionInterval = time.Duration(opts.MinCompletionIntervalMs) * time.Millisecond - } return s } +// tryStartLLM attempts to mark the LLM as busy. Returns true when it acquired +// the guard; false if another LLM request is already running. +func (s *Server) tryStartLLM() bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.llmBusy { + return false + } + s.llmBusy = true + return true +} + +// endLLM releases the busy guard for LLM requests. +func (s *Server) endLLM() { + s.mu.Lock() + s.llmBusy = false + s.mu.Unlock() +} + func (s *Server) Run() error { for { body, err := s.readMessage() |
