Files
gnoma/internal/elf/elf.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

177 lines
3.8 KiB
Go

package elf
import (
"context"
"fmt"
"sync"
"sync/atomic"
"time"
"somegit.dev/Owlibou/gnoma/internal/engine"
"somegit.dev/Owlibou/gnoma/internal/message"
"somegit.dev/Owlibou/gnoma/internal/stream"
)
// Status tracks the lifecycle of an elf.
type Status int
const (
StatusPending Status = iota
StatusRunning
StatusCompleted
StatusFailed
StatusCancelled
)
func (s Status) String() string {
switch s {
case StatusPending:
return "pending"
case StatusRunning:
return "running"
case StatusCompleted:
return "completed"
case StatusFailed:
return "failed"
case StatusCancelled:
return "cancelled"
default:
return "unknown"
}
}
// Result is the output of a completed elf.
type Result struct {
ID string
Status Status
Messages []message.Message
Usage message.Usage
Output string // final text output
Error error
Duration time.Duration
}
// Elf is a sub-agent with its own engine and conversation history.
type Elf interface {
// ID returns the unique elf identifier.
ID() string
// Status returns the current lifecycle status.
Status() Status
// Events returns a channel for streaming events (nil for sync elfs).
Events() <-chan stream.Event
// Wait blocks until the elf completes and returns its result.
Wait() Result
// Cancel aborts the elf.
Cancel()
}
var elfCounter atomic.Int64
func nextID(prefix string) string {
n := elfCounter.Add(1)
return fmt.Sprintf("%s-%d", prefix, n)
}
// BackgroundElf runs on its own goroutine with an independent engine.
type BackgroundElf struct {
id string
eng *engine.Engine
events chan stream.Event
result chan Result
cancel context.CancelFunc
status atomic.Int32
startAt time.Time
cachedResult Result
resultOnce sync.Once
eventsClose sync.Once
}
// SpawnBackground creates and starts a background elf.
func SpawnBackground(eng *engine.Engine, prompt string) *BackgroundElf {
ctx, cancel := context.WithCancel(context.Background())
elf := &BackgroundElf{
id: nextID("elf"),
eng: eng,
events: make(chan stream.Event, 64),
result: make(chan Result, 1),
cancel: cancel,
startAt: time.Now(),
}
elf.status.Store(int32(StatusRunning))
go elf.run(ctx, prompt)
return elf
}
func (e *BackgroundElf) run(ctx context.Context, prompt string) {
closeEvents := func() { e.eventsClose.Do(func() { close(e.events) }) }
defer func() {
if r := recover(); r != nil {
closeEvents()
res := Result{
ID: e.id,
Status: StatusFailed,
Error: fmt.Errorf("elf panicked: %v", r),
Duration: time.Since(e.startAt),
}
e.status.Store(int32(StatusFailed))
e.result <- res
}
}()
cb := func(evt stream.Event) {
select {
case e.events <- evt:
case <-ctx.Done():
}
}
turn, err := e.eng.Submit(ctx, prompt, cb)
closeEvents()
r := Result{
ID: e.id,
Duration: time.Since(e.startAt),
}
if ctx.Err() != nil {
r.Status = StatusCancelled
r.Error = ctx.Err()
e.status.Store(int32(StatusCancelled))
} else if err != nil {
r.Status = StatusFailed
r.Error = err
e.status.Store(int32(StatusFailed))
} else {
r.Status = StatusCompleted
r.Messages = turn.Messages
r.Usage = turn.Usage
// Extract final text from last assistant message
for i := len(turn.Messages) - 1; i >= 0; i-- {
if turn.Messages[i].Role == message.RoleAssistant {
r.Output = turn.Messages[i].TextContent()
break
}
}
e.status.Store(int32(StatusCompleted))
}
e.result <- r
}
func (e *BackgroundElf) ID() string { return e.id }
func (e *BackgroundElf) Status() Status { return Status(e.status.Load()) }
func (e *BackgroundElf) Events() <-chan stream.Event { return e.events }
func (e *BackgroundElf) Cancel() { e.cancel() }
func (e *BackgroundElf) Wait() Result {
e.resultOnce.Do(func() {
e.cachedResult = <-e.result
})
return e.cachedResult
}