Files
gnoma/internal/session/local.go
vikingowl cb2d63d06f feat: Ollama/gemma4 compat — /init flow, stream filter, safety fixes
provider/openai:
- Fix doubled tool call args (argsComplete flag): Ollama sends complete
  args in the first streaming chunk then repeats them as delta, causing
  doubled JSON and 400 errors in elfs
- Handle fs: prefix (gemma4 uses fs:grep instead of fs.grep)
- Add Reasoning field support for Ollama thinking output

cmd/gnoma:
- Early TTY detection so logger is created with correct destination
  before any component gets a reference to it (fixes slog WARN bleed
  into TUI textarea)

permission:
- Exempt spawn_elfs and agent tools from safety scanner: elf prompt
  text may legitimately mention .env/.ssh/credentials patterns and
  should not be blocked

tui/app:
- /init retry chain: no-tool-calls → spawn_elfs nudge → write nudge
  (ask for plain text output) → TUI fallback write from streamBuf
- looksLikeAgentsMD + extractMarkdownDoc: validate and clean fallback
  content before writing (reject refusals, strip narrative preambles)
- Collapse thinking output to 3 lines; ctrl+o to expand (live stream
  and committed messages)
- Stream-level filter for model pseudo-tool-call blocks: suppresses
  <<tool_code>>...</tool_code>> and <<function_call>>...<tool_call|>
  from entering streamBuf across chunk boundaries
- sanitizeAssistantText regex covers both block formats
- Reset streamFilterClose at every turn start
2026-04-05 19:24:51 +02:00

149 lines
2.7 KiB
Go

package session
import (
"context"
"fmt"
"sync"
"somegit.dev/Owlibou/gnoma/internal/engine"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// Local implements Session using goroutines and channels within the same process.
type Local struct {
mu sync.Mutex
eng *engine.Engine
state SessionState
events chan stream.Event
// Current turn context
cancel context.CancelFunc
turn *engine.Turn
err error
// Stats
provider string
model string
turnCount int
}
// NewLocal creates a channel-based in-process session.
func NewLocal(eng *engine.Engine, providerName, model string) *Local {
return &Local{
eng: eng,
state: StateIdle,
provider: providerName,
model: model,
}
}
func (s *Local) Send(input string) error {
return s.SendWithOptions(input, engine.TurnOptions{})
}
// SendWithOptions is like Send but applies per-turn engine options.
func (s *Local) SendWithOptions(input string, opts engine.TurnOptions) error {
s.mu.Lock()
if s.state != StateIdle {
s.mu.Unlock()
return fmt.Errorf("session not idle (state: %s)", s.state)
}
s.state = StateStreaming
s.events = make(chan stream.Event, 64)
s.turn = nil
s.err = nil
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.turnCount++
s.mu.Unlock()
// Run engine in background goroutine
go func() {
cb := func(evt stream.Event) {
select {
case s.events <- evt:
case <-ctx.Done():
}
}
turn, err := s.eng.SubmitWithOptions(ctx, input, opts, cb)
s.mu.Lock()
s.turn = turn
s.err = err
if err != nil && ctx.Err() != nil {
s.state = StateCancelled
} else if err != nil {
s.state = StateError
} else {
s.state = StateIdle
}
s.mu.Unlock()
close(s.events)
}()
return nil
}
func (s *Local) Events() <-chan stream.Event {
s.mu.Lock()
defer s.mu.Unlock()
return s.events
}
func (s *Local) TurnResult() (*engine.Turn, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.turn, s.err
}
func (s *Local) Cancel() {
s.mu.Lock()
defer s.mu.Unlock()
if s.cancel != nil {
s.cancel()
}
}
func (s *Local) Close() error {
s.Cancel()
s.mu.Lock()
defer s.mu.Unlock()
s.state = StateClosed
return nil
}
// SetModel updates the displayed model name.
func (s *Local) SetModel(model string) {
s.mu.Lock()
defer s.mu.Unlock()
s.model = model
}
func (s *Local) Status() Status {
s.mu.Lock()
defer s.mu.Unlock()
st := Status{
State: s.state,
Provider: s.provider,
Model: s.model,
TokensUsed: s.eng.Usage().TotalTokens(),
TurnCount: s.turnCount,
TokenState: "ok",
}
if w := s.eng.ContextWindow(); w != nil {
tr := w.Tracker()
st.TokensMax = tr.MaxTokens()
st.TokenPercent = tr.PercentUsed()
st.TokenState = tr.State().String()
}
return st
}