feat: complete M1 — core engine with Mistral provider
Mistral provider adapter with streaming, tool calls (single-chunk pattern), stop reason inference, model listing, capabilities, and JSON output support. Tool system: bash (7 security checks, shell alias harvesting for bash/zsh/fish), file ops (read, write, edit, glob, grep, ls). Alias harvesting collects 300+ aliases from user's shell config. Engine agentic loop: stream → tool execution → re-query → until done. Tool gating on model capabilities. Max turns safety limit. CLI pipe mode: echo "prompt" | gnoma streams response to stdout. Flags: --provider, --model, --system, --api-key, --max-turns, --verbose, --version. Provider interface expanded: Models(), DefaultModel(), Capabilities (ToolUse, JSONOutput, Vision, Thinking, ContextWindow, MaxOutput), ResponseFormat with JSON schema support. Live verified: text streaming + tool calling with devstral-small. 117 tests across 8 packages, 10MB binary.
This commit is contained in:
204
internal/engine/loop.go
Normal file
204
internal/engine/loop.go
Normal file
@@ -0,0 +1,204 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"somegit.dev/Owlibou/gnoma/internal/message"
|
||||
"somegit.dev/Owlibou/gnoma/internal/provider"
|
||||
"somegit.dev/Owlibou/gnoma/internal/stream"
|
||||
)
|
||||
|
||||
// Submit sends a user message and runs the agentic loop to completion.
|
||||
// The callback receives real-time streaming events.
|
||||
func (e *Engine) Submit(ctx context.Context, input string, cb Callback) (*Turn, error) {
|
||||
userMsg := message.NewUserText(input)
|
||||
e.history = append(e.history, userMsg)
|
||||
|
||||
return e.runLoop(ctx, cb)
|
||||
}
|
||||
|
||||
// SubmitMessages is like Submit but accepts pre-built messages.
|
||||
func (e *Engine) SubmitMessages(ctx context.Context, msgs []message.Message, cb Callback) (*Turn, error) {
|
||||
e.history = append(e.history, msgs...)
|
||||
|
||||
return e.runLoop(ctx, cb)
|
||||
}
|
||||
|
||||
func (e *Engine) runLoop(ctx context.Context, cb Callback) (*Turn, error) {
|
||||
turn := &Turn{}
|
||||
|
||||
for {
|
||||
turn.Rounds++
|
||||
if e.cfg.MaxTurns > 0 && turn.Rounds > e.cfg.MaxTurns {
|
||||
return turn, fmt.Errorf("safety limit: %d rounds exceeded", e.cfg.MaxTurns)
|
||||
}
|
||||
|
||||
// Build provider request (gates tools on model capabilities)
|
||||
req := e.buildRequest(ctx)
|
||||
|
||||
e.logger.Debug("streaming request",
|
||||
"provider", e.cfg.Provider.Name(),
|
||||
"model", req.Model,
|
||||
"messages", len(req.Messages),
|
||||
"tools", len(req.Tools),
|
||||
"round", turn.Rounds,
|
||||
)
|
||||
|
||||
// Stream from provider
|
||||
s, err := e.cfg.Provider.Stream(ctx, req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("provider stream: %w", err)
|
||||
}
|
||||
|
||||
// Consume stream, forwarding events to callback
|
||||
acc := stream.NewAccumulator()
|
||||
var stopReason message.StopReason
|
||||
var model string
|
||||
|
||||
for s.Next() {
|
||||
evt := s.Current()
|
||||
acc.Apply(evt)
|
||||
|
||||
// Capture stop reason and model from events
|
||||
if evt.StopReason != "" {
|
||||
stopReason = evt.StopReason
|
||||
}
|
||||
if evt.Model != "" {
|
||||
model = evt.Model
|
||||
}
|
||||
|
||||
if cb != nil {
|
||||
cb(evt)
|
||||
}
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
s.Close()
|
||||
return nil, fmt.Errorf("stream error: %w", err)
|
||||
}
|
||||
s.Close()
|
||||
|
||||
// Build response
|
||||
resp := acc.Response(stopReason, model)
|
||||
turn.Usage.Add(resp.Usage)
|
||||
turn.Messages = append(turn.Messages, resp.Message)
|
||||
e.history = append(e.history, resp.Message)
|
||||
e.usage.Add(resp.Usage)
|
||||
|
||||
e.logger.Debug("turn response",
|
||||
"stop_reason", resp.StopReason,
|
||||
"tool_calls", len(resp.Message.ToolCalls()),
|
||||
"round", turn.Rounds,
|
||||
)
|
||||
|
||||
// Decide next action
|
||||
switch resp.StopReason {
|
||||
case message.StopEndTurn, message.StopMaxTokens, message.StopSequence:
|
||||
return turn, nil
|
||||
|
||||
case message.StopToolUse:
|
||||
results, err := e.executeTools(ctx, resp.Message.ToolCalls(), cb)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tool execution: %w", err)
|
||||
}
|
||||
toolMsg := message.NewToolResults(results...)
|
||||
turn.Messages = append(turn.Messages, toolMsg)
|
||||
e.history = append(e.history, toolMsg)
|
||||
// Continue loop — re-query provider with tool results
|
||||
|
||||
default:
|
||||
// Unknown stop reason or empty — treat as end of turn
|
||||
return turn, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Engine) buildRequest(ctx context.Context) provider.Request {
|
||||
req := provider.Request{
|
||||
Model: e.cfg.Model,
|
||||
SystemPrompt: e.cfg.System,
|
||||
Messages: e.history,
|
||||
}
|
||||
|
||||
// Only include tools if the model supports them
|
||||
caps := e.resolveCapabilities(ctx)
|
||||
if caps == nil || caps.ToolUse {
|
||||
// nil caps = unknown model, include tools optimistically
|
||||
for _, t := range e.cfg.Tools.All() {
|
||||
req.Tools = append(req.Tools, provider.ToolDefinition{
|
||||
Name: t.Name(),
|
||||
Description: t.Description(),
|
||||
Parameters: t.Parameters(),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
e.logger.Debug("tools omitted — model does not support tool use",
|
||||
"model", req.Model,
|
||||
)
|
||||
}
|
||||
|
||||
return req
|
||||
}
|
||||
|
||||
func (e *Engine) executeTools(ctx context.Context, calls []message.ToolCall, cb Callback) ([]message.ToolResult, error) {
|
||||
results := make([]message.ToolResult, 0, len(calls))
|
||||
|
||||
for _, call := range calls {
|
||||
t, ok := e.cfg.Tools.Get(call.Name)
|
||||
if !ok {
|
||||
e.logger.Warn("unknown tool", "name", call.Name)
|
||||
results = append(results, message.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Content: fmt.Sprintf("unknown tool: %s", call.Name),
|
||||
IsError: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
e.logger.Debug("executing tool", "name", call.Name, "id", call.ID)
|
||||
|
||||
result, err := t.Execute(ctx, call.Arguments)
|
||||
if err != nil {
|
||||
e.logger.Error("tool execution failed", "name", call.Name, "error", err)
|
||||
results = append(results, message.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Content: err.Error(),
|
||||
IsError: true,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Emit tool result as a text delta event so the UI can show it
|
||||
if cb != nil {
|
||||
cb(stream.Event{
|
||||
Type: stream.EventTextDelta,
|
||||
Text: fmt.Sprintf("\n[tool:%s] %s\n", call.Name, truncate(result.Output, 500)),
|
||||
})
|
||||
}
|
||||
|
||||
results = append(results, message.ToolResult{
|
||||
ToolCallID: call.ID,
|
||||
Content: result.Output,
|
||||
})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func truncate(s string, maxLen int) string {
|
||||
if len(s) <= maxLen {
|
||||
return s
|
||||
}
|
||||
return s[:maxLen] + "..."
|
||||
}
|
||||
|
||||
// toolDefFromTool converts a tool.Tool to provider.ToolDefinition.
|
||||
// Unused currently but kept for reference when building tool definitions dynamically.
|
||||
func toolDefFromJSON(name, description string, params json.RawMessage) provider.ToolDefinition {
|
||||
return provider.ToolDefinition{
|
||||
Name: name,
|
||||
Description: description,
|
||||
Parameters: params,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user