Tools now go through permission.Checker before executing: - plan mode: denies all writes (fs.write, bash), allows reads - bypass mode: allows all (deny rules still enforced) - default mode: prompts user (pipe: stdin prompt, TUI: auto-approve for now) - accept_edits: auto-allows file ops, prompts for bash - deny mode: denies all without allow rules CLI flags: --permission <mode>, --incognito Pipe mode: console Y/N prompt on stderr TUI mode: auto-approve (proper overlay TODO) Verified: plan mode correctly blocks fs.write, model sees error.
274 lines
7.0 KiB
Go
274 lines
7.0 KiB
Go
package engine
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
|
|
"somegit.dev/Owlibou/gnoma/internal/message"
|
|
"somegit.dev/Owlibou/gnoma/internal/permission"
|
|
"somegit.dev/Owlibou/gnoma/internal/provider"
|
|
"somegit.dev/Owlibou/gnoma/internal/router"
|
|
"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)
|
|
|
|
// Route and stream
|
|
var s stream.Stream
|
|
var err error
|
|
|
|
if e.cfg.Router != nil {
|
|
// Classify task from the latest user message
|
|
prompt := ""
|
|
for i := len(e.history) - 1; i >= 0; i-- {
|
|
if e.history[i].Role == message.RoleUser {
|
|
prompt = e.history[i].TextContent()
|
|
break
|
|
}
|
|
}
|
|
task := router.ClassifyTask(prompt)
|
|
task.EstimatedTokens = 4000 // rough default
|
|
|
|
e.logger.Debug("routing request",
|
|
"task_type", task.Type,
|
|
"complexity", task.ComplexityScore,
|
|
"round", turn.Rounds,
|
|
)
|
|
|
|
var arm *router.Arm
|
|
s, arm, err = e.cfg.Router.Stream(ctx, task, req)
|
|
if arm != nil {
|
|
e.logger.Debug("streaming request",
|
|
"provider", arm.Provider.Name(),
|
|
"model", arm.ModelName,
|
|
"arm", arm.ID,
|
|
"messages", len(req.Messages),
|
|
"tools", len(req.Tools),
|
|
"round", turn.Rounds,
|
|
)
|
|
}
|
|
} else {
|
|
e.logger.Debug("streaming request",
|
|
"provider", e.cfg.Provider.Name(),
|
|
"model", req.Model,
|
|
"messages", len(req.Messages),
|
|
"tools", len(req.Tools),
|
|
"round", turn.Rounds,
|
|
)
|
|
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 {
|
|
// Scan messages through firewall if configured
|
|
messages := e.history
|
|
systemPrompt := e.cfg.System
|
|
if e.cfg.Firewall != nil {
|
|
messages = e.cfg.Firewall.ScanOutgoingMessages(messages)
|
|
systemPrompt = e.cfg.Firewall.ScanSystemPrompt(systemPrompt)
|
|
}
|
|
|
|
req := provider.Request{
|
|
Model: e.cfg.Model,
|
|
SystemPrompt: systemPrompt,
|
|
Messages: messages,
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// Permission check
|
|
if e.cfg.Permissions != nil {
|
|
info := permission.ToolInfo{
|
|
Name: call.Name,
|
|
IsReadOnly: t.IsReadOnly(),
|
|
IsDestructive: t.IsDestructive(),
|
|
}
|
|
if err := e.cfg.Permissions.Check(ctx, info, call.Arguments); err != nil {
|
|
e.logger.Info("tool permission denied", "name", call.Name, "error", err)
|
|
results = append(results, message.ToolResult{
|
|
ToolCallID: call.ID,
|
|
Content: fmt.Sprintf("permission denied: %v", err),
|
|
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
|
|
}
|
|
|
|
// Scan tool result through firewall
|
|
output := result.Output
|
|
if e.cfg.Firewall != nil {
|
|
output = e.cfg.Firewall.ScanToolResult(output)
|
|
}
|
|
|
|
// Emit tool result event for the UI
|
|
if cb != nil {
|
|
cb(stream.Event{
|
|
Type: stream.EventToolResult,
|
|
ToolName: call.Name,
|
|
ToolOutput: truncate(output, 2000),
|
|
})
|
|
}
|
|
|
|
results = append(results, message.ToolResult{
|
|
ToolCallID: call.ID,
|
|
Content: 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,
|
|
}
|
|
}
|