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
84 changes: 84 additions & 0 deletions .agents/skills/bump-go-dependencies/SKILL.md
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since this is not reusable anywhere outside of this repo, should this even be a skill or rather something our agent just knows how to do? 🤔
we could probably compact the prompt quite a bit, include it in the system prompt and get the same results.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, we could but I want to play a bit more with it.

  • I think on Claude we can call tools from skills to make them resolve in a single round-trip. I'd like to play with that
  • We don't all use the same agent but we could be using the same set of skills

Also, by activating this as a skill, I realised the things we were missing (/command support and schema)

Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
---
name: bump-go-dependencies
description: Update direct Go module dependencies one by one, validating each bump with tests and linter, committing individually, and producing a summary table for a PR description
---

# Bump Direct Go Dependencies

When asked to update or bump Go dependencies, follow this procedure.

## 1. List Outdated Direct Dependencies

Run the following to get a list of direct dependencies that have newer versions available:

```sh
go list -m -u -json all 2>/dev/null | jq -r 'select(.Indirect != true and .Update != null) | "\(.Path) \(.Version) \(.Update.Path) \(.Update.Version)"'
```

This produces lines of the form:

```
module/path current_version update_path new_version
```

If the command produces no output, all direct dependencies are already up to date. Inform the user and stop.

## 2. Update Each Dependency One by One

For **each** outdated dependency, perform the following steps in order:

### a. Upgrade

```sh
go get <module_path>@<new_version>
```

### b. Tidy

```sh
go mod tidy
```

### c. Validate

Run the linter and the tests:

```sh
task lint
task test
```

### d. Decide

- **If both pass**: stage and commit the changes:
```sh
git add -A
git commit -m "bump <module_path> from <old_version> to <new_version>" -m "" -m "Assisted-By: cagent"
```
Record the dependency as **bumped** in your tracking table.

- **If either fails**: revert all changes and move on:
```sh
git checkout -- .
Comment on lines +56 to +62
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm generally strongly against agents committing for me or generally manipulating git too much, but lets see if others feel the same.

If users don't use a good enough model, disasters are bound to happen 😅

```
Record the dependency as **skipped** in your tracking table, noting the reason (lint failure, test failure, or both).

## 3. Produce a Summary Table

After processing every dependency, output a **copy-pastable** Markdown table inside a fenced code block.
The table must list every dependency that was considered, with columns for the module path, old version, new version, and status.
Don't use emojis, just plain markdown.

Example:

~~~
```markdown
| Module | From | To | Status |
|--------|------|----|--------|
| github.com/example/foo | v1.2.0 | v1.3.0 | bumped |
| github.com/example/bar | v0.4.1 | v0.5.0 | skipped — test failure |
| golang.org/x/text | v0.21.0 | v0.22.0 | bumped |
```
~~~

This table is meant to be pasted directly into a pull-request description.
4 changes: 4 additions & 0 deletions cagent-schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,10 @@
"hooks": {
"$ref": "#/definitions/HooksConfig",
"description": "Lifecycle hooks for executing shell commands at various points in the agent's execution"
},
"skills": {
"type": "boolean",
"description": "Enable skills discovery for this agent. When enabled, the agent can discover and load skill files (SKILL.md) from the workspace."
}
},
"additionalProperties": false
Expand Down
11 changes: 3 additions & 8 deletions cmd/root/new.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ import (
"github.com/docker/cagent/pkg/creator"
"github.com/docker/cagent/pkg/runtime"
"github.com/docker/cagent/pkg/session"
"github.com/docker/cagent/pkg/sessiontitle"
"github.com/docker/cagent/pkg/telemetry"
"github.com/docker/cagent/pkg/tui"
tuiinput "github.com/docker/cagent/pkg/tui/input"
Expand Down Expand Up @@ -88,13 +87,9 @@ func (f *newFlags) runNewCommand(cmd *cobra.Command, args []string) error {
}

func runTUI(ctx context.Context, rt runtime.Runtime, sess *session.Session, opts ...app.Opt) error {
// For local runtime, create and pass a title generator.
if pr, ok := rt.(*runtime.PersistentRuntime); ok {
if a := pr.CurrentAgent(); a != nil {
if model := a.Model(); model != nil {
opts = append(opts, app.WithTitleGenerator(sessiontitle.New(model, a.FallbackModels()...)))
}
}
// If the runtime can provide a title generator, use it.
if gen := rt.TitleGenerator(); gen != nil {
opts = append(opts, app.WithTitleGenerator(gen))
}

a := app.New(ctx, rt, sess, opts...)
Expand Down
1 change: 1 addition & 0 deletions examples/gopher.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ agents:
root:
model: claude
description: Expert Golang Developer specialized in implementing features and improving code quality.
skills: true
instruction: |
**Goal:**
Help with Go code-related tasks by examining, modifying, and validating code changes.
Expand Down
129 changes: 58 additions & 71 deletions pkg/app/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,14 @@ import (
"context"
"fmt"
"log/slog"
"os"
"os/exec"
"slices"
"strings"
"sync/atomic"
"time"

tea "charm.land/bubbletea/v2"
"github.com/modelcontextprotocol/go-sdk/mcp"

"github.com/docker/cagent/pkg/app/export"
"github.com/docker/cagent/pkg/app/transcript"
Expand All @@ -21,6 +21,7 @@ import (
"github.com/docker/cagent/pkg/runtime"
"github.com/docker/cagent/pkg/session"
"github.com/docker/cagent/pkg/sessiontitle"
"github.com/docker/cagent/pkg/skills"
"github.com/docker/cagent/pkg/tools"
mcptools "github.com/docker/cagent/pkg/tools/mcp"
"github.com/docker/cagent/pkg/tui/messages"
Expand Down Expand Up @@ -144,6 +145,55 @@ func (a *App) CurrentAgentCommands(ctx context.Context) types.Commands {
return a.runtime.CurrentAgentInfo(ctx).Commands
}

// CurrentAgentSkills returns the available skills if skills are enabled for the current agent.
func (a *App) CurrentAgentSkills() []skills.Skill {
if a.runtime.CurrentAgentSkillsEnabled() {
return skills.Load()
}
return nil
}

// ResolveSkillCommand checks if the input matches a skill slash command (e.g. /skill-name args).
// If matched, it reads the skill file and returns the resolved prompt. Otherwise returns "".
func (a *App) ResolveSkillCommand(input string) (string, error) {
if !strings.HasPrefix(input, "/") {
return "", nil
}

cmd, arg, _ := strings.Cut(input[1:], " ")
arg = strings.TrimSpace(arg)

for _, skill := range a.CurrentAgentSkills() {
if skill.Name != cmd {
continue
}

content, err := os.ReadFile(skill.FilePath)
if err != nil {
return "", fmt.Errorf("reading skill %q: %w", skill.Name, err)
}

if arg != "" {
return fmt.Sprintf("Use the following skill.\n\nUser's request: %s\n\n<skill name=%q>\n%s\n</skill>", arg, skill.Name, string(content)), nil
}
return fmt.Sprintf("Use the following skill.\n\n<skill name=%q>\n%s\n</skill>", skill.Name, string(content)), nil
}

return "", nil
}

// ResolveInput resolves the user input by trying skill commands first,
// then agent commands. Returns the resolved content ready to send to the agent.
func (a *App) ResolveInput(ctx context.Context, input string) string {
if resolved, err := a.ResolveSkillCommand(input); err != nil {
return fmt.Sprintf("Error loading skill: %v", err)
} else if resolved != "" {
return resolved
}

return a.ResolveCommand(ctx, input)
}

// CurrentAgentModel returns the model ID for the current agent.
// Returns the tracked model from AgentInfoEvent, or falls back to session overrides.
// Returns empty string if no model information is available (fail-open scenario).
Expand All @@ -169,56 +219,12 @@ func (a *App) TrackCurrentAgentModel(model string) {

// CurrentMCPPrompts returns the available MCP prompts for the active agent
func (a *App) CurrentMCPPrompts(ctx context.Context) map[string]mcptools.PromptInfo {
if localRuntime, ok := a.runtime.(*runtime.LocalRuntime); ok {
return localRuntime.CurrentMCPPrompts(ctx)
}
return make(map[string]mcptools.PromptInfo)
return a.runtime.CurrentMCPPrompts(ctx)
}

// ExecuteMCPPrompt executes an MCP prompt with provided arguments and returns the content
func (a *App) ExecuteMCPPrompt(ctx context.Context, promptName string, arguments map[string]string) (string, error) {
localRuntime, ok := a.runtime.(*runtime.LocalRuntime)
if !ok {
return "", fmt.Errorf("MCP prompts are only supported with local runtime")
}

currentAgent := localRuntime.CurrentAgent()
if currentAgent == nil {
return "", fmt.Errorf("no current agent available")
}

for _, toolset := range currentAgent.ToolSets() {
if mcpToolset, ok := tools.As[*mcptools.Toolset](toolset); ok {
result, err := mcpToolset.GetPrompt(ctx, promptName, arguments)
if err == nil {
// Convert the MCP result to a string format suitable for the editor
// The result contains Messages which are the prompt content
if len(result.Messages) == 0 {
return "No content returned from MCP prompt", nil
}

var content string
for i, message := range result.Messages {
if i > 0 {
content += "\n\n"
}
if textContent, ok := message.Content.(*mcp.TextContent); ok {
content += textContent.Text
} else {
content += fmt.Sprintf("[Non-text content: %T]", message.Content)
}
}
return content, nil
}
// If error is "prompt not found", continue to next toolset
// Otherwise, return the error
if err.Error() != "prompt not found" {
return "", fmt.Errorf("error executing prompt '%s': %w", promptName, err)
}
}
}

return "", fmt.Errorf("MCP prompt '%s' not found in any active toolset", promptName)
return a.runtime.ExecuteMCPPrompt(ctx, promptName, arguments)
}

// ResolveCommand converts /command to its prompt text
Expand Down Expand Up @@ -829,19 +835,9 @@ func (a *App) UpdateSessionTitle(ctx context.Context, title string) error {
return ErrTitleGenerating
}

// Update in-memory session
a.session.Title = title

// Check if runtime is a RemoteRuntime and use its UpdateSessionTitle method
if remoteRT, ok := a.runtime.(*runtime.RemoteRuntime); ok {
if err := remoteRT.UpdateSessionTitle(ctx, title); err != nil {
return fmt.Errorf("failed to update session title on remote: %w", err)
}
} else if store := a.runtime.SessionStore(); store != nil {
// For local runtime, persist via session store
if err := store.UpdateSession(ctx, a.session); err != nil {
return fmt.Errorf("failed to persist session title: %w", err)
}
// Persist the title through the runtime
if err := a.runtime.UpdateSessionTitle(ctx, a.session, title); err != nil {
return fmt.Errorf("failed to update session title: %w", err)
}

// Emit a SessionTitleEvent to update the UI consistently
Expand Down Expand Up @@ -876,18 +872,9 @@ func (a *App) generateTitle(ctx context.Context, userMessages []string) {
return
}

// Update the session title
a.session.Title = title

// Persist the title
if remoteRT, ok := a.runtime.(*runtime.RemoteRuntime); ok {
if err := remoteRT.UpdateSessionTitle(ctx, title); err != nil {
slog.Error("Failed to persist title on remote", "session_id", a.session.ID, "error", err)
}
} else if store := a.runtime.SessionStore(); store != nil {
if err := store.UpdateSession(ctx, a.session); err != nil {
slog.Error("Failed to persist title", "session_id", a.session.ID, "error", err)
}
if err := a.runtime.UpdateSessionTitle(ctx, a.session, title); err != nil {
slog.Error("Failed to persist title", "session_id", a.session.ID, "error", err)
}

// Emit the title event to update the UI
Expand Down
45 changes: 44 additions & 1 deletion pkg/app/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import (

"github.com/docker/cagent/pkg/runtime"
"github.com/docker/cagent/pkg/session"
"github.com/docker/cagent/pkg/sessiontitle"
"github.com/docker/cagent/pkg/tools"
mcptools "github.com/docker/cagent/pkg/tools/mcp"
)

// mockRuntime is a minimal mock for testing App without a real runtime
Expand Down Expand Up @@ -43,7 +45,21 @@ func (m *mockRuntime) SessionStore() session.Store { return nil }
func (m *mockRuntime) Summarize(ctx context.Context, sess *session.Session, additionalPrompt string, events chan runtime.Event) {
}
func (m *mockRuntime) PermissionsInfo() *runtime.PermissionsInfo { return nil }
func (m *mockRuntime) Stop() {}
func (m *mockRuntime) CurrentAgentSkillsEnabled() bool { return false }
func (m *mockRuntime) CurrentMCPPrompts(context.Context) map[string]mcptools.PromptInfo {
return make(map[string]mcptools.PromptInfo)
}

func (m *mockRuntime) ExecuteMCPPrompt(context.Context, string, map[string]string) (string, error) {
return "", nil
}

func (m *mockRuntime) UpdateSessionTitle(_ context.Context, sess *session.Session, title string) error {
sess.Title = title
return nil
}
func (m *mockRuntime) TitleGenerator() *sessiontitle.Generator { return nil }
func (m *mockRuntime) Stop() {}

// Verify mockRuntime implements runtime.Runtime
var _ runtime.Runtime = (*mockRuntime)(nil)
Expand Down Expand Up @@ -213,6 +229,33 @@ func TestApp_UpdateSessionTitle(t *testing.T) {
})
}

func TestApp_ResolveSkillCommand_NoLocalRuntime(t *testing.T) {
t.Parallel()

ctx := t.Context()
rt := &mockRuntime{}
sess := session.New()
app := New(ctx, rt, sess)

// mockRuntime is not a LocalRuntime, so no skills should be returned
resolved, err := app.ResolveSkillCommand("/some-skill")
require.NoError(t, err)
assert.Empty(t, resolved)
}

func TestApp_ResolveSkillCommand_NotSlashCommand(t *testing.T) {
t.Parallel()

ctx := t.Context()
rt := &mockRuntime{}
sess := session.New()
app := New(ctx, rt, sess)

resolved, err := app.ResolveSkillCommand("not a slash command")
require.NoError(t, err)
assert.Empty(t, resolved)
}

func TestApp_RegenerateSessionTitle(t *testing.T) {
t.Parallel()

Expand Down
Loading
Loading