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
10 changes: 5 additions & 5 deletions cmd/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,10 +132,7 @@ $ mcpshell agent --tools=examples/config.yaml \

If a model is configured as default in the agent configuration file, you can omit the --model flag:

$ mcpshell agent --tools=examples/config.yaml \
--user-prompt "I am having trouble with my computer. It is slow and I think it is due to the CPU usage."

You can also provide the initial user prompt as positional arguments:
You can provide initial user prompt as positional arguments:

$ mcpshell agent I am having trouble with my computer. It is slow and I think it is due to the CPU usage.

Expand Down Expand Up @@ -250,7 +247,10 @@ The agent will try to debug the issue with the given tools.
go func() {
defer wg.Done()
if err := agentInstance.Run(ctx, userInput, agentOutput); err != nil {
logger.Error(color.HiRedString("Agent encountered an error: %v", err))
// Don't log context cancellation as an error - it's an expected exit condition
if err != context.Canceled && err != context.DeadlineExceeded {
logger.Error(color.HiRedString("Agent encountered an error: %v", err))
}
// Cancel context to abort all goroutines on fatal errors
cancel()
}
Expand Down
2 changes: 1 addition & 1 deletion cmd/exe.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ will be reported.

// Get the tool name
toolName := args[0]
logger.Info("Executing tool: %s", toolName)
logger.Debug("Executing tool: %s", toolName)

// Load the configuration file(s) (local or remote)
localConfigPath, cleanup, err := config.ResolveMultipleConfigPaths(toolsFiles, logger)
Expand Down
139 changes: 40 additions & 99 deletions docs/usage-agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The MCPShell can be run in "agent mode" to establish a direct connection between

In agent mode, MCPShell:

1. Connects directly to an LLM API (currently OpenAI-compatible APIs)
1. Connects directly to an LLM API
1. Makes your tools available to the LLM
1. Manages the conversation flow.
1. Handles tool execution requests
Expand Down Expand Up @@ -51,121 +51,66 @@ MCPShell has agent-specific configuration that includes model definitions with p

**See the [Agent Configuration Guide](usage-agent-conf.md)**

## Creating an Agent Script

You can create a shell script to run MCPShell in agent mode with a specific configuration. This is useful for creating specialized agents for different tasks.

Here's a practical example showing both agent configuration and tools configuration for a disk space analyzer agent:

**Tools Configuration** (`disk-analyzer.yaml`):

```yaml
description: "Tools for analyzing disk space usage and system performance"
run:
shell: "bash"
tools:
- name: "disk_usage"
description: "Check disk usage for a directory"
params:
directory:
type: string
description: "Directory to analyze"
required: true
max_depth:
type: number
description: "Maximum depth to analyze (1-3)"
default: 2
constraints:
- "directory.startsWith('/')" # Must be absolute path
- "!directory.contains('..')" # Prevent directory traversal
- "max_depth >= 1 && max_depth <= 3" # Limit recursion depth
run:
command: |
du -h --max-depth={{ .max_depth }} {{ .directory }} | sort -hr | head -20
output:
format: "text"
prefix: "Disk Usage Analysis (Top 20 largest directories):"

- name: "filesystem_info"
description: "Show filesystem usage information"
run:
command: |
df -h
output:
format: "text"
prefix: "Filesystem Usage Information:"

- name: "large_files"
description: "Find large files in a directory"
params:
directory:
type: string
description: "Directory to search in"
required: true
min_size:
type: string
description: "Minimum file size (e.g., '100M', '1G')"
default: "100M"
file_type:
type: string
description: "Filter by file extension (e.g., 'log', 'zip')"
required: false
constraints:
- "directory.startsWith('/')" # Must be absolute path
- "!directory.contains('..')" # Prevent directory traversal
run:
command: |
file_type="{{ .file_type }}"
if [ -n "$file_type" ]; then
find {{ .directory }} -type f -name "*.${file_type}" -size +{{ .min_size }} -exec ls -lh {} \; | sort -k5hr | head -20
else
find {{ .directory }} -type f -size +{{ .min_size }} -exec ls -lh {} \; | sort -k5hr | head -20
fi
output:
format: "text"
prefix: "Large Files (minimum size {{ .min_size }}):"
```

## Running the Agent

With the tools configuration saved as `disk-analyzer.yaml`, you can run:
With the tools defined in `disk-diagnostics-ro.yaml`, you can run:

```bash
mcpshell agent \
--tools disk-analyzer.yaml \
--user-prompt "My root partition is running low on space. Can you help me find what's taking up space and how I might free some up?"
--tools disk-diagnostics-ro.yaml \
"My root partition is running low on space. Can you help me find what's taking up space and how I might free some up?"
```

If you have a default model configured in your agent config file,
you don't need to specify the model or API key:
The agent will:

```bash
mcpshell agent --tools disk-analyzer.yaml
- Load model and API settings from `~/.mcpshell/agent.yaml` (see [Agent Configuration Guide](usage-agent-conf.md)). It will look like this:

```console
$ cat ~/.mcpshell/agent.yaml
agent:
models:
- model: "gpt-4o"
class: "openai"
name: "gpt-4o"
default: true
api-key: "your-openai-api-key"
api-url: "https://api.openai.com/v1"

- name: "claude-sonnet-4"
class: "amazon-bedrock"
model: "us.anthropic.claude-sonnet-4-5-20250929-v1:0"
api-url: "https://bedrock-runtime.us-east-2.amazonaws.com"

- model: "gemma3n"
class: "ollama"
name: "gemma3n"

- model: "llama3.1:8b"
class: "ollama"
name: "llama3"
```

The agent will:

1. Load model and API settings from `~/.mcpshell/agent.yaml` (see [Agent Configuration Guide](usage-agent-conf.md))
1. Load system prompts from the agent configuration
1. Load tools from `disk-analyzer.yaml`
1. Connect to the configured LLM API
1. Process the LLM's responses and execute tool calls as requested
- Load system prompts from the agent configuration (if any, or use the default ones).
- Load tools from `disk-diagnostics-ro.yaml`.
- Connect to the configured LLM API.
- Process the LLM's responses and execute tool calls as requested.

## Interacting with the Agent

In interactive mode (without the `--once` flag), the agent will:

- Display the LLM's responses
- Execute tool calls as requested by the LLM
- Prompt you for additional input
- Continue the conversation until you exit (Ctrl+C) or the LLM responds with "TERMINATE"
- Wait for you to provide additional input after the LLM completes its response
- Continue the conversation with the full conversation context preserved
- Loop until you exit (Ctrl+C)

In one-shot mode (with the `--once` flag), the agent will:

- Process the initial prompt
- Execute any requested tools
- Display the final response
- Exit automatically
- Exit automatically after the LLM completes

## Testing and Debugging

Expand All @@ -178,9 +123,5 @@ When developing agents, you can:
1. Test with the `exe` command to verify individual tools:

```bash
mcpshell exe --tools disk-analyzer.yaml disk_usage directory="/" max_depth=2
```

## Conclusion

The agent mode provides a powerful way to create specialized AI assistants that can perform specific tasks on your system using the tools you define. By combining well-defined tools with appropriate system and user prompts, you can create agents that solve real-world problems in a secure and controlled manner.
mcpshell exe --tools disk-diagnostics-ro.yaml disk_usage directory="/" max_depth=2
```
88 changes: 59 additions & 29 deletions pkg/agent/agent.go
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ func (a *Agent) Run(ctx context.Context, userInput chan string, agentOutput chan
defer singleRunCancel()
ctx = singleRunCtx
a.logger.Info("Running in one-shot mode with 120s safety timeout")
} else {
a.logger.Info("Running in interactive mode (will wait for user input to continue)")
}

// Create cagent runtime with multi-agent system
Expand All @@ -112,37 +114,65 @@ func (a *Agent) Run(ctx context.Context, userInput chan string, agentOutput chan
return fmt.Errorf("failed to create cagent runtime: %w", err)
}

// Start streaming events from cagent
a.logger.Info("Starting cagent event stream")
events := cagentRT.RunStream(ctx)

// Process events and send output
eventCount := 0
for event := range events {
eventCount++
a.logger.Debug("Received event #%d: %T", eventCount, event)

// Handle tool call confirmations - auto-approve tools
if _, ok := event.(*runtime.ToolCallConfirmationEvent); ok {
a.logger.Debug("Auto-approving tool execution")
cagentRT.Runtime().Resume(ctx, "approve-session")
// Conversation loop - run until Once mode or context cancellation
for {
// Start streaming events from cagent
a.logger.Debug("Starting cagent event stream")
events := cagentRT.RunStream(ctx)

// Process events and send output
eventCount := 0
for event := range events {
eventCount++
a.logger.Debug("Received event #%d: %T", eventCount, event)

// Handle tool call confirmations - auto-approve tools
if _, ok := event.(*runtime.ToolCallConfirmationEvent); ok {
a.logger.Debug("Auto-approving tool execution")
cagentRT.Runtime().Resume(ctx, "approve-session")
}

if err := a.handleCagentEvent(event, agentOutput); err != nil {
a.logger.Error("Error handling event: %v", err)
// Continue processing other events
}
}
a.logger.Debug("Event stream completed, processed %d events", eventCount)

if err := a.handleCagentEvent(event, agentOutput); err != nil {
a.logger.Error("Error handling event: %v", err)
// Continue processing other events
// In one-shot mode, exit after first response
if a.config.Once {
a.logger.Info("One-shot mode: exiting after first response")
return nil
}
}
a.logger.Info("Event stream completed, processed %d events", eventCount)

a.logger.Info("Cagent runtime completed")

// In one-shot mode, close the userInput channel
if a.config.Once {
close(userInput)
// In interactive mode, wait for user input to continue
a.logger.Debug("Waiting for user input to continue conversation...")
promptColor := color.New(color.Bold, color.FgHiCyan)
agentOutput <- fmt.Sprintf("\n%s", promptColor.Sprint("💬 Enter your next question (or Ctrl+C to exit): "))

select {
case <-ctx.Done():
a.logger.Info("Context cancelled, exiting")
return ctx.Err()
case nextInput, ok := <-userInput:
if !ok {
a.logger.Info("User input channel closed, exiting")
return nil
}
if nextInput == "" {
continue // Skip empty input
}

// Add the new user message to the session to continue the conversation
a.logger.Debug("Received user input: %s", nextInput)
if err := cagentRT.ContinueConversation(nextInput); err != nil {
a.logger.Error("Failed to continue conversation: %v", err)
agentOutput <- fmt.Sprintf("Error: %v\n", err)
return fmt.Errorf("failed to continue conversation: %w", err)
}
// Loop will continue with the updated session
}
}

return nil
}

// handleCagentEvent processes a single cagent event and sends appropriate output
Expand Down Expand Up @@ -184,6 +214,8 @@ func (a *Agent) handleCagentEvent(event interface{}, agentOutput chan string) er

case *runtime.ToolCallConfirmationEvent:
// Tool is being confirmed/executed
// Add newline before logs to separate from agent output
agentOutput <- "\n"
a.logger.Debug("Tool call confirmed for agent: %s", e.AgentName)

case *runtime.ToolCallResponseEvent:
Expand All @@ -192,9 +224,7 @@ func (a *Agent) handleCagentEvent(event interface{}, agentOutput chan string) er
if len(response) > 1000 {
response = response[:1000] + "... (truncated)"
}
agentOutput <- fmt.Sprintf("%s\n%s\n",
blue.Sprint("✓ Tool result:"),
response)
agentOutput <- fmt.Sprintf("%s\n%s\n", blue.Sprint("✓ Tool result:"), response)

case *runtime.StreamStartedEvent:
// Agent started processing - use magenta for agent status
Expand Down
Loading
Loading