Skip to content
Merged
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
80 changes: 75 additions & 5 deletions cmd/axis/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"strings"
"time"

"github.com/chzyer/readline"
"github.com/spf13/cobra"
"github.com/toasterbook88/axis/internal/agent"
"github.com/toasterbook88/axis/internal/api"
Expand Down Expand Up @@ -37,6 +39,7 @@ func agentCmd() *cobra.Command {
maxTurns int
autoApprove bool
systemMsg string
resume bool
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -93,6 +96,18 @@ func agentCmd() *cobra.Command {

a := agent.New(cfg)

// Resume previous conversation if requested.
historyPath, err := chat.PersistPath("agent")
if err != nil {
fmt.Fprintf(errW, "warning: cannot determine history path: %v\n", err)
} else if resume {
if err := a.Conversation().LoadFromFile(historyPath); err != nil {
fmt.Fprintf(errW, "warning: could not resume conversation: %v\n", err)
} else if n := a.Conversation().HistoryCount(); n > 0 {
fmt.Fprintf(errW, "Resumed %d messages from previous session.\n", n)
}
}

// Single-shot mode.
if len(args) > 0 {
instruction := strings.Join(args, " ")
Expand All @@ -105,19 +120,35 @@ func agentCmd() *cobra.Command {
return ExitCodeError{Code: ExitErrCommandFail, Message: fmt.Sprintf("agent failed: %v", err)}
}
fmt.Fprintln(w)
if historyPath != "" {
_ = a.Conversation().SaveToFile(historyPath)
}
return nil
}

// Interactive REPL.
// Interactive REPL with readline.
fmt.Fprintf(errW, "AXIS Agent [%s] — max %d turns per query, type exit to quit\n\n", ui.Bold(currentModel), maxTurns)

scanner := bufio.NewScanner(os.Stdin)
rlCfg := &readline.Config{
Prompt: ui.Cyan("agent> "),
InterruptPrompt: "^C",
EOFPrompt: "exit",
}
if historyPath != "" {
rlCfg.HistoryFile = historyPath + ".line"
}
rl, err := readline.NewEx(rlCfg)
if err != nil {
return runPlainAgentREPL(a, w, errW, timeout, historyPath)
}
defer rl.Close()

for {
fmt.Fprint(errW, ui.Cyan("agent> "))
if !scanner.Scan() {
line, err := rl.Readline()
if err != nil {
break
}
instruction := strings.TrimSpace(scanner.Text())
instruction := strings.TrimSpace(line)
if instruction == "" {
continue
}
Expand All @@ -133,6 +164,14 @@ func agentCmd() *cobra.Command {
cancel()
fmt.Fprintln(w)
}

if historyPath != "" && a.Conversation().HistoryCount() > 0 {
if err := a.Conversation().SaveToFile(historyPath); err != nil {
fmt.Fprintf(errW, "warning: could not save conversation: %v\n", err)
} else {
fmt.Fprintf(errW, "Saved %d messages to conversation history.\n", a.Conversation().HistoryCount())
}
}
return nil
},
}
Expand All @@ -143,9 +182,40 @@ func agentCmd() *cobra.Command {
cmd.Flags().IntVar(&maxTurns, "max-turns", 10, "Maximum agent loop iterations per query")
cmd.Flags().BoolVar(&autoApprove, "auto-approve", false, "Auto-approve safe commands (safety score < 70)")
cmd.Flags().StringVar(&systemMsg, "system", "", "Extra text appended to system prompt")
cmd.Flags().BoolVar(&resume, "resume", false, "Resume previous conversation from history")
return cmd
}

// runPlainAgentREPL is the fallback scanner-based REPL when readline is unavailable.
func runPlainAgentREPL(a *agent.Agent, w, errW io.Writer, timeout time.Duration, historyPath string) error {
fmt.Fprintln(errW, ui.Yellow("Note: using plain input mode (no arrow keys or history)"))
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Fprint(errW, ui.Cyan("agent\u003e "))
if !scanner.Scan() {
break
}
instruction := strings.TrimSpace(scanner.Text())
if instruction == "" {
continue
}
lower := strings.ToLower(instruction)
if lower == "exit" || lower == "quit" {
break
}
ctx, cancel := agentRequestContext(timeout)
if err := a.Run(ctx, instruction); err != nil {
fmt.Fprintf(errW, "\n%s %v\n", ui.Red("Error:"), err)
}
cancel()
fmt.Fprintln(w)
}
if historyPath != "" && a.Conversation().HistoryCount() > 0 {
_ = a.Conversation().SaveToFile(historyPath)
}
return nil
}

func agentRequestContext(timeout time.Duration) (context.Context, context.CancelFunc) {
if timeout <= 0 {
return context.WithCancel(context.Background())
Expand Down
97 changes: 92 additions & 5 deletions cmd/axis/chat.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"strings"
"time"

"github.com/chzyer/readline"
"github.com/spf13/cobra"
"github.com/toasterbook88/axis/internal/chat"
"github.com/toasterbook88/axis/internal/config"
Expand All @@ -32,6 +33,7 @@ func chatCmd() *cobra.Command {
useContext bool
systemMsg string
format string
resume bool
)

cmd := &cobra.Command{
Expand Down Expand Up @@ -68,6 +70,18 @@ func chatCmd() *cobra.Command {
sysPrompt := chat.BuildSystemPrompt(cluster, systemMsg)
conv.Append(chat.Message{Role: chat.RoleSystem, Content: sysPrompt})

// Resume previous conversation if requested.
historyPath, err := chat.PersistPath("chat")
if err != nil {
fmt.Fprintf(errW, "warning: cannot determine history path: %v\n", err)
} else if resume {
if err := conv.LoadFromFile(historyPath); err != nil {
fmt.Fprintf(errW, "warning: could not resume conversation: %v\n", err)
} else if n := conv.HistoryCount(); n > 0 {
fmt.Fprintf(errW, "Resumed %d messages from previous session.\n", n)
}
}

fmt.Fprintln(errW, ui.Dim("advisory: chat output is not cluster truth — validate with axis status or axis facts"))

// Single-shot mode.
Expand Down Expand Up @@ -100,19 +114,37 @@ func chatCmd() *cobra.Command {
} else {
fmt.Fprintln(w)
}
// Save conversation after single-shot.
if historyPath != "" {
_ = conv.SaveToFile(historyPath)
}
return nil
}

// Interactive REPL.
// Interactive REPL with readline.
fmt.Fprintf(errW, "AXIS Chat [%s] — type /help for commands, exit to quit\n\n", ui.Bold(currentModel))
scanner := bufio.NewScanner(os.Stdin)

cfg := &readline.Config{
Prompt: ui.Cyan(">>> "),
InterruptPrompt: "^C",
EOFPrompt: "exit",
}
if historyPath != "" {
cfg.HistoryFile = historyPath + ".line"
}
rl, err := readline.NewEx(cfg)
if err != nil {
// Fallback to plain scanner if readline fails (e.g., non-TTY).
return runPlainREPL(cmd.Context(), client, conv, currentModel, w, errW, timeout, historyPath)
}
defer rl.Close()

for {
fmt.Fprint(errW, ui.Cyan(">>> "))
if !scanner.Scan() {
line, err := rl.Readline()
if err != nil { // io.EOF or readline.ErrInterrupt
break
}
query := strings.TrimSpace(scanner.Text())
query := strings.TrimSpace(line)
if query == "" {
continue
}
Expand Down Expand Up @@ -148,6 +180,15 @@ func chatCmd() *cobra.Command {
conv.Append(resp)
fmt.Fprintln(w)
}

// Save conversation on exit.
if historyPath != "" && conv.HistoryCount() > 0 {
if err := conv.SaveToFile(historyPath); err != nil {
fmt.Fprintf(errW, "warning: could not save conversation: %v\n", err)
} else {
fmt.Fprintf(errW, "Saved %d messages to conversation history.\n", conv.HistoryCount())
}
}
return nil
},
}
Expand All @@ -158,9 +199,55 @@ func chatCmd() *cobra.Command {
cmd.Flags().BoolVar(&useContext, "context", false, "Inject live cluster snapshot into system prompt")
cmd.Flags().StringVar(&systemMsg, "system", "", "Extra text appended to system prompt")
cmd.Flags().StringVar(&format, "format", "text", "Output format for single-shot mode (text, json)")
cmd.Flags().BoolVar(&resume, "resume", false, "Resume previous conversation from history")
return cmd
}

// runPlainREPL is the fallback scanner-based REPL when readline is unavailable.
func runPlainREPL(ctx context.Context, client *chat.Client, conv *chat.Conversation, currentModel string, w, errW io.Writer, timeout time.Duration, historyPath string) error {
fmt.Fprintln(errW, ui.Yellow("Note: using plain input mode (no arrow keys or history)"))
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Fprint(errW, ui.Cyan(">>> "))
if !scanner.Scan() {
break
}
query := strings.TrimSpace(scanner.Text())
if query == "" {
continue
}
lower := strings.ToLower(query)
if lower == "exit" || lower == "quit" {
break
}
if strings.HasPrefix(query, "/") {
nextModel := handleSlashCommand(query, currentModel, conv, errW)
if nextModel != "" {
currentModel = nextModel
client = chat.NewClient(chat.DefaultEndpoint, currentModel)
}
continue
}
conv.Append(chat.Message{Role: chat.RoleUser, Content: query})
sp := ui.NewSpinner()
sp.Start("Thinking...")
ctx2, cancel := chatRequestContext(timeout)
resp, err := client.ChatStream(ctx2, conv.Messages(), nil, w)
sp.Stop("")
cancel()
if err != nil {
fmt.Fprintf(errW, "\n%s\n", ui.Red("Error: ", err))
continue
}
conv.Append(resp)
fmt.Fprintln(w)
}
if historyPath != "" && conv.HistoryCount() > 0 {
_ = conv.SaveToFile(historyPath)
}
return nil
}

// handleSlashCommand processes a slash command and returns a new model name
// if the model was switched, or empty string otherwise.
func handleSlashCommand(input, currentModel string, conv *chat.Conversation, w io.Writer) string {
Expand Down
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ require (
)

require (
github.com/chzyer/readline v1.5.1 // indirect
github.com/google/jsonschema-go v0.4.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
Expand Down
5 changes: 5 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
al.essio.dev/pkg/shellescape v1.6.0 h1:NxFcEqzFSEVCGN2yq7Huv/9hyCEGVa/TncnOOBBeXHA=
al.essio.dev/pkg/shellescape v1.6.0/go.mod h1:6sIqp7X2P6mThCQ7twERpZTuigpr6KbZWtls1U8I890=
github.com/chzyer/logex v1.2.1/go.mod h1:JLbx6lG2kDbNRFnfkgvh4eRJRPX1QCoOIWomwysCBrQ=
github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI=
github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk=
github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
Expand Down Expand Up @@ -51,6 +55,7 @@ golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
Expand Down
53 changes: 50 additions & 3 deletions internal/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,19 +144,22 @@ func (a *Agent) Run(ctx context.Context, userPrompt string) error {

// Process each tool call.
for _, tc := range resp.ToolCalls {
fmt.Fprintf(a.output, "\n▶ Calling %s...\n", tc.Function.Name)
result, err := a.dispatchToolCall(ctx, tc)
if err != nil {
// Feed the error back to the model for self-correction.
errMsg := fmt.Sprintf("Error executing tool %q: %s", tc.Function.Name, err.Error())
fmt.Fprintf(a.output, "\n⚠ %s\n", errMsg)
fmt.Fprintf(a.output, "⚠ %s\n", errMsg)
a.conv.Append(chat.Message{
Role: chat.RoleTool,
Content: errMsg,
})
continue
}

fmt.Fprintf(a.output, "\n✓ %s returned %d chars\n", tc.Function.Name, len(result))
// Print a compact summary line instead of raw char count.
summary := formatToolResultSummary(tc.Function.Name, result)
fmt.Fprintf(a.output, "✓ %s\n", summary)
a.conv.Append(chat.Message{
Role: chat.RoleTool,
Content: result,
Expand All @@ -168,6 +171,51 @@ func (a *Agent) Run(ctx context.Context, userPrompt string) error {
return nil
}

// formatToolResultSummary produces a human-readable one-line summary of a
// tool result for operator feedback.
func formatToolResultSummary(toolName, result string) string {
switch toolName {
case "axis_status":
// Extract first line (cluster summary).
if i := strings.Index(result, "\n"); i > 0 {
return toolName + ": " + strings.TrimSpace(result[:i])
}
case "axis_summary":
return toolName + ": " + strings.TrimSpace(result)
case "axis_facts":
if i := strings.Index(result, "\n"); i > 0 {
return toolName + ": " + strings.TrimSpace(result[:i])
}
case "axis_place":
return toolName + ": " + strings.TrimSpace(result)
case "axis_reservations":
if strings.Contains(result, "Active reservations") {
lines := strings.Split(result, "\n")
if len(lines) >= 2 {
count := 0
for _, l := range lines[1:] {
if strings.HasPrefix(l, "-") {
count++
}
}
return fmt.Sprintf("%s: found %d nodes with active reservations", toolName, count)
}
}
return toolName + ": no active reservations"
case "read_file":
lines := strings.Count(result, "\n")
return fmt.Sprintf("%s: read %d lines (%d chars)", toolName, lines, len(result))
case "list_directory":
if i := strings.Index(result, "("); i > 0 && strings.Contains(result, " entries)") {
return toolName + ": " + strings.TrimSpace(result[strings.Index(result, "Directory:")+len("Directory:"):])
}
return toolName + ": listed directory"
case "run_shell":
return toolName + ": executed shell command"
}
return fmt.Sprintf("%s returned %d chars", toolName, len(result))
}

// dispatchToolCall handles a single tool call with safety gating and confirmation.
func (a *Agent) dispatchToolCall(ctx context.Context, tc chat.ToolCall) (string, error) {
name := tc.Function.Name
Expand All @@ -189,7 +237,6 @@ func (a *Agent) dispatchToolCall(ctx context.Context, tc chat.ToolCall) (string,
}

// 4. Read-only tools execute directly (no confirmation needed).
fmt.Fprintf(a.output, "\n▶ Executing: %s\n", name)
return a.tools.Execute(ctx, name, args)
}

Expand Down
Loading