1101 lines
32 KiB
Go
1101 lines
32 KiB
Go
package agentcore
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io/fs"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"crypto/rand"
|
|
|
|
"ollie/internal/agent"
|
|
"ollie/internal/backend"
|
|
"ollie/internal/config"
|
|
execpkg "ollie/internal/exec"
|
|
"ollie/internal/mcp"
|
|
"ollie/internal/tools"
|
|
"ollie/pkg/core"
|
|
)
|
|
|
|
const systemPromptBase = `Use the fewest words possible. No preamble, filler, or narration ("Let me...", "I'll now...", "Great!"). No explanations of actions taken. No summaries of completed work. No reasoning unless asked. If the answer is one word, write one word.
|
|
Call tools immediately and directly. Never describe what you are about to do — act.
|
|
Complete tasks fully before stopping. Do not pause mid-task to narrate progress or request confirmation.
|
|
Do not ask clarifying questions unless the task is genuinely ambiguous. Attempt the task; correct based on feedback.
|
|
Do not restate tasks, hedge, or self-congratulate.
|
|
Do not attempt tasks outside your tools.
|
|
Do not use hedging language ("it looks like", "it appears", "it seems", "likely", "probably"). If you are uncertain, use tools to find out. Give definite answers based on evidence.
|
|
Do not re-read or re-fetch any file or resource that already has a result in the conversation history. Use the existing result.
|
|
Use tools to gather information before asking the user for clarification. Explore files, run commands, and investigate the environment first. Only ask when you have exhausted what you can discover on your own.
|
|
Use execute_code for all shell commands and scripts. Use execute_tool only for named scripts in ~/mnt/anvillm/tools. Use execute_pipe to chain steps: use {code: "cmd --flags"} for shell commands, {tool, args} only for named scripts in ~/mnt/anvillm/tools.
|
|
Use grep or execute_code to search and explore files. Use file_read only when you need to write — it reads the full file and is required before file_write. Never use shell commands to read or write files.
|
|
|
|
Tool call examples:
|
|
Read a file: {"path": "/home/user/foo.go"}
|
|
Run shell code: {"code": "ls -la", "language": "bash"}
|
|
List directory: {"code": "find . -maxdepth 2 -type f", "language": "bash"}
|
|
Run named tool: {"tool": "discover_skill.sh", "args": ["keyword"]}
|
|
Pipeline: {"pipe": [{"code": "cat file.txt"}, {"code": "grep foo"}]}`
|
|
|
|
// BuildFirstPrompt seeds the first user message with the project file listing
|
|
// and README so the agent has immediate context.
|
|
func BuildFirstPrompt(input string) string {
|
|
cwd, err := os.Getwd()
|
|
if err != nil {
|
|
return input
|
|
}
|
|
|
|
var sb strings.Builder
|
|
sb.WriteString(input)
|
|
|
|
const maxListingBytes = 16 * 1024
|
|
const maxReadmeBytes = 8 * 1024
|
|
|
|
lsOut, err := exec.Command("git", "-C", cwd, "ls-files").Output()
|
|
if err == nil && len(lsOut) > 0 {
|
|
if len(lsOut) > maxListingBytes {
|
|
lsOut = lsOut[:maxListingBytes]
|
|
if i := bytes.LastIndexByte(lsOut, '\n'); i >= 0 {
|
|
lsOut = lsOut[:i+1]
|
|
}
|
|
lsOut = append(lsOut, []byte("...(truncated)\n")...)
|
|
}
|
|
sb.WriteString("\n\n--- files (git ls-files) ---\n")
|
|
sb.Write(lsOut)
|
|
} else {
|
|
var files []string
|
|
filepath.WalkDir(cwd, func(path string, d fs.DirEntry, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
rel := strings.TrimPrefix(path, cwd+"/")
|
|
if rel == "" {
|
|
return nil
|
|
}
|
|
if strings.HasPrefix(d.Name(), ".") {
|
|
if d.IsDir() {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if !d.IsDir() {
|
|
files = append(files, rel)
|
|
}
|
|
return nil
|
|
})
|
|
if len(files) > 0 {
|
|
sb.WriteString("\n\n--- files ---\n")
|
|
written := 0
|
|
for _, f := range files {
|
|
line := f + "\n"
|
|
if written+len(line) > maxListingBytes {
|
|
sb.WriteString("...(truncated)\n")
|
|
break
|
|
}
|
|
sb.WriteString(line)
|
|
written += len(line)
|
|
}
|
|
}
|
|
}
|
|
|
|
readmeData, err := os.ReadFile(cwd + "/README.md")
|
|
if err == nil && len(readmeData) > 0 {
|
|
if len(readmeData) > maxReadmeBytes {
|
|
readmeData = readmeData[:maxReadmeBytes]
|
|
if i := bytes.LastIndexByte(readmeData, '\n'); i >= 0 {
|
|
readmeData = readmeData[:i+1]
|
|
}
|
|
readmeData = append(readmeData, []byte("...(truncated)\n")...)
|
|
}
|
|
sb.WriteString("\n--- README.md ---\n")
|
|
sb.Write(readmeData)
|
|
}
|
|
|
|
return sb.String()
|
|
}
|
|
|
|
// SystemPrompt builds the full system prompt for a given tool set.
|
|
func SystemPrompt(allTools []backend.Tool) string {
|
|
cwd, _ := os.Getwd()
|
|
now := time.Now().Format("2006-01-02 15:04:05 MST")
|
|
names := make([]string, len(allTools))
|
|
for i, t := range allTools {
|
|
names[i] = t.Name
|
|
}
|
|
return systemPromptBase + "\n\nWorking directory: " + cwd +
|
|
"\nCurrent time: " + now +
|
|
"\nAvailable tools: " + strings.Join(names, ", ")
|
|
}
|
|
|
|
// BuiltinTools is the set of tools provided by the agent core.
|
|
var BuiltinTools = []backend.Tool{
|
|
{
|
|
Name: "execute_code",
|
|
Description: "Run inline code in a sandboxed environment.",
|
|
Parameters: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["code"],
|
|
"properties": {
|
|
"code": {"type": "string", "description": "Code to execute."},
|
|
"language": {"type": "string", "description": "Language interpreter (default: bash)."},
|
|
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
|
|
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
|
|
}
|
|
}`),
|
|
},
|
|
{
|
|
Name: "execute_tool",
|
|
Description: "Run a named tool script from ~/mnt/anvillm/tools in a sandboxed environment. Use this only for named scripts, not for inline shell commands.",
|
|
Parameters: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["tool"],
|
|
"properties": {
|
|
"tool": {"type": "string", "description": "Name of the tool script (e.g. discover_skill.sh)."},
|
|
"args": {"type": "array", "items": {"type": "string"}, "description": "Arguments for the tool script."},
|
|
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
|
|
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
|
|
}
|
|
}`),
|
|
},
|
|
{
|
|
Name: "execute_pipe",
|
|
Description: "Run a pipeline of steps, piping stdout of each into stdin of the next. Use {code: \"cmd --flags\"} for shell commands; use {tool, args} only for named scripts in ~/mnt/anvillm/tools.",
|
|
Parameters: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["pipe"],
|
|
"properties": {
|
|
"pipe": {
|
|
"type": "array",
|
|
"items": {
|
|
"type": "object",
|
|
"properties": {
|
|
"tool": {"type": "string"},
|
|
"args": {"type": "array", "items": {"type": "string"}},
|
|
"code": {"type": "string"}
|
|
}
|
|
}
|
|
},
|
|
"timeout": {"type": "integer", "description": "Timeout in seconds (default: 30)."},
|
|
"sandbox": {"type": "string", "description": "Sandbox name (default: default)."}
|
|
}
|
|
}`),
|
|
},
|
|
{
|
|
Name: "file_read",
|
|
Description: "Read a file in full. Output includes line numbers. Use grep/execute_code to search before reading. Prefer file_read only when you need to write — use grep or execute_code for exploration.",
|
|
Parameters: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["path"],
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Path to the file."}
|
|
}
|
|
}`),
|
|
},
|
|
{
|
|
Name: "file_write",
|
|
Description: "Write content to a file. For existing files, start_line and end_line are required — whole-file overwrites are not permitted. For new files (not yet on disk), omit start_line/end_line to write the full content. Always use file_read or grep -n to identify the exact line range before writing. Never guess line numbers. Preserve original formatting and indentation.",
|
|
Parameters: json.RawMessage(`{
|
|
"type": "object",
|
|
"required": ["path", "content"],
|
|
"properties": {
|
|
"path": {"type": "string", "description": "Path to the file."},
|
|
"content": {"type": "string", "description": "Content to write."},
|
|
"start_line": {"type": "integer", "description": "First line of range to replace, 1-based."},
|
|
"end_line": {"type": "integer", "description": "Last line of range to replace, inclusive."}
|
|
}
|
|
}`),
|
|
},
|
|
}
|
|
|
|
// AgentEnv holds the runtime state derived from an agent config file.
|
|
type AgentEnv struct {
|
|
McpExec *tools.Executor
|
|
Tools []backend.Tool
|
|
Exec agent.ToolExecutor
|
|
Confirm *agent.ConfirmFn
|
|
Hooks map[string]string
|
|
SystemPrompt string
|
|
GenParams backend.GenerationParams
|
|
CtxOverhead int
|
|
Messages []string
|
|
InvalidateCaches func()
|
|
}
|
|
|
|
// BuildAgentEnv constructs an AgentEnv from a config file and a builtin executor.
|
|
func BuildAgentEnv(cfg *config.Config, builtinExec *execpkg.Executor) AgentEnv {
|
|
var messages []string
|
|
mcpExec := tools.NewExecutor()
|
|
|
|
if cfg != nil {
|
|
for name, serverCfg := range cfg.MCPServers {
|
|
if serverCfg.Disabled || serverCfg.Command == "" {
|
|
continue
|
|
}
|
|
transport := mcp.NewSTDIOTransport(serverCfg.Command, serverCfg.Args, serverCfg.Env)
|
|
client, err := transport.Connect()
|
|
if err != nil {
|
|
messages = append(messages, fmt.Sprintf("MCP %s: failed to connect: %v", name, err))
|
|
continue
|
|
}
|
|
mcpExec.AddServer(name, client)
|
|
messages = append(messages, fmt.Sprintf("MCP %s: connected", name))
|
|
}
|
|
}
|
|
|
|
mcpTools, err := mcpExec.ListTools()
|
|
if err != nil {
|
|
messages = append(messages, fmt.Sprintf("MCP list tools: %v", err))
|
|
} else if len(mcpTools) > 0 {
|
|
messages = append(messages, fmt.Sprintf("MCP tools loaded: %d", len(mcpTools)))
|
|
}
|
|
|
|
serverOf := make(map[string]string, len(mcpTools))
|
|
for _, t := range mcpTools {
|
|
serverOf[t.Name] = t.Server
|
|
}
|
|
|
|
allTools := append(mcpToolsToBackend(mcpTools), BuiltinTools...)
|
|
|
|
hooks := map[string]string{}
|
|
agentPrompt := ""
|
|
trustedTools := map[string]struct{}{}
|
|
var genParams backend.GenerationParams
|
|
if cfg != nil {
|
|
if cfg.Hooks != nil {
|
|
hooks = cfg.Hooks
|
|
}
|
|
agentPrompt = cfg.Prompt
|
|
for _, t := range cfg.TrustedTools {
|
|
trustedTools[t] = struct{}{}
|
|
}
|
|
genParams = backend.GenerationParams{
|
|
MaxTokens: cfg.MaxTokens,
|
|
Temperature: cfg.Temperature,
|
|
FrequencyPenalty: cfg.FrequencyPenalty,
|
|
PresencePenalty: cfg.PresencePenalty,
|
|
}
|
|
}
|
|
|
|
sp := SystemPrompt(allTools)
|
|
if agentPrompt != "" {
|
|
sp = SystemPrompt(allTools) + "\n\n" + agentPrompt
|
|
}
|
|
|
|
overhead := len(sp)
|
|
for _, t := range allTools {
|
|
overhead += len(t.Name) + len(t.Description) + len(t.Parameters)
|
|
}
|
|
|
|
builtinNames := make(map[string]struct{}, len(BuiltinTools))
|
|
for _, t := range BuiltinTools {
|
|
builtinNames[t.Name] = struct{}{}
|
|
}
|
|
|
|
var confirmFn agent.ConfirmFn
|
|
confirmPtr := &confirmFn
|
|
|
|
fileReadCache := make(map[string]bool)
|
|
toolCallSeen := make(map[string]bool)
|
|
|
|
rawExec := func(ctx context.Context, name string, args json.RawMessage) (string, error) {
|
|
if server, ok := serverOf[name]; ok {
|
|
raw, err := mcpExec.Execute(server, name, args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return extractMCPText(raw), nil
|
|
}
|
|
if _, ok := builtinNames[name]; ok {
|
|
var cfn agent.ConfirmFn
|
|
if _, trusted := trustedTools[name]; !trusted {
|
|
cfn = *confirmPtr
|
|
}
|
|
|
|
if name == "file_read" {
|
|
var a struct {
|
|
Path string `json:"path"`
|
|
}
|
|
json.Unmarshal(args, &a) //nolint:errcheck
|
|
if a.Path != "" && fileReadCache[a.Path] {
|
|
return "", fmt.Errorf("file_read: %s is already in context this session; use existing content instead of re-reading", a.Path)
|
|
}
|
|
content, err := dispatchFileRead(cfn, args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if a.Path != "" {
|
|
fileReadCache[a.Path] = true
|
|
}
|
|
return content, nil
|
|
}
|
|
|
|
if name == "file_write" {
|
|
var a struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
StartLine int `json:"start_line"`
|
|
EndLine int `json:"end_line"`
|
|
}
|
|
json.Unmarshal(args, &a) //nolint:errcheck
|
|
if a.Path != "" {
|
|
_, statErr := os.Stat(a.Path)
|
|
fileExists := !os.IsNotExist(statErr)
|
|
if fileExists {
|
|
if a.StartLine == 0 && a.EndLine == 0 {
|
|
return "", fmt.Errorf("file_write: whole-file overwrite of existing file %s is not allowed; use start_line/end_line to write specific line ranges", a.Path)
|
|
}
|
|
if !fileReadCache[a.Path] {
|
|
return "", fmt.Errorf("file_write: %s has not been read this session; read it first", a.Path)
|
|
}
|
|
}
|
|
}
|
|
result, err := dispatchBuiltinExec(ctx, name, builtinExec, cfn, args)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if a.Path != "" {
|
|
fileReadCache[a.Path] = true
|
|
}
|
|
return result, nil
|
|
}
|
|
|
|
return dispatchBuiltinExec(ctx, name, builtinExec, cfn, args)
|
|
}
|
|
return "", fmt.Errorf("unknown tool: %s", name)
|
|
}
|
|
|
|
execFn := func(ctx context.Context, name string, args json.RawMessage) (string, error) {
|
|
if name != "file_read" && name != "file_write" {
|
|
key := name + "\x00" + string(args)
|
|
if toolCallSeen[key] {
|
|
return "", fmt.Errorf("duplicate tool call: %s with these exact arguments was already called this session; the result is already in your context", name)
|
|
}
|
|
result, err := rawExec(ctx, name, args)
|
|
if err == nil {
|
|
toolCallSeen[key] = true
|
|
}
|
|
return result, err
|
|
}
|
|
return rawExec(ctx, name, args)
|
|
}
|
|
|
|
return AgentEnv{
|
|
McpExec: mcpExec,
|
|
Tools: allTools,
|
|
Exec: execFn,
|
|
Confirm: confirmPtr,
|
|
Hooks: hooks,
|
|
SystemPrompt: sp,
|
|
GenParams: genParams,
|
|
CtxOverhead: overhead,
|
|
Messages: messages,
|
|
InvalidateCaches: func() {
|
|
clear(fileReadCache)
|
|
clear(toolCallSeen)
|
|
},
|
|
}
|
|
}
|
|
|
|
// AgentConfigPath resolves the config file path for a named agent.
|
|
func AgentConfigPath(agentsDir, name string) string {
|
|
p := agentsDir + "/" + name + ".json"
|
|
if _, err := os.Stat(p); err == nil {
|
|
return p
|
|
}
|
|
if name == "default" {
|
|
home, _ := os.UserHomeDir()
|
|
return home + "/.config/ollie/config.json"
|
|
}
|
|
return p
|
|
}
|
|
|
|
// NewSessionID generates a unique session identifier.
|
|
func NewSessionID() string {
|
|
b := make([]byte, 3)
|
|
rand.Read(b) //nolint:errcheck
|
|
return time.Now().Format("20060102-150405") + "-" + fmt.Sprintf("%06x", b)
|
|
}
|
|
|
|
// DefaultModelForBackend returns a sensible default model for the given backend label.
|
|
func DefaultModelForBackend(name string) string {
|
|
switch name {
|
|
case "anthropic":
|
|
return "claude-sonnet-4-5"
|
|
case "openrouter":
|
|
return "deepseek/deepseek-v3.2"
|
|
case "kiro", "codewhisperer":
|
|
return "auto"
|
|
default: // ollama, local, groq, mistral, together, etc.
|
|
return "qwen3.5:9b"
|
|
}
|
|
}
|
|
|
|
// ResolveBackendName returns a short human-readable backend label from env vars.
|
|
func ResolveBackendName() string {
|
|
which := os.Getenv("OLLIE_BACKEND")
|
|
if which == "" {
|
|
which = "ollama"
|
|
}
|
|
if which != "openai" {
|
|
return which
|
|
}
|
|
url := strings.ToLower(os.Getenv("OLLIE_OPENAI_URL"))
|
|
switch {
|
|
case strings.Contains(url, "openrouter"):
|
|
return "openrouter"
|
|
case strings.Contains(url, "together"):
|
|
return "together"
|
|
case strings.Contains(url, "groq"):
|
|
return "groq"
|
|
case strings.Contains(url, "mistral"):
|
|
return "mistral"
|
|
case strings.Contains(url, "anthropic"):
|
|
return "anthropic"
|
|
case strings.Contains(url, "localhost") || strings.Contains(url, "127.0.0.1"):
|
|
return "local"
|
|
default:
|
|
return "openai"
|
|
}
|
|
}
|
|
|
|
// infoEvent wraps a plain-text message as an info Event.
|
|
func infoEvent(text string) core.Event {
|
|
return core.Event{Role: "info", Content: text + "\n"}
|
|
}
|
|
|
|
// actionHandle holds the cancel function for the current agent turn.
|
|
type actionHandle struct {
|
|
cancel context.CancelCauseFunc
|
|
}
|
|
|
|
// AgentCoreConfig is the configuration for creating an AgentCore.
|
|
type AgentCoreConfig struct {
|
|
Backend backend.Backend
|
|
BackendName string
|
|
ModelName string
|
|
AgentName string
|
|
AgentsDir string
|
|
SessionsDir string
|
|
SessionID string
|
|
Session *agent.Session
|
|
Env AgentEnv
|
|
BuiltinExec *execpkg.Executor
|
|
}
|
|
|
|
// AgentCore is the Core implementation. It owns all agent and session state
|
|
// but has no knowledge of how output is rendered.
|
|
type AgentCore struct {
|
|
session *agent.Session
|
|
loopcfg agent.Config
|
|
hooks map[string]string
|
|
modelName string
|
|
backendName string
|
|
agentName string
|
|
agentsDir string
|
|
sessionsDir string
|
|
sessionID string
|
|
mcpExec *tools.Executor
|
|
builtinExec *execpkg.Executor
|
|
confirmPtr *agent.ConfirmFn
|
|
ctxOverhead int
|
|
invalidateCaches func()
|
|
currentAction atomic.Pointer[actionHandle]
|
|
}
|
|
|
|
var _ core.Core = (*AgentCore)(nil) // compile-time interface check
|
|
|
|
// NewAgentCore creates an AgentCore from the given configuration.
|
|
func NewAgentCore(cfg AgentCoreConfig) *AgentCore {
|
|
loopcfg := agent.Config{
|
|
Backend: cfg.Backend,
|
|
Model: cfg.ModelName,
|
|
SystemPrompt: cfg.Env.SystemPrompt,
|
|
Tools: cfg.Env.Tools,
|
|
Exec: cfg.Env.Exec,
|
|
MaxSteps: 20,
|
|
GenerationParams: cfg.Env.GenParams,
|
|
}
|
|
return &AgentCore{
|
|
session: cfg.Session,
|
|
loopcfg: loopcfg,
|
|
hooks: cfg.Env.Hooks,
|
|
modelName: cfg.ModelName,
|
|
backendName: cfg.BackendName,
|
|
agentName: cfg.AgentName,
|
|
agentsDir: cfg.AgentsDir,
|
|
sessionsDir: cfg.SessionsDir,
|
|
sessionID: cfg.SessionID,
|
|
mcpExec: cfg.Env.McpExec,
|
|
builtinExec: cfg.BuiltinExec,
|
|
confirmPtr: cfg.Env.Confirm,
|
|
ctxOverhead: cfg.Env.CtxOverhead,
|
|
invalidateCaches: cfg.Env.InvalidateCaches,
|
|
}
|
|
}
|
|
|
|
func (s *AgentCore) prompt() string {
|
|
return fmt.Sprintf("[%s :: %s] ", s.backendName, s.agentName)
|
|
}
|
|
|
|
// Prompt returns the display prompt string for the current session state.
|
|
func (s *AgentCore) Prompt() string { return s.prompt() }
|
|
|
|
func (s *AgentCore) saveSession() {
|
|
if s.session == nil || s.sessionID == "" || s.sessionsDir == "" {
|
|
return
|
|
}
|
|
path := s.sessionsDir + "/" + s.sessionID + ".json"
|
|
if err := s.session.SaveTo(path, s.sessionID, s.agentName); err != nil {
|
|
fmt.Fprintln(os.Stderr, "session save:", err)
|
|
}
|
|
}
|
|
|
|
func (s *AgentCore) getActionCancel() context.CancelCauseFunc {
|
|
if a := s.currentAction.Load(); a != nil {
|
|
return a.cancel
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// Interrupt cancels the current in-progress agent turn.
|
|
// Returns true if an action was running and was cancelled.
|
|
func (s *AgentCore) Interrupt(cause error) bool {
|
|
if cancel := s.getActionCancel(); cancel != nil {
|
|
cancel(cause)
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
|
|
// Submit implements Core. It processes one line of user input: slash commands
|
|
// and shell shortcuts are dispatched immediately via handler; any other input
|
|
// starts an agent turn that streams events to handler.
|
|
func (s *AgentCore) Submit(ctx context.Context, input string, handler core.EventHandler) {
|
|
if s.handleCommand(ctx, input, handler) {
|
|
return
|
|
}
|
|
|
|
if hook := s.hooks["userPromptSubmit"]; hook != "" {
|
|
exec.Command("sh", "-c", hook).Run() //nolint:errcheck
|
|
}
|
|
|
|
if s.session == nil {
|
|
s.session = agent.NewSessionWithConfig(BuildFirstPrompt(input), agent.ContextConfig{
|
|
FixedOverheadChars: s.ctxOverhead,
|
|
})
|
|
} else {
|
|
s.session.AppendUserMessage(input)
|
|
}
|
|
|
|
actCtx, actCancel := context.WithCancelCause(ctx)
|
|
handle := &actionHandle{cancel: actCancel}
|
|
s.currentAction.Store(handle)
|
|
|
|
s.loopcfg.Output = handler
|
|
*s.confirmPtr = nil // auto-approve all confirmations for now
|
|
|
|
if hook := s.hooks["agentSpawn"]; hook != "" {
|
|
exec.Command("sh", "-c", hook).Run() //nolint:errcheck
|
|
}
|
|
|
|
err := agent.Run(actCtx, s.loopcfg, s.session)
|
|
actCancel(nil)
|
|
s.currentAction.CompareAndSwap(handle, nil)
|
|
|
|
if err != nil && !errors.Is(err, context.Canceled) && !errors.Is(err, core.ErrInterrupted) {
|
|
handler(core.Event{Role: "error", Content: err.Error()})
|
|
s.session.Rollback()
|
|
}
|
|
|
|
handler(core.Event{Role: "newline"})
|
|
|
|
if hook := s.hooks["stop"]; hook != "" {
|
|
exec.Command("sh", "-c", hook).Run() //nolint:errcheck
|
|
}
|
|
|
|
s.saveSession()
|
|
}
|
|
|
|
func (s *AgentCore) handleCommand(ctx context.Context, input string, handler core.EventHandler) bool {
|
|
if strings.HasPrefix(input, "!") {
|
|
cmdStr := strings.TrimSpace(input[1:])
|
|
if cmdStr == "" {
|
|
return true
|
|
}
|
|
o, err := exec.Command("sh", "-c", cmdStr).CombinedOutput()
|
|
if err != nil {
|
|
handler(infoEvent("error: " + err.Error()))
|
|
}
|
|
if len(o) > 0 {
|
|
handler(infoEvent(strings.TrimRight(string(o), "\n")))
|
|
}
|
|
return true
|
|
}
|
|
|
|
if !strings.HasPrefix(input, "/") {
|
|
return false
|
|
}
|
|
|
|
parts := strings.Fields(input)
|
|
if len(parts) == 0 {
|
|
return false
|
|
}
|
|
|
|
cmd := parts[0]
|
|
args := parts[1:]
|
|
|
|
switch cmd {
|
|
case "/backend":
|
|
if len(args) == 0 {
|
|
handler(infoEvent("error: /backend requires an argument (e.g., /backend ollama)"))
|
|
return true
|
|
}
|
|
os.Setenv("OLLIE_BACKEND", args[0])
|
|
be, err := backend.New()
|
|
if err != nil {
|
|
handler(infoEvent(fmt.Sprintf("error: failed to switch backend: %v", err)))
|
|
return true
|
|
}
|
|
s.loopcfg.Backend = be
|
|
s.backendName = ResolveBackendName()
|
|
s.loopcfg.Model = DefaultModelForBackend(s.backendName)
|
|
s.modelName = s.loopcfg.Model
|
|
handler(infoEvent(fmt.Sprintf("switched backend to: %s (model: %s)", s.backendName, s.modelName)))
|
|
return true
|
|
|
|
case "/model":
|
|
if len(args) == 0 {
|
|
handler(infoEvent("error: /model requires an argument (e.g., /model qwen3:8b)"))
|
|
return true
|
|
}
|
|
s.loopcfg.Model = args[0]
|
|
s.modelName = args[0]
|
|
handler(infoEvent("switched model to: " + args[0]))
|
|
return true
|
|
|
|
case "/agents":
|
|
entries, err := os.ReadDir(s.agentsDir)
|
|
if err != nil {
|
|
handler(infoEvent(fmt.Sprintf("agents: %v", err)))
|
|
return true
|
|
}
|
|
found := false
|
|
for _, e := range entries {
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
name := strings.TrimSuffix(e.Name(), ".json")
|
|
marker := " "
|
|
if name == s.agentName {
|
|
marker = "* "
|
|
}
|
|
handler(infoEvent(marker + name))
|
|
found = true
|
|
}
|
|
if !found {
|
|
handler(infoEvent("no agents found in " + s.agentsDir))
|
|
}
|
|
return true
|
|
|
|
case "/agent":
|
|
if len(args) == 0 {
|
|
handler(infoEvent("active agent: " + s.agentName))
|
|
return true
|
|
}
|
|
name := args[0]
|
|
cfgPath := AgentConfigPath(s.agentsDir, name)
|
|
cfg, err := config.Load(cfgPath)
|
|
if err != nil {
|
|
handler(infoEvent(fmt.Sprintf("error: agent %q: %v", name, err)))
|
|
return true
|
|
}
|
|
if s.mcpExec != nil {
|
|
s.mcpExec.Close()
|
|
}
|
|
env := BuildAgentEnv(cfg, s.builtinExec)
|
|
s.mcpExec = env.McpExec
|
|
s.hooks = env.Hooks
|
|
s.loopcfg.SystemPrompt = env.SystemPrompt
|
|
s.loopcfg.Tools = env.Tools
|
|
s.loopcfg.Exec = env.Exec
|
|
s.loopcfg.GenerationParams = env.GenParams
|
|
s.ctxOverhead = env.CtxOverhead
|
|
s.confirmPtr = env.Confirm
|
|
s.invalidateCaches = env.InvalidateCaches
|
|
s.agentName = name
|
|
s.session = nil
|
|
s.sessionID = NewSessionID()
|
|
for _, msg := range env.Messages {
|
|
handler(infoEvent(msg))
|
|
}
|
|
handler(infoEvent("agent: " + name))
|
|
return true
|
|
|
|
case "/compact":
|
|
if s.session == nil {
|
|
handler(infoEvent("nothing to compact"))
|
|
return true
|
|
}
|
|
n, summary, err := s.session.Compact(ctx, s.loopcfg.Backend, s.loopcfg.Model)
|
|
if err != nil {
|
|
handler(infoEvent("compact error: " + err.Error()))
|
|
} else if n == 0 {
|
|
handler(infoEvent("nothing to compact"))
|
|
} else {
|
|
handler(infoEvent(fmt.Sprintf("compacted %d messages", n)))
|
|
if summary != "" {
|
|
handler(core.Event{Role: "newline"})
|
|
handler(infoEvent(summary))
|
|
}
|
|
s.saveSession()
|
|
if s.invalidateCaches != nil {
|
|
s.invalidateCaches()
|
|
}
|
|
}
|
|
return true
|
|
|
|
case "/context":
|
|
if s.session == nil {
|
|
handler(infoEvent("no active session"))
|
|
return true
|
|
}
|
|
handler(infoEvent(strings.TrimRight(s.session.ContextDebug(), "\n")))
|
|
return true
|
|
|
|
case "/history":
|
|
if s.session == nil {
|
|
handler(infoEvent("no active session"))
|
|
return true
|
|
}
|
|
for _, msg := range s.session.History() {
|
|
preview := msg.Content
|
|
if len(preview) > 200 {
|
|
preview = preview[:200] + "..."
|
|
}
|
|
handler(infoEvent(fmt.Sprintf("[%s] %s", msg.Role, preview)))
|
|
}
|
|
return true
|
|
|
|
case "/clear":
|
|
s.session = nil
|
|
s.sessionID = NewSessionID()
|
|
if s.invalidateCaches != nil {
|
|
s.invalidateCaches()
|
|
}
|
|
handler(infoEvent("cleared"))
|
|
return true
|
|
|
|
case "/sessions":
|
|
entries, err := os.ReadDir(s.sessionsDir)
|
|
if err != nil {
|
|
handler(infoEvent(fmt.Sprintf("sessions: %v", err)))
|
|
return true
|
|
}
|
|
found := false
|
|
for i := len(entries) - 1; i >= 0; i-- {
|
|
e := entries[i]
|
|
if e.IsDir() || !strings.HasSuffix(e.Name(), ".json") {
|
|
continue
|
|
}
|
|
id := strings.TrimSuffix(e.Name(), ".json")
|
|
marker := " "
|
|
if id == s.sessionID {
|
|
marker = "* "
|
|
}
|
|
label := id
|
|
if data, readErr := os.ReadFile(s.sessionsDir + "/" + e.Name()); readErr == nil {
|
|
var ps agent.PersistedSession
|
|
if json.Unmarshal(data, &ps) == nil {
|
|
goal := ""
|
|
for _, msg := range ps.Messages {
|
|
if msg.Role == "user" {
|
|
goal = msg.Content
|
|
break
|
|
}
|
|
}
|
|
if len(goal) > 60 {
|
|
goal = goal[:60] + "..."
|
|
}
|
|
label = fmt.Sprintf("%-24s [%s] %q", id, ps.Agent, goal)
|
|
}
|
|
}
|
|
handler(infoEvent(marker + label))
|
|
found = true
|
|
}
|
|
if !found {
|
|
handler(infoEvent("no sessions found in " + s.sessionsDir))
|
|
}
|
|
return true
|
|
|
|
case "/help":
|
|
lines := []string{
|
|
"Available commands:",
|
|
" /agents - list available agent configs",
|
|
" /sessions - list saved sessions",
|
|
" /agent [name] - show or switch active agent",
|
|
" /backend <type> - switch backend (ollama, openai)",
|
|
" /model <name> - switch model",
|
|
" /queued [pop|clear] - manage queued prompts",
|
|
" /compact - summarize evicted context messages",
|
|
" /context - show context window debug info",
|
|
" /history - dump bounded message history",
|
|
" /clear - clear session",
|
|
" /help - show this help",
|
|
" !<cmd> - run shell command",
|
|
}
|
|
for _, l := range lines {
|
|
handler(infoEvent(l))
|
|
}
|
|
return true
|
|
}
|
|
|
|
return false
|
|
}
|
|
|
|
func mcpToolsToBackend(mcpTools []tools.ToolInfo) []backend.Tool {
|
|
out := make([]backend.Tool, len(mcpTools))
|
|
for i, t := range mcpTools {
|
|
out[i] = backend.Tool{
|
|
Name: t.Name,
|
|
Description: t.Description,
|
|
Parameters: t.InputSchema,
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func extractMCPText(raw json.RawMessage) string {
|
|
var result struct {
|
|
Content []struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text"`
|
|
} `json:"content"`
|
|
}
|
|
if err := json.Unmarshal(raw, &result); err != nil {
|
|
return string(raw)
|
|
}
|
|
var parts []string
|
|
for _, c := range result.Content {
|
|
if c.Type == "text" {
|
|
parts = append(parts, c.Text)
|
|
}
|
|
}
|
|
return strings.Join(parts, "\n")
|
|
}
|
|
|
|
func dispatchBuiltinExec(ctx context.Context, name string, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
|
|
switch name {
|
|
case "file_read":
|
|
return dispatchFileRead(confirm, args)
|
|
case "file_write":
|
|
return dispatchFileWrite(confirm, args)
|
|
case "execute_pipe":
|
|
return dispatchExecutePipe(ctx, e, confirm, args)
|
|
case "execute_tool":
|
|
return dispatchExecuteTool(ctx, e, confirm, args)
|
|
default:
|
|
return dispatchExecuteCode(ctx, e, confirm, args)
|
|
}
|
|
}
|
|
|
|
func execArgs(args json.RawMessage) (code, language, sandbox string, timeout int, err error) {
|
|
var a struct {
|
|
Code string `json:"code"`
|
|
Language string `json:"language"`
|
|
Timeout int `json:"timeout"`
|
|
Sandbox string `json:"sandbox"`
|
|
}
|
|
if err = json.Unmarshal(args, &a); err != nil {
|
|
return
|
|
}
|
|
code = a.Code
|
|
language = a.Language
|
|
if language == "" {
|
|
language = "bash"
|
|
}
|
|
timeout = a.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 30
|
|
}
|
|
sandbox = a.Sandbox
|
|
if sandbox == "" {
|
|
sandbox = "default"
|
|
}
|
|
return
|
|
}
|
|
|
|
func dispatchExecuteCode(ctx context.Context, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
|
|
code, language, sandbox, timeout, err := execArgs(args)
|
|
if err != nil {
|
|
return "", fmt.Errorf("execute_code: bad args: %w", err)
|
|
}
|
|
if code == "" {
|
|
return "", fmt.Errorf("execute_code: 'code' is required")
|
|
}
|
|
if confirm != nil && !confirm(fmt.Sprintf("execute_code: %s", squashWhitespace(code))) {
|
|
return "", fmt.Errorf("execute_code: denied by user")
|
|
}
|
|
return e.Execute(ctx, code, language, timeout, sandbox, false)
|
|
}
|
|
|
|
func dispatchExecuteTool(ctx context.Context, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
|
|
var a struct {
|
|
Tool string `json:"tool"`
|
|
Args []string `json:"args"`
|
|
Language string `json:"language"`
|
|
Timeout int `json:"timeout"`
|
|
Sandbox string `json:"sandbox"`
|
|
}
|
|
if err := json.Unmarshal(args, &a); err != nil {
|
|
return "", fmt.Errorf("execute_tool: bad args: %w", err)
|
|
}
|
|
if a.Tool == "" {
|
|
return "", fmt.Errorf("execute_tool: 'tool' is required")
|
|
}
|
|
if confirm != nil && !confirm(fmt.Sprintf("execute_tool: %s %s", a.Tool, strings.Join(a.Args, " "))) {
|
|
return "", fmt.Errorf("execute_tool: denied by user")
|
|
}
|
|
toolCode, err := execpkg.ReadTool(a.Tool)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
code := toolCode
|
|
if len(a.Args) > 0 {
|
|
var escaped []string
|
|
for _, arg := range a.Args {
|
|
escaped = append(escaped, "'"+strings.ReplaceAll(arg, "'", "'\\''")+"'")
|
|
}
|
|
code = fmt.Sprintf("set -- %s\n%s", strings.Join(escaped, " "), code)
|
|
}
|
|
language := a.Language
|
|
if language == "" {
|
|
language = "bash"
|
|
}
|
|
timeout := a.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 30
|
|
}
|
|
sandbox := a.Sandbox
|
|
if sandbox == "" {
|
|
sandbox = "default"
|
|
}
|
|
return e.Execute(ctx, code, language, timeout, sandbox, true)
|
|
}
|
|
|
|
func dispatchExecutePipe(ctx context.Context, e *execpkg.Executor, confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
|
|
var a struct {
|
|
Pipe []execpkg.PipeStep `json:"pipe"`
|
|
Timeout int `json:"timeout"`
|
|
Sandbox string `json:"sandbox"`
|
|
}
|
|
if err := json.Unmarshal(args, &a); err != nil {
|
|
return "", fmt.Errorf("execute_pipe: bad args: %w", err)
|
|
}
|
|
if len(a.Pipe) == 0 {
|
|
return "", fmt.Errorf("execute_pipe: 'pipe' is required")
|
|
}
|
|
code, _, err := execpkg.BuildPipeline(a.Pipe)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if confirm != nil && !confirm(fmt.Sprintf("execute_pipe: %s", squashWhitespace(code))) {
|
|
return "", fmt.Errorf("execute_pipe: denied by user")
|
|
}
|
|
timeout := a.Timeout
|
|
if timeout <= 0 {
|
|
timeout = 30
|
|
}
|
|
sandbox := a.Sandbox
|
|
if sandbox == "" {
|
|
sandbox = "default"
|
|
}
|
|
return e.Execute(ctx, code, "bash", timeout, sandbox, true)
|
|
}
|
|
|
|
func dispatchFileRead(confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
|
|
var a struct {
|
|
Path string `json:"path"`
|
|
}
|
|
if err := json.Unmarshal(args, &a); err != nil {
|
|
return "", fmt.Errorf("file_read: bad args: %w", err)
|
|
}
|
|
if a.Path == "" {
|
|
return "", fmt.Errorf("file_read: 'path' is required")
|
|
}
|
|
if confirm != nil && !confirm(fmt.Sprintf("read %s", a.Path)) {
|
|
return "", fmt.Errorf("file_read: denied by user")
|
|
}
|
|
data, err := os.ReadFile(a.Path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("file_read: %w", err)
|
|
}
|
|
lines := strings.Split(string(data), "\n")
|
|
var out strings.Builder
|
|
for i, line := range lines {
|
|
fmt.Fprintf(&out, "%d\t%s\n", i+1, line)
|
|
}
|
|
return strings.TrimRight(out.String(), "\n"), nil
|
|
}
|
|
|
|
func dispatchFileWrite(confirm agent.ConfirmFn, args json.RawMessage) (string, error) {
|
|
var a struct {
|
|
Path string `json:"path"`
|
|
Content string `json:"content"`
|
|
StartLine int `json:"start_line"`
|
|
EndLine int `json:"end_line"`
|
|
}
|
|
if err := json.Unmarshal(args, &a); err != nil {
|
|
return "", fmt.Errorf("file_write: bad args: %w", err)
|
|
}
|
|
if a.Path == "" {
|
|
return "", fmt.Errorf("file_write: 'path' is required")
|
|
}
|
|
prompt := fmt.Sprintf("write %s", a.Path)
|
|
if a.StartLine > 0 {
|
|
prompt = fmt.Sprintf("write %s lines %d-%d", a.Path, a.StartLine, a.EndLine)
|
|
}
|
|
if confirm != nil && !confirm(prompt) {
|
|
return "", fmt.Errorf("file_write: denied by user")
|
|
}
|
|
|
|
if a.StartLine == 0 && a.EndLine == 0 {
|
|
if err := os.WriteFile(a.Path, []byte(a.Content), 0644); err != nil {
|
|
return "", fmt.Errorf("file_write: %w", err)
|
|
}
|
|
return fmt.Sprintf("wrote %d bytes to %s", len(a.Content), a.Path), nil
|
|
}
|
|
|
|
data, err := os.ReadFile(a.Path)
|
|
if err != nil {
|
|
return "", fmt.Errorf("file_write: %w", err)
|
|
}
|
|
lines := strings.Split(string(data), "\n")
|
|
start, end := a.StartLine, a.EndLine
|
|
if start < 1 {
|
|
start = 1
|
|
}
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
if start > end {
|
|
return "", fmt.Errorf("file_write: start_line %d > end_line %d", start, end)
|
|
}
|
|
newLines := strings.Split(a.Content, "\n")
|
|
result := append(lines[:start-1], append(newLines, lines[end:]...)...)
|
|
if err := os.WriteFile(a.Path, []byte(strings.Join(result, "\n")), 0644); err != nil {
|
|
return "", fmt.Errorf("file_write: %w", err)
|
|
}
|
|
return fmt.Sprintf("replaced lines %d-%d in %s", start, end, a.Path), nil
|
|
}
|
|
|
|
func squashWhitespace(s string) string {
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|