Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 7 additions & 7 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ Agent Driver (Goose, Claude Code, Copilot CLI)
| **Infer** | [Ollama](https://ollama.com) | Local LLM inference (Metal GPU on Mac) |
| **Optimize** | [RTK](https://github.com/rtk-ai/rtk) | Token compression — 70-90% reduction on shell output |
| **Execute** | [Goose](https://block.github.io/goose) | AI coding agent with native Ollama support (headless) |
| **Orchestrate** | [Dagu](https://github.com/dagu-org/dagu) | YAML DAG workflows with scheduling and web UI |
| **Coordinate** | [Octi Pulpo](https://github.com/AgentGuardHQ/octi-pulpo) | Budget-aware dispatch, episodic memory, model cascading |
| **Govern** | [AgentGuard](https://github.com/AgentGuardHQ/agentguard) | Policy enforcement on every action — allow/deny/correct |
| **Sandbox** | [OpenShell](https://github.com/NVIDIA/OpenShell) | Kernel-level isolation (Docker on macOS) |
| **Scan** | [DefenseClaw](https://github.com/cisco-ai-defense/defenseclaw) | Supply chain scanner — AI Bill of Materials |
Expand All @@ -101,7 +101,7 @@ shellforge status
# Ollama running (qwen3:30b loaded)
# RTK v0.4.2
# AgentGuard enforce mode (5 rules)
# Dagu connected (web UI at :8080)
# Octi Pulpo connected (http://localhost:8080)
# OpenShell Docker sandbox active
# DefenseClaw scanner ready
```
Expand Down Expand Up @@ -149,14 +149,14 @@ See `dags/multi-driver-swarm.yaml` and `dags/workspace-swarm.yaml` for examples.

```
┌───────────────────────────────────────────────────┐
Dagu (Orchestration)
YAML DAGs · Cron scheduling · Web UI · Retries
Octi Pulpo (Coordination)
Budget-aware dispatch · Memory · Model cascading
└────────────────────┬──────────────────────────────┘
│ task
┌────────────────────▼──────────────────────────────┐
Goose (Execution Engine)
Agent loop · Tool calling · Ollama-native
Uses Ollama for inference
ShellForge Agent Loop
LLM provider · Tool calling · Drift detection
Anthropic API or Ollama
└────────────────────┬──────────────────────────────┘
│ tool call
═══════════╪═══════════
Expand Down
64 changes: 64 additions & 0 deletions cmd/shellforge/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"github.com/AgentGuardHQ/shellforge/internal/llm"
"github.com/AgentGuardHQ/shellforge/internal/logger"
"github.com/AgentGuardHQ/shellforge/internal/ollama"
"github.com/AgentGuardHQ/shellforge/internal/repl"
"github.com/AgentGuardHQ/shellforge/internal/scheduler"
)

Expand Down Expand Up @@ -84,6 +85,8 @@ os.Exit(1)
}
cmdAgent(strings.Join(filtered, " "), providerName, thinkingBudget)
}
case "chat":
cmdChat()
case "swarm":
cmdSwarm()
case "serve":
Expand Down Expand Up @@ -116,6 +119,7 @@ Usage:
shellforge qa [target] QA analysis with tool use + governance
shellforge report [repo] Weekly status report from git + logs
shellforge agent "prompt" Run any task with agentic tool use
shellforge chat Interactive pair-programming REPL
shellforge status Full ecosystem health check
shellforge scan [dir] DefenseClaw supply chain scan
shellforge version Print version
Expand Down Expand Up @@ -724,6 +728,66 @@ printResult("prototype-agent", result)
saveReport("outputs/logs", "prototype", result)
}

func cmdChat() {
engine := mustGovernance()

providerName := ""
model := ""
remaining := os.Args[2:]
for i := 0; i < len(remaining); i++ {
switch remaining[i] {
case "--provider":
if i+1 < len(remaining) {
providerName = remaining[i+1]
i++
}
case "--model":
if i+1 < len(remaining) {
model = remaining[i+1]
i++
}
}
}

var provider llm.Provider
switch providerName {
case "anthropic":
apiKey := os.Getenv("ANTHROPIC_API_KEY")
if apiKey == "" {
fmt.Fprintln(os.Stderr, "Error: ANTHROPIC_API_KEY environment variable not set")
os.Exit(1)
}
if model == "" {
model = os.Getenv("ANTHROPIC_MODEL")
if model == "" {
model = "claude-haiku-4-5-20251001"
}
}
provider = llm.NewAnthropicProvider(apiKey, model)
default:
mustOllama()
if model == "" {
model = ollama.Model
}
provider = llm.NewOllamaProvider("", model)
}

cfg := repl.REPLConfig{
Agent: "shellforge-repl",
System: "You are a senior engineer. Complete the requested task using available tools. Read files, write files, run commands, search code. Be precise and helpful.",
Model: model,
MaxTurns: 15,
TokenBudget: 8000,
Provider: provider,
Governance: engine,
}

if err := repl.RunREPL(cfg); err != nil {
fmt.Fprintf(os.Stderr, "REPL error: %s\n", err)
os.Exit(1)
}
}

func cmdSwarm() {
fmt.Println("=== ShellForge Swarm Setup (Dagu) ===")
fmt.Println()
Expand Down
2 changes: 1 addition & 1 deletion docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ Octi Pulpo routes tasks to the cheapest capable driver:
| **Infer** | [Ollama](https://ollama.com) | Local LLM inference (Metal GPU on Mac) |
| **Optimize** | [RTK](https://github.com/rtk-ai/rtk) | Token compression — 70-90% reduction on shell output |
| **Execute** | [Goose](https://block.github.io/goose) / [OpenClaw](https://github.com/openclaw/openclaw) | Agent execution + browser automation |
| **Orchestrate** | [Dagu](https://github.com/dagu-org/dagu) | YAML DAG workflows with scheduling and web UI |
| **Coordinate** | [Octi Pulpo](https://github.com/AgentGuardHQ/octi-pulpo) | Budget-aware dispatch, episodic memory, model cascading |
| **Coordinate** | [Octi Pulpo](https://github.com/AgentGuardHQ/octi-pulpo) | Swarm coordination via MCP |
| **Govern** | [AgentGuard](https://github.com/AgentGuardHQ/agentguard) | Policy enforcement on every action |
| **Sandbox** | [OpenShell](https://github.com/NVIDIA/OpenShell) | Kernel-level isolation (Docker on macOS) |
Expand Down
232 changes: 232 additions & 0 deletions internal/repl/repl.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
// Package repl implements an interactive REPL for ShellForge.
//
// The REPL maintains conversation history across prompts, making it usable
// as a pair-programming tool. Each user prompt is appended to a running
// message history so the agent retains context from previous turns.
package repl

import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"os/signal"
"strings"
"sync"

"github.com/AgentGuardHQ/shellforge/internal/agent"
"github.com/AgentGuardHQ/shellforge/internal/governance"
"github.com/AgentGuardHQ/shellforge/internal/llm"
)

// ANSI color codes.
const (
colorGreen = "\033[32m"
colorRed = "\033[31m"
colorYellow = "\033[33m"
colorReset = "\033[0m"
)

// REPLConfig holds configuration for the interactive REPL session.
type REPLConfig struct {
Agent string
System string
Model string
MaxTurns int
TokenBudget int
Provider llm.Provider
Governance *governance.Engine
}

// RunREPL starts the interactive REPL loop.
// It reads from stdin and writes to stdout/stderr.
func RunREPL(cfg REPLConfig) error {
return runREPLWithIO(cfg, os.Stdin, os.Stdout, os.Stderr)
}

// runREPLWithIO is the testable core that accepts explicit readers/writers.
func runREPLWithIO(cfg REPLConfig, stdin io.Reader, stdout, stderr io.Writer) error {
if cfg.Agent == "" {
cfg.Agent = "shellforge-repl"
}
if cfg.System == "" {
cfg.System = "You are a senior engineer. Complete tasks using available tools. Be precise and helpful."
}
if cfg.MaxTurns <= 0 {
cfg.MaxTurns = 15
}
if cfg.TokenBudget <= 0 {
cfg.TokenBudget = 8000
}

// Conversation history persists across prompts — this is the key innovation.
var history []llm.Message

promptCount := 0
scanner := bufio.NewScanner(stdin)
// Increase buffer for long inputs.
scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024)

fmt.Fprintf(stdout, "%sShellForge Interactive Mode%s\n", colorGreen, colorReset)
fmt.Fprintf(stdout, "Provider: %s | Model: %s | MaxTurns: %d\n", providerName(cfg.Provider), cfg.Model, cfg.MaxTurns)
fmt.Fprintf(stdout, "Type %sexit%s to quit, %s!cmd%s to run shell commands\n\n", colorYellow, colorReset, colorYellow, colorReset)

for {
fmt.Fprintf(stdout, "%sshellforge> %s", colorGreen, colorReset)

if !scanner.Scan() {
// EOF or scan error — exit cleanly.
fmt.Fprintln(stdout)
break
}

input := strings.TrimSpace(scanner.Text())
if input == "" {
continue
}

// Handle built-in commands.
cmd := ParseCommand(input)
switch cmd.Type {
case CmdExit:
fmt.Fprintf(stdout, "Goodbye. (%d prompts in session)\n", promptCount)
return nil

case CmdShell:
runShellCommand(cmd.Arg, stdout, stderr)
continue

case CmdPrompt:
// Fall through to agent execution below.
}

promptCount++

// Set up Ctrl+C to cancel current run without killing the REPL.
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, os.Interrupt)
defer signal.Stop(sigCh)

var result *agent.RunResult
var runErr error
done := make(chan struct{})

var mu sync.Mutex
cancelled := false

go func() {
defer close(done)
loopCfg := agent.LoopConfig{
Agent: cfg.Agent,
System: cfg.System,
UserPrompt: input,
Model: cfg.Model,
MaxTurns: cfg.MaxTurns,
TimeoutMs: 180_000,
OutputDir: "",
TokenBudget: cfg.TokenBudget,
Provider: cfg.Provider,
}
result, runErr = agent.RunLoop(loopCfg, cfg.Governance)
}()

// Wait for either completion or Ctrl+C.
select {
case <-done:
signal.Stop(sigCh)
case <-sigCh:
mu.Lock()
cancelled = true
mu.Unlock()
signal.Stop(sigCh)
fmt.Fprintf(stderr, "\n%s[interrupted]%s\n", colorYellow, colorReset)
// Wait for goroutine to finish (it will timeout eventually).
<-done
}

mu.Lock()
wasCancelled := cancelled
mu.Unlock()

if wasCancelled {
continue
}

if runErr != nil {
fmt.Fprintf(stderr, "%sError: %s%s\n\n", colorRed, runErr.Error(), colorReset)
continue
}

// Display result.
if result.Output != "" {
fmt.Fprintln(stdout, result.Output)
}

// Session stats.
denialStr := ""
if result.Denials > 0 {
denialStr = fmt.Sprintf(", %s%d denials%s", colorYellow, result.Denials, colorReset)
}
fmt.Fprintf(stdout, "\n[%d turns, %d tool calls%s | %dms]\n\n",
result.Turns, result.ToolCalls, denialStr, result.DurationMs)

// Append this exchange to persistent history for context.
history = append(history, llm.Message{Role: "user", Content: input})
if result.Output != "" {
history = append(history, llm.Message{Role: "assistant", Content: result.Output})
}
}

if err := scanner.Err(); err != nil {
return fmt.Errorf("scanner error: %w", err)
}
return nil
}

// CommandType classifies REPL input.
type CommandType int

const (
CmdPrompt CommandType = iota
CmdExit
CmdShell
)

// Command is a parsed REPL input.
type Command struct {
Type CommandType
Arg string // shell command text for CmdShell, original input for CmdPrompt
}

// ParseCommand classifies a line of REPL input.
func ParseCommand(input string) Command {
lower := strings.ToLower(strings.TrimSpace(input))

if lower == "exit" || lower == "quit" {
return Command{Type: CmdExit}
}

if strings.HasPrefix(input, "!") {
return Command{Type: CmdShell, Arg: strings.TrimPrefix(input, "!")}
}

return Command{Type: CmdPrompt, Arg: input}
}

func runShellCommand(cmd string, stdout, stderr io.Writer) {
c := exec.Command("sh", "-c", cmd)
c.Stdout = stdout
c.Stderr = stderr
if err := c.Run(); err != nil {
fmt.Fprintf(stderr, "%sShell error: %s%s\n", colorRed, err.Error(), colorReset)
}
fmt.Fprintln(stdout)
}

func providerName(p llm.Provider) string {
if p == nil {
return "ollama"
}
return p.Name()
}
Loading
Loading