diff --git a/.golangci.yml b/.golangci.yml index f9a35d3c4..a76d0270e 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -26,6 +26,7 @@ linters: - usestdlibvars - forbidigo - iotamixing + - gochecknoinits settings: forbidigo: forbid: @@ -37,6 +38,8 @@ linters: msg: "do not use os.MkdirTemp() in tests, use t.TempDir()" - pattern: os\.Setenv() msg: "do not use os.Setenv() in tests, use t.Setenv()" + - pattern: fmt\.Print.*() + msg: "do not use fmt.Print() or fmt.Println() in tests" depguard: rules: internal: diff --git a/AGENTS.md b/AGENTS.md index 95023506c..419dcf105 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -80,8 +80,6 @@ cagent is a multi-agent AI system with hierarchical agent structure and pluggabl #### Agent Configuration ```yaml -version: "2" - agents: root: model: model_ref # Can be inline like "openai/gpt-4o" or reference defined models @@ -251,8 +249,6 @@ toolsets: ### Agent Hierarchy Example ```yaml -version: "2" - agents: root: model: anthropic/claude-sonnet-4-0 @@ -282,7 +278,7 @@ agents: - `/new` - Clear session history - `/compact` - Generate summary and compact session history -- `/copy` - Show token usage statistics +- `/copy` - Copy the current conversation to the clipboard - `/eval` - Save evaluation data ## File Locations and Patterns @@ -309,9 +305,9 @@ agents: - `OPENAI_API_KEY` - OpenAI authentication - `ANTHROPIC_API_KEY` - Anthropic authentication - `GOOGLE_API_KEY` - Google/Gemini authentication +- `MISTRAL_API_KEY` - Mistral authentication - `TELEMETRY_ENABLED` - Control telemetry (set to false to disable) - `CAGENT_HIDE_TELEMETRY_BANNER` - Hide telemetry banner message -- `CAGENT_HIDE_FEEDBACK_LINK` - Hide feedback link ## Debugging and Troubleshooting diff --git a/README.md b/README.md index 8215b5bea..7b1255332 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,8 @@ # 🤖 `cagent` 🤖 -> A powerful, easy to use, customizable multi-agent runtime that orchestrates AI agents with -> specialized capabilities and tools, and the interactions between agents. +> A powerful, easy to use, customizable multi-agent runtime that orchestrates AI +> agents with specialized capabilities and tools, and the interactions between +> agents. ![cagent in action](docs/assets/cagent-run.gif) @@ -10,18 +11,20 @@ `cagent` lets you create and run intelligent AI agents, where each agent has specialized knowledge, tools, and capabilities. -Think of it as allowing you to quickly build, share and run a team of virtual experts that -collaborate to solve complex problems for you. +Think of it as allowing you to quickly build, share and run a team of virtual +experts that collaborate to solve complex problems for you. And it's dead easy to use! -⚠️ Note: `cagent` is in active development, **breaking changes are to be expected** ⚠️ +⚠️ Note: `cagent` is in active development, **breaking changes are to be +expected** ⚠️ ### Your First Agent Example [basic_agent.yaml](/examples/basic_agent.yaml): -Creating agents with cagent is very simple. They are described in a short yaml file, like this one: +Creating agents with cagent is very simple. They are described in a short yaml +file, like this one: ```yaml agents: @@ -39,19 +42,23 @@ Many more examples can be found [here](/examples/README.md)! ### Improving an agent with MCP tools -`cagent` supports MCP servers, enabling agents to use a wide variety of external tools and services. +`cagent` supports MCP servers, enabling agents to use a wide variety of external +tools and services. It supports three transport types: `stdio`, `http` and `sse`. -Giving an agent access to tools via MCP is a quick way to greatly improve its capabilities, the quality of its results and its general useful-ness. +Giving an agent access to tools via MCP is a quick way to greatly improve its +capabilities, the quality of its results and its general usefulness. -Get started quickly with the [Docker MCP Toolkit](https://docs.docker.com/ai/mcp-catalog-and-toolkit/toolkit/) and [catalog](https://docs.docker.com/ai/mcp-catalog-and-toolkit/catalog/) +Get started quickly with the [Docker MCP +Toolkit](https://docs.docker.com/ai/mcp-catalog-and-toolkit/toolkit/) and +[catalog](https://docs.docker.com/ai/mcp-catalog-and-toolkit/catalog/) -Here, we're giving the same basic agent from the example above access to a **containerized** `duckduckgo` mcp server and it's tools by using Docker's MCP Gateway: +Here, we're giving the same basic agent from the example above access to a +**containerized** `duckduckgo` mcp server and its tools by using Docker's MCP +Gateway: ```yaml -version: "2" - agents: root: model: openai/gpt-5-mini @@ -64,15 +71,18 @@ agents: ref: docker:duckduckgo # stdio transport ``` -When using a containerized server via the Docker MCP gateway, you can configure any required settings/secrets/authentication using the [Docker MCP Toolkit](https://docs.docker.com/ai/mcp-catalog-and-toolkit/toolkit/#example-use-the-github-official-mcp-server) in Docker Desktop. +When using a containerized server via the Docker MCP gateway, you can configure +any required settings/secrets/authentication using the [Docker MCP +Toolkit](https://docs.docker.com/ai/mcp-catalog-and-toolkit/toolkit/#example-use-the-github-official-mcp-server) +in Docker Desktop. -Aside from the containerized MCP severs the Docker MCP Gateway provides, any standard MCP server can be used with cagent! +Aside from the containerized MCP servers the Docker MCP Gateway provides, any +standard MCP server can be used with cagent! -Here's an example similar to the above but adding `read_file` and `write_file` tools from the `rust-mcp-filesystem` MCP server: +Here's an example similar to the above but adding `read_file` and `write_file` +tools from the `rust-mcp-filesystem` MCP server: ```yaml -version: "2" - agents: root: model: openai/gpt-5-mini @@ -91,16 +101,51 @@ agents: - "RUST_LOG=debug" ``` -See [the USAGE docs](./docs/USAGE.md#tool-configuration) for more detailed information and examples +See [the USAGE docs](./docs/USAGE.md#tool-configuration) for more detailed +information and examples + +### Exposing agents as MCP tools + +`cagent` can expose agents as MCP tools via the `cagent mcp` command, allowing other MCP clients to use your agents. + +Each agent in your configuration becomes an MCP tool with its description. + +```bash +# Start MCP server with local file +cagent mcp ./examples/dev-team.yaml + +# Or use an OCI artifact +cagent mcp agentcatalog/pirate +``` + +This exposes each agent as a tool (e.g., `root`, `designer`, `awesome_engineer`) that MCP clients can call: + +```json +{ + "method": "tools/call", + "params": { + "name": "designer", + "arguments": { + "message": "Design a login page" + } + } +} +``` ### 🎯 Key Features -- **🏗️ Multi-agent architecture** - Create specialized agents for different domains. -- **🔧 Rich tool ecosystem** - Agents can use external tools and APIs via the MCP protocol. -- **🔄 Smart delegation** - Agents can automatically route tasks to the most suitable specialist. +- **🏗️ Multi-agent architecture** - Create specialized agents for different + domains. +- **🔧 Rich tool ecosystem** - Agents can use external tools and APIs via the + MCP protocol. +- **🔄 Smart delegation** - Agents can automatically route tasks to the most + suitable specialist. - **📝 YAML configuration** - Declarative model and agent configuration. -- **💭 Advanced reasoning** - Built-in "think", "todo" and "memory" tools for complex problem-solving. -- **🌐 Multiple AI providers** - Support for OpenAI, Anthropic, Gemini, xAI and [Docker Model Runner](https://docs.docker.com/ai/model-runner/). +- **💭 Advanced reasoning** - Built-in "think", "todo" and "memory" tools for + complex problem-solving. +- **🌐 Multiple AI providers** - Support for OpenAI, Anthropic, Gemini, xA, + Mistral, Nebius and [Docker Model + Runner](https://docs.docker.com/ai/model-runner/). ## 🚀 Quick Start 🚀 @@ -116,22 +161,27 @@ $ brew install cagent #### Using binary releases -[Prebuilt binaries](https://github.com/docker/cagent/releases) for Windows, macOS and Linux can be found on the releases page of the [project's GitHub repository](https://github.com/docker/cagent/releases) +[Prebuilt binaries](https://github.com/docker/cagent/releases) for Windows, +macOS and Linux can be found on the releases page of the [project's GitHub +repository](https://github.com/docker/cagent/releases) -Once you've downloaded the appropriate binary for your platform, you may need to give it executable permissions. -On macOS and Linux, this is done with the following command: +Once you've downloaded the appropriate binary for your platform, you may need to +give it executable permissions. On macOS and Linux, this is done with the +following command: ```sh # linux amd64 build example chmod +x /path/to/downloads/cagent-linux-amd64 ``` -You can then rename the binary to `cagent` and configure your `PATH` to be able to find it (configuration varies by platform). +You can then rename the binary to `cagent` and configure your `PATH` to be able +to find it (configuration varies by platform). ### **Set your API keys** -Based on the models you configure your agents to use, you will need to set the corresponding provider API key accordingly, -all theses keys are optional, you will likely need at least one of these, though: +Based on the models you configure your agents to use, you will need to set the +corresponding provider API key accordingly, all these keys are optional, you +will likely need at least one of these, though: ```bash # For OpenAI models @@ -145,6 +195,12 @@ export GOOGLE_API_KEY=your_api_key_here # For xAI models export XAI_API_KEY=your_api_key_here + +# For Nebius models +export NEBIUS_API_KEY=your_api_key_here + +# For Mistral models +export MISTRAL_API_KEY=your_api_key_here ``` ### Run Agents! @@ -198,11 +254,17 @@ models: max_tokens: 64000 ``` -You'll find a curated list of agents examples, spread into 3 categories, [Basic](https://github.com/docker/cagent/tree/main/examples#basic-configurations), [Advanced](https://github.com/docker/cagent/tree/main/examples#advanced-configurations) and [multi-agents](https://github.com/docker/cagent/tree/main/examples#multi-agent-configurations) in the `/examples/` directory. +You'll find a curated list of agents examples, spread into 3 categories, +[Basic](https://github.com/docker/cagent/tree/main/examples#basic-configurations), +[Advanced](https://github.com/docker/cagent/tree/main/examples#advanced-configurations) +and +[multi-agents](https://github.com/docker/cagent/tree/main/examples#multi-agent-configurations) +in the `/examples/` directory. ### DMR (Docker Model Runner) provider options -When using the `dmr` provider, you can use the `provider_opts` key for DMR runtime-specific (e.g. llama.cpp) options: +When using the `dmr` provider, you can use the `provider_opts` key for DMR +runtime-specific (e.g. llama.cpp) options: ```yaml models: @@ -214,18 +276,29 @@ models: runtime_flags: ["--ngl=33", "--repeat-penalty=1.2", ...] # or comma/space-separated string ``` -The default base_url `cagent` will use for DMR providers is `http://localhost:12434/engines/llama.cpp/v1`. DMR itself might need to be enabled via [Docker Desktop's settings](https://docs.docker.com/ai/model-runner/get-started/#enable-dmr-in-docker-desktop) on MacOS and Windows, and via command line on [Docker CE on Linux](https://docs.docker.com/ai/model-runner/get-started/#enable-dmr-in-docker-engine). +The default base_url `cagent` will use for DMR providers is +`http://localhost:12434/engines/llama.cpp/v1`. DMR itself might need to be +enabled via [Docker Desktop's +settings](https://docs.docker.com/ai/model-runner/get-started/#enable-dmr-in-docker-desktop) +on MacOS and Windows, and via command line on [Docker CE on +Linux](https://docs.docker.com/ai/model-runner/get-started/#enable-dmr-in-docker-engine). ## Quickly generate agents and agent teams with `cagent new` -Using the command `cagent new` you can quickly generate agents or multi-agent teams using a single prompt! +Using the command `cagent new` you can quickly generate agents or multi-agent +teams using a single prompt! `cagent` has a built-in agent dedicated to this task. -To use the feature, you must have an Anthropic, OpenAI or Google API key available in your environment, or specify a local model to run with DMR (Docker Model Runner). +To use the feature, you must have an Anthropic, OpenAI or Google API key +available in your environment, or specify a local model to run with DMR (Docker +Model Runner). -You can choose what provider and model gets used by passing the `--model provider/modelname` flag to `cagent new` +You can choose what provider and model gets used by passing the `--model +provider/modelname` flag to `cagent new` -If `--model` is unspecified, `cagent new` will automatically choose between these 3 providers in order based on the first api key it finds in your environment. +If `--model` is unspecified, `cagent new` will automatically choose between +these 3 providers in order based on the first api key it finds in your +environment. ```sh export ANTHROPIC_API_KEY=your_api_key_here # first choice. default model claude-sonnet-4-0 @@ -234,10 +307,13 @@ export GOOGLE_API_KEY=your_api_key_here # if anthropic and openai keys are n ``` `--max-tokens` can be specified to override the context limit used. -When using DMR, the default is 16k to limit memory usage. With all other providers the default is 64k +When using DMR, the default is 16k to limit memory usage. With all other +providers the default is 64k -`--max-iterations` can be specified to override how many times the agent is allowed to loop when doing tool calling etc. -When using DMR, the default is set to 20 (small local models have the highest chance of getting confused and looping endlessly). For all other providers, the default is 0 (unlimited). +`--max-iterations` can be specified to override how many times the agent is +allowed to loop when doing tool calling etc. When using DMR, the default is set +to 20 (small local models have the highest chance of getting confused and +looping endlessly). For all other providers, the default is 0 (unlimited). Example of provider, model, context size and max iterations overriding: @@ -272,13 +348,15 @@ What should your agent/agent team do? (describe its purpose): ### `cagent push` -Agent configurations can be packaged and shared to Docker Hub using the `cagent push` command +Agent configurations can be packaged and shared to Docker Hub using the `cagent +push` command ```sh cagent push ./.yaml namespace/reponame ``` -`cagent` will automatically build an OCI image and push it to the desired repository using your Docker credentials +`cagent` will automatically build an OCI image and push it to the desired +repository using your Docker credentials ### `cagent pull` @@ -288,27 +366,32 @@ Pulling agents from Docker Hub is also just one `cagent pull` command away. cagent pull creek/pirate ``` -`cagent` will pull the image, extract the yaml file and place it in your working directory for ease of use. +`cagent` will pull the image, extract the yaml file and place it in your working +directory for ease of use. `cagent run creek.yaml` will run your newly pulled agent ## Usage -More details on the usage and configuration of `cagent` can be found in [USAGE.md](/docs/USAGE.md) +More details on the usage and configuration of `cagent` can be found in +[USAGE.md](/docs/USAGE.md) ## Telemetry -We track anonymous usage data to improve the tool. See [TELEMETRY.md](/docs/TELEMETRY.md) for details. +We track anonymous usage data to improve the tool. See +[TELEMETRY.md](/docs/TELEMETRY.md) for details. ## Contributing Want to hack on `cagent`, or help us fix bugs and build out some features? 🔧 -Read the information on how to build from source and contribute to the project in [CONTRIBUTING.md](/docs/CONTRIBUTING.md) +Read the information on how to build from source and contribute to the project +in [CONTRIBUTING.md](/docs/CONTRIBUTING.md) ## DogFooding: using `cagent` to code on `cagent` -A smart way to improve `cagent`'s codebase and feature set is to do it with the help of a `cagent` agent! +A smart way to improve `cagent`'s codebase and feature set is to do it with the +help of a `cagent` agent! We have one that we use and that you should use too: @@ -317,12 +400,14 @@ cd cagent cagent run ./golang_developer.yaml ``` -This agent is an _expert Golang developer specializing in the cagent multi-agent AI system architecture_. +This agent is an _expert Golang developer specializing in the cagent multi-agent +AI system architecture_. -Ask it anything about `cagent`. It can be questions about the current code or about -improvements to the code. It can also fix issues and implement new features! +Ask it anything about `cagent`. It can be questions about the current code or +about improvements to the code. It can also fix issues and implement new +features! ## Share your feedback -We’d love to hear your thoughts on this project. -You can find us on [Slack](https://dockercommunity.slack.com/archives/C09DASHHRU4) +We’d love to hear your thoughts on this project. You can find us on +[Slack](https://dockercommunity.slack.com/archives/C09DASHHRU4) diff --git a/cagent-schema.json b/cagent-schema.json index 52abd00d8..41d97a116 100644 --- a/cagent-schema.json +++ b/cagent-schema.json @@ -9,16 +9,14 @@ "type": "string", "description": "Configuration version", "enum": [ + "0", "1", - "2", - "v1", - "v2" + "2" ], "examples": [ + "0", "1", - "2", - "v1", - "v2" + "2" ] }, "agents": { @@ -61,6 +59,10 @@ "type": "string", "description": "Description of the agent" }, + "welcome_message": { + "type": "string", + "description": "Optional welcome message to display when the agent starts" + }, "toolsets": { "type": "array", "description": "List of toolsets available to the agent", @@ -325,7 +327,8 @@ "filesystem", "shell", "todo", - "fetch" + "fetch", + "api" ] }, "instruction": { @@ -397,6 +400,10 @@ "items": { "$ref": "#/definitions/PostEditConfig" } + }, + "api_config": { + "$ref": "#/definitions/ApiConfig", + "description": "API tool configuration" } }, "additionalProperties": false, @@ -445,6 +452,22 @@ ] } } + }, + { + "allOf": [ + { + "properties": { + "type": { + "const": "api" + } + } + }, + { + "required": [ + "api_config" + ] + } + ] } ] }, @@ -530,6 +553,73 @@ "cmd" ], "additionalProperties": false + }, + "ApiConfig": { + "type": "object", + "description": "API tool configuration for making HTTP requests to external APIs", + "properties": { + "name": { + "type": "string", + "description": "Name of the API tool" + }, + "instruction": { + "type": "string", + "description": "Instructions for using the API tool" + }, + "endpoint": { + "type": "string", + "description": "API endpoint URL", + "format": "uri" + }, + "method": { + "type": "string", + "description": "HTTP method", + "enum": [ + "GET", + "POST", + "PUT", + "PATCH", + "DELETE" + ] + }, + "headers": { + "type": "object", + "description": "HTTP headers for the request", + "additionalProperties": { + "type": "string" + } + }, + "args": { + "type": "object", + "description": "Arguments schema for the API call", + "additionalProperties": { + "type": "object", + "properties": { + "type": { + "type": "string", + "description": "Argument type" + }, + "description": { + "type": "string", + "description": "Argument description" + } + } + } + }, + "required": { + "type": "array", + "description": "Required argument names", + "items": { + "type": "string" + } + } + }, + "required": [ + "name", + "endpoint", + "method" + ], + "additionalProperties": false } } } \ No newline at end of file diff --git a/cmd/root/acp.go b/cmd/root/acp.go index 7d472767f..a495197aa 100644 --- a/cmd/root/acp.go +++ b/cmd/root/acp.go @@ -2,42 +2,45 @@ package root import ( "log/slog" - "os" acpsdk "github.com/coder/acp-go-sdk" "github.com/spf13/cobra" "github.com/docker/cagent/pkg/acp" + "github.com/docker/cagent/pkg/config" "github.com/docker/cagent/pkg/telemetry" ) -// NewACPCmd creates a new acp command -func NewACPCmd() *cobra.Command { +type acpFlags struct { + runConfig config.RuntimeConfig +} + +func newACPCmd() *cobra.Command { + var flags acpFlags + cmd := &cobra.Command{ Use: "acp ", Short: "Start an ACP (Agent Client Protocol) server", Long: `Start an ACP server that exposes the agent via the Agent Client Protocol`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - telemetry.TrackCommand("acp", args) - return runACP(cmd, args) - }, + RunE: flags.runACPCommand, } - addGatewayFlags(cmd) - addRuntimeConfigFlags(cmd) + addRuntimeConfigFlags(cmd, &flags.runConfig) return cmd } -func runACP(cmd *cobra.Command, args []string) error { +func (f *acpFlags) runACPCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("acp", args) + ctx := cmd.Context() agentFilename := args[0] - slog.Debug("Starting ACP server", "agent_file", agentFilename, "debug_mode", debugMode) + slog.Debug("Starting ACP server", "agent_file", agentFilename) - acpAgent := acp.NewAgent(agentFilename, runConfig) - conn := acpsdk.NewAgentSideConnection(acpAgent, os.Stdout, os.Stdin) + acpAgent := acp.NewAgent(agentFilename, f.runConfig) + conn := acpsdk.NewAgentSideConnection(acpAgent, cmd.OutOrStdout(), cmd.InOrStdin()) conn.SetLogger(slog.Default()) acpAgent.SetAgentConnection(conn) defer acpAgent.Stop(ctx) diff --git a/cmd/root/alias.go b/cmd/root/alias.go index ff47f244c..c2a313cca 100644 --- a/cmd/root/alias.go +++ b/cmd/root/alias.go @@ -10,11 +10,11 @@ import ( "github.com/spf13/cobra" "github.com/docker/cagent/pkg/aliases" + "github.com/docker/cagent/pkg/cli" "github.com/docker/cagent/pkg/telemetry" ) -// NewAliasCmd creates a new alias command for managing aliases -func NewAliasCmd() *cobra.Command { +func newAliasCmd() *cobra.Command { cmd := &cobra.Command{ Use: "alias", Short: "Manage aliases for agents", @@ -39,49 +39,42 @@ func NewAliasCmd() *cobra.Command { return cmd } -// newAliasAddCmd creates the add subcommand func newAliasAddCmd() *cobra.Command { return &cobra.Command{ Use: "add ", Short: "Add a new alias", Args: cobra.ExactArgs(2), - RunE: func(_ *cobra.Command, args []string) error { - telemetry.TrackCommand("alias", append([]string{"add"}, args...)) - return createAlias(args[0], args[1]) - }, + RunE: runAliasAddCommand, } } -// newAliasListCmd creates the list subcommand func newAliasListCmd() *cobra.Command { return &cobra.Command{ Use: "list", Aliases: []string{"ls"}, Short: "List all registered aliases", Args: cobra.NoArgs, - RunE: func(*cobra.Command, []string) error { - telemetry.TrackCommand("alias", []string{"list"}) - return listAliases() - }, + RunE: runAliasListCommand, } } -// newAliasRemoveCmd creates the remove subcommand func newAliasRemoveCmd() *cobra.Command { return &cobra.Command{ Use: "remove ", Aliases: []string{"rm"}, Short: "Remove a registered alias", Args: cobra.ExactArgs(1), - RunE: func(_ *cobra.Command, args []string) error { - telemetry.TrackCommand("alias", append([]string{"remove"}, args...)) - return removeAlias(args[0]) - }, + RunE: runAliasRemoveCommand, } } -// createAlias creates a new alias -func createAlias(name, agentPath string) error { +func runAliasAddCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("alias", append([]string{"add"}, args...)) + + out := cli.NewPrinter(cmd.OutOrStdout()) + name := args[0] + agentPath := args[1] + // Load existing aliases s, err := aliases.Load() if err != nil { @@ -102,34 +95,19 @@ func createAlias(name, agentPath string) error { return fmt.Errorf("failed to save aliases: %w", err) } - fmt.Printf("Alias '%s' created successfully\n", name) - fmt.Printf(" Alias: %s\n", name) - fmt.Printf(" Agent: %s\n", absAgentPath) - fmt.Printf("\nYou can now run: cagent run %s\n", name) + out.Printf("Alias '%s' created successfully\n", name) + out.Printf(" Alias: %s\n", name) + out.Printf(" Agent: %s\n", absAgentPath) + out.Printf("\nYou can now run: cagent run %s\n", name) return nil } -// removeAlias removes an alias -func removeAlias(name string) error { - s, err := aliases.Load() - if err != nil { - return fmt.Errorf("failed to load aliases: %w", err) - } +func runAliasListCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("alias", append([]string{"list"}, args...)) - if !s.Delete(name) { - return fmt.Errorf("alias '%s' not found", name) - } + out := cli.NewPrinter(cmd.OutOrStdout()) - if err := s.Save(); err != nil { - return fmt.Errorf("failed to save aliases: %w", err) - } - - fmt.Printf("Alias '%s' removed successfully\n", name) - return nil -} - -func listAliases() error { s, err := aliases.Load() if err != nil { return fmt.Errorf("failed to load aliases: %w", err) @@ -137,12 +115,12 @@ func listAliases() error { allAliases := s.List() if len(allAliases) == 0 { - fmt.Println("No aliases registered.") - fmt.Println("\nCreate an alias with: cagent alias add ") + out.Println("No aliases registered.") + out.Println("\nCreate an alias with: cagent alias add ") return nil } - fmt.Printf("Registered aliases (%d):\n\n", len(allAliases)) + out.Printf("Registered aliases (%d):\n\n", len(allAliases)) // Sort aliases by name for consistent output names := make([]string, 0, len(allAliases)) @@ -162,11 +140,34 @@ func listAliases() error { for _, name := range names { path := allAliases[name] padding := strings.Repeat(" ", maxLen-len(name)) - fmt.Printf(" %s%s → %s\n", name, padding, path) + out.Printf(" %s%s → %s\n", name, padding, path) + } + + out.Println("\nRun an alias with: cagent run ") + + return nil +} + +func runAliasRemoveCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("alias", append([]string{"remove"}, args...)) + + out := cli.NewPrinter(cmd.OutOrStdout()) + name := args[0] + + s, err := aliases.Load() + if err != nil { + return fmt.Errorf("failed to load aliases: %w", err) + } + + if !s.Delete(name) { + return fmt.Errorf("alias '%s' not found", name) } - fmt.Printf("\nRun an alias with: cagent run \n") + if err := s.Save(); err != nil { + return fmt.Errorf("failed to save aliases: %w", err) + } + out.Printf("Alias '%s' removed successfully\n", name) return nil } diff --git a/cmd/root/api.go b/cmd/root/api.go index d543be89b..e9b30866a 100644 --- a/cmd/root/api.go +++ b/cmd/root/api.go @@ -3,82 +3,108 @@ package root import ( "fmt" "log/slog" - "net" "os" "path/filepath" + "time" + "github.com/google/go-containerregistry/pkg/name" "github.com/spf13/cobra" + "github.com/docker/cagent/pkg/agentfile" + "github.com/docker/cagent/pkg/cli" + "github.com/docker/cagent/pkg/config" + "github.com/docker/cagent/pkg/remote" "github.com/docker/cagent/pkg/server" "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/teamloader" "github.com/docker/cagent/pkg/telemetry" ) -// NewAPICmd creates a new api command -func NewAPICmd() *cobra.Command { +type apiFlags struct { + listenAddr string + sessionDB string + pullIntervalMins int + runConfig config.RuntimeConfig +} + +func newAPICmd() *cobra.Command { + var flags apiFlags + cmd := &cobra.Command{ Use: "api |", Short: "Start the API server", Long: `Start the API server that exposes the agent via an HTTP API`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - telemetry.TrackCommand("api", args) - - // Make sure no question is ever asked to the user in api mode. - os.Stdin = nil - - return runHTTP(cmd, args) - }, + RunE: flags.runAPICommand, } - cmd.PersistentFlags().StringVarP(&listenAddr, "listen", "l", ":8080", "Address to listen on") - cmd.PersistentFlags().StringVarP(&sessionDB, "session-db", "s", "session.db", "Path to the session database") - addGatewayFlags(cmd) - addRuntimeConfigFlags(cmd) + cmd.PersistentFlags().StringVarP(&flags.listenAddr, "listen", "l", ":8080", "Address to listen on") + cmd.PersistentFlags().StringVarP(&flags.sessionDB, "session-db", "s", "session.db", "Path to the session database") + cmd.PersistentFlags().IntVar(&flags.pullIntervalMins, "pull-interval", 0, "Auto-pull OCI reference every N minutes (0 = disabled)") + addRuntimeConfigFlags(cmd, &flags.runConfig) return cmd } -func runHTTP(cmd *cobra.Command, args []string) error { +// isOCIReference checks if the input is a valid OCI reference +func isOCIReference(input string) bool { + if agentfile.IsLocalFile(input) { + return false + } + _, err := name.ParseReference(input) + return err == nil +} + +func (f *apiFlags) runAPICommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("api", args) + ctx := cmd.Context() + out := cli.NewPrinter(cmd.OutOrStdout()) agentsPath := args[0] - ln, err := server.Listen(ctx, listenAddr) + // Make sure no question is ever asked to the user in api mode. + os.Stdin = nil + + if f.pullIntervalMins > 0 && !isOCIReference(agentsPath) { + return fmt.Errorf("--pull-interval flag can only be used with OCI references, not local files") + } + + resolvedPath, err := agentfile.Resolve(ctx, out, agentsPath) + if err != nil { + return err + } + + ln, err := server.Listen(ctx, f.listenAddr) if err != nil { - return fmt.Errorf("failed to listen on %s: %w", listenAddr, err) + return fmt.Errorf("failed to listen on %s: %w", f.listenAddr, err) } go func() { <-ctx.Done() _ = ln.Close() }() - if _, ok := ln.(*net.TCPListener); ok { - slog.Info("Listening on http://localhost" + listenAddr) - } else { - slog.Info("Listening on " + listenAddr) - } + slog.Info("Listening on " + ln.Addr().String()) - slog.Debug("Starting server", "agents", agentsPath, "debug_mode", debugMode) + slog.Debug("Starting server", "agents", resolvedPath) - sessionStore, err := session.NewSQLiteSessionStore(sessionDB) + sessionStore, err := session.NewSQLiteSessionStore(f.sessionDB) if err != nil { return fmt.Errorf("failed to create session store: %w", err) } var opts []server.Opt - stat, err := os.Stat(agentsPath) + stat, err := os.Stat(resolvedPath) if err != nil { return fmt.Errorf("failed to stat agents path: %w", err) } if stat.IsDir() { - opts = append(opts, server.WithAgentsDir(agentsPath)) + opts = append(opts, server.WithAgentsDir(resolvedPath)) } else { - opts = append(opts, server.WithAgentsDir(filepath.Dir(agentsPath))) + opts = append(opts, server.WithAgentsDir(filepath.Dir(resolvedPath))) } - teams, err := teamloader.LoadTeams(ctx, agentsPath, runConfig) + teams, err := teamloader.LoadTeams(ctx, resolvedPath, f.runConfig) if err != nil { return fmt.Errorf("failed to load teams: %w", err) } @@ -90,10 +116,46 @@ func runHTTP(cmd *cobra.Command, args []string) error { } }() - s, err := server.New(sessionStore, runConfig, teams, opts...) + s, err := server.New(sessionStore, f.runConfig, teams, opts...) if err != nil { return fmt.Errorf("failed to create server: %w", err) } + // Start background auto-pull for OCI references if enabled + if f.pullIntervalMins > 0 { + go func() { + ticker := time.NewTicker(time.Duration(f.pullIntervalMins) * time.Minute) + defer ticker.Stop() + + slog.Info("Auto-pull enabled for OCI reference", "reference", agentsPath, "interval_minutes", f.pullIntervalMins) + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + slog.Info("Auto-pulling OCI reference", "reference", agentsPath) + if _, err := remote.Pull(agentsPath); err != nil { + slog.Error("Failed to auto-pull OCI reference", "reference", agentsPath, "error", err) + continue + } + + // Resolve the OCI reference to get the updated file path + newResolvedPath, err := agentfile.Resolve(ctx, out, agentsPath) + if err != nil { + slog.Error("Failed to resolve OCI reference after pull", "reference", agentsPath, "error", err) + continue + } + + if err := s.ReloadTeams(ctx, newResolvedPath); err != nil { + slog.Error("Failed to reload teams", "reference", agentsPath, "error", err) + } else { + slog.Info("Successfully reloaded teams from updated OCI reference", "reference", agentsPath) + } + } + } + }() + } + return s.Serve(ctx, ln) } diff --git a/cmd/root/api_test.go b/cmd/root/api_test.go new file mode 100644 index 000000000..5100162a1 --- /dev/null +++ b/cmd/root/api_test.go @@ -0,0 +1,105 @@ +package root + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestIsOCIReference(t *testing.T) { + tmpDir := t.TempDir() + + tests := []struct { + name string + input string + expected bool + }{ + // Valid OCI references + { + name: "simple repository with tag", + input: "myregistry/myrepo:latest", + expected: true, + }, + { + name: "repository with digest", + input: "myregistry/myrepo@sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", + expected: true, + }, + { + name: "docker hub image", + input: "nginx:latest", + expected: true, + }, + { + name: "fully qualified registry", + input: "ghcr.io/docker/cagent:v1.0.0", + expected: true, + }, + { + name: "registry with port", + input: "localhost:5000/myimage:tag", + expected: true, + }, + + // Local files - NOT OCI references + { + name: "yaml file", + input: "agent.yaml", + expected: false, + }, + { + name: "yml file", + input: "config.yml", + expected: false, + }, + { + name: "yaml file with path", + input: "/path/to/agent.yaml", + expected: false, + }, + { + name: "file descriptor", + input: "/dev/fd/3", + expected: false, + }, + + // Invalid inputs - NOT valid OCI references + { + name: "empty string", + input: "", + expected: false, + }, + { + name: "typo in yaml filename", + input: "my-agnt.yaml", + expected: false, + }, + { + name: "invalid OCI reference with too many colons", + input: "invalid:reference:with:too:many:colons", + expected: false, + }, + { + name: "random string", + input: "not-a-valid-reference!!!", + expected: false, + }, + { + name: "non-existent directory path that looks like OCI ref", + input: "/path/to/agents", + expected: true, // Parses as valid OCI ref if path doesn't exist + }, + { + name: "existing directory", + input: tmpDir, + expected: false, // Existing paths are NOT OCI references + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isOCIReference(tt.input) + assert.Equal(t, tt.expected, result, "isOCIReference(%q) = %v, want %v", tt.input, result, tt.expected) + }) + } +} diff --git a/cmd/root/build.go b/cmd/root/build.go index 5886860ab..0e48f3ab7 100644 --- a/cmd/root/build.go +++ b/cmd/root/build.go @@ -3,37 +3,45 @@ package root import ( "github.com/spf13/cobra" + "github.com/docker/cagent/pkg/cli" "github.com/docker/cagent/pkg/filesystem" "github.com/docker/cagent/pkg/oci" "github.com/docker/cagent/pkg/telemetry" ) -var opts oci.Options +type buildFlags struct { + opts oci.Options +} + +func newBuildCmd() *cobra.Command { + var flags buildFlags -func NewBuildCmd() *cobra.Command { cmd := &cobra.Command{ Use: "build [docker-image-name]", Short: "Build a Docker image for the agent", Args: cobra.RangeArgs(1, 2), - RunE: runBuildCommand, + RunE: flags.runBuildCommand, } - cmd.PersistentFlags().BoolVar(&opts.DryRun, "dry-run", false, "only print the generated Dockerfile") - cmd.PersistentFlags().BoolVar(&opts.Push, "push", false, "push the image") - cmd.PersistentFlags().BoolVar(&opts.NoCache, "no-cache", false, "Do not use cache when building the image") - cmd.PersistentFlags().BoolVar(&opts.Pull, "pull", false, "Always attempt to pull all referenced images") + cmd.PersistentFlags().BoolVar(&flags.opts.DryRun, "dry-run", false, "only print the generated Dockerfile") + cmd.PersistentFlags().BoolVar(&flags.opts.Push, "push", false, "push the image") + cmd.PersistentFlags().BoolVar(&flags.opts.NoCache, "no-cache", false, "Do not use cache when building the image") + cmd.PersistentFlags().BoolVar(&flags.opts.Pull, "pull", false, "Always attempt to pull all referenced images") return cmd } -func runBuildCommand(cmd *cobra.Command, args []string) error { +func (f *buildFlags) runBuildCommand(cmd *cobra.Command, args []string) error { telemetry.TrackCommand("build", args) + ctx := cmd.Context() + out := cli.NewPrinter(cmd.OutOrStdout()) + agentFilePath := args[0] dockerImageName := "" if len(args) > 1 { dockerImageName = args[1] } - return oci.BuildDockerImage(cmd.Context(), agentFilePath, filesystem.AllowAll, dockerImageName, opts) + return oci.BuildDockerImage(ctx, out, agentFilePath, filesystem.AllowAll, dockerImageName, f.opts) } diff --git a/cmd/root/catalog.go b/cmd/root/catalog.go index 8f7a7208d..d8ba606aa 100644 --- a/cmd/root/catalog.go +++ b/cmd/root/catalog.go @@ -15,8 +15,7 @@ import ( "github.com/docker/cagent/pkg/telemetry" ) -// NewCatalogCmd creates the catalog command with its subcommands -func NewCatalogCmd() *cobra.Command { +func newCatalogCmd() *cobra.Command { cmd := &cobra.Command{ Use: "catalog", Short: "Manage the agent catalog", @@ -32,20 +31,21 @@ func newCatalogListCmd() *cobra.Command { Use: "list [org]", Short: "List catalog entries", Args: cobra.MaximumNArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - // Track catalog list with "list" as the first argument for telemetry - telemetryArgs := append([]string{"list"}, args...) - telemetry.TrackCommand("catalog", telemetryArgs) - - var org string - if len(args) == 0 { - org = "agentcatalog" - } else { - org = args[0] - } - return listCatalog(cmd.Context(), org) - }, + RunE: runCatalogListCommand, + } +} + +func runCatalogListCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("catalog", append([]string{"list"}, args...)) + + var org string + if len(args) == 0 { + org = "agentcatalog" + } else { + org = args[0] } + + return listCatalog(cmd.Context(), org) } type hubRepoList struct { diff --git a/cmd/root/common.go b/cmd/root/common.go new file mode 100644 index 000000000..d5e85f1e0 --- /dev/null +++ b/cmd/root/common.go @@ -0,0 +1,33 @@ +package root + +import ( + "fmt" + "log/slog" + "os" + "path/filepath" +) + +func setupWorkingDirectory(workingDir string) error { + if workingDir == "" { + return nil + } + + absWd, err := filepath.Abs(workingDir) + if err != nil { + return fmt.Errorf("invalid working directory: %w", err) + } + + info, err := os.Stat(absWd) + if err != nil || !info.IsDir() { + return fmt.Errorf("working directory does not exist or is not a directory: %s", absWd) + } + + if err := os.Chdir(absWd); err != nil { + return fmt.Errorf("failed to change working directory: %w", err) + } + + _ = os.Setenv("PWD", absWd) + slog.Debug("Working directory set", "path", absWd) + + return nil +} diff --git a/cmd/root/debug.go b/cmd/root/debug.go index c4a090a9c..96ef95a85 100644 --- a/cmd/root/debug.go +++ b/cmd/root/debug.go @@ -5,32 +5,43 @@ import ( "github.com/spf13/cobra" + "github.com/docker/cagent/pkg/cli" + "github.com/docker/cagent/pkg/config" "github.com/docker/cagent/pkg/teamloader" + "github.com/docker/cagent/pkg/telemetry" ) -// NewDebugCmd creates a command that prints the debug information about cagent. -func NewDebugCmd() *cobra.Command { +type debugFlags struct { + runConfig config.RuntimeConfig +} + +func newDebugCmd() *cobra.Command { + var flags debugFlags + cmd := &cobra.Command{ - Use: "debug", - Hidden: true, + Use: "debug", } cmd.AddCommand(&cobra.Command{ Use: "toolsets ", Short: "Debug the toolsets of an agent", Args: cobra.ExactArgs(1), - RunE: debugToolsetsCommand, + RunE: flags.runDebugToolsetsCommand, }) + addRuntimeConfigFlags(cmd, &flags.runConfig) + return cmd } -func debugToolsetsCommand(cmd *cobra.Command, args []string) error { +func (f *debugFlags) runDebugToolsetsCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("debug", append([]string{"toolsets"}, args...)) + ctx := cmd.Context() + out := cli.NewPrinter(cmd.OutOrStdout()) agentFilename := args[0] - slog.Info("Loading agent", "agent", agentFilename) - team, err := teamloader.Load(ctx, agentFilename, runConfig) + team, err := teamloader.Load(ctx, agentFilename, f.runConfig) if err != nil { return err } @@ -42,19 +53,23 @@ func debugToolsetsCommand(cmd *cobra.Command, args []string) error { continue } - slog.Info("Query tools", "name", agent.Name()) tools, err := agent.Tools(ctx) if err != nil { slog.Error("Failed to query tools", "name", agent.Name(), "error", err) continue } + if len(tools) == 0 { + out.Printf("No tools for %s\n", agent.Name()) + continue + } + + out.Printf("%d tool(s) for %s:\n", len(tools), agent.Name()) for _, tool := range tools { - slog.Info("Tool found", "name", tool.Name) + out.Println(" +", tool.Name) } } - slog.Info("Stopping toolsets", "agent", agentFilename) if err := team.StopToolSets(ctx); err != nil { slog.Error("Failed to stop tool sets", "error", err) } diff --git a/cmd/root/eval.go b/cmd/root/eval.go index a9b4e11e0..3f7208238 100644 --- a/cmd/root/eval.go +++ b/cmd/root/eval.go @@ -1,33 +1,40 @@ package root import ( - "fmt" - "github.com/spf13/cobra" + "github.com/docker/cagent/pkg/cli" + "github.com/docker/cagent/pkg/config" "github.com/docker/cagent/pkg/evaluation" "github.com/docker/cagent/pkg/teamloader" "github.com/docker/cagent/pkg/telemetry" ) -func NewEvalCmd() *cobra.Command { +type evalFlags struct { + runConfig config.RuntimeConfig +} + +func newEvalCmd() *cobra.Command { + var flags evalFlags + cmd := &cobra.Command{ Use: "eval ", Short: "Run evaluations for an agent", Args: cobra.ExactArgs(2), - RunE: runEvalCommand, + RunE: flags.runEvalCommand, } - addGatewayFlags(cmd) - addRuntimeConfigFlags(cmd) + addRuntimeConfigFlags(cmd, &flags.runConfig) return cmd } -func runEvalCommand(cmd *cobra.Command, args []string) error { +func (f *evalFlags) runEvalCommand(cmd *cobra.Command, args []string) error { telemetry.TrackCommand("eval", args) - agents, err := teamloader.Load(cmd.Context(), args[0], runConfig) + out := cli.NewPrinter(cmd.OutOrStdout()) + + agents, err := teamloader.Load(cmd.Context(), args[0], f.runConfig) if err != nil { return err } @@ -38,9 +45,9 @@ func runEvalCommand(cmd *cobra.Command, args []string) error { } for _, evalResult := range evalResults { - fmt.Printf("Eval file: %s\n", evalResult.EvalFile) - fmt.Printf("Tool trajectory score: %f\n", evalResult.Score.ToolTrajectoryScore) - fmt.Printf("Rouge-1 score: %f\n", evalResult.Score.Rouge1Score) + out.Printf("Eval file: %s\n", evalResult.EvalFile) + out.Printf("Tool trajectory score: %f\n", evalResult.Score.ToolTrajectoryScore) + out.Printf("Rouge-1 score: %f\n", evalResult.Score.Rouge1Score) } return nil diff --git a/cmd/root/exec.go b/cmd/root/exec.go index 8168d9569..945e4ee4c 100644 --- a/cmd/root/exec.go +++ b/cmd/root/exec.go @@ -1,25 +1,40 @@ package root -import "github.com/spf13/cobra" +import ( + "github.com/spf13/cobra" + + "github.com/docker/cagent/pkg/cli" + "github.com/docker/cagent/pkg/telemetry" +) + +func newExecCmd() *cobra.Command { + var flags runExecFlags -func NewExecCmd() *cobra.Command { cmd := &cobra.Command{ Use: "exec ", Short: "Execute an agent", Args: cobra.RangeArgs(1, 2), - RunE: execCommand, + RunE: flags.runExecCommand, } - cmd.PersistentFlags().StringVarP(&agentName, "agent", "a", "root", "Name of the agent to run") - cmd.PersistentFlags().StringVar(&workingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)") - cmd.PersistentFlags().BoolVar(&autoApprove, "yolo", false, "Automatically approve all tool calls without prompting") - cmd.PersistentFlags().StringVar(&attachmentPath, "attach", "", "Attach an image file to the message") - cmd.PersistentFlags().StringArrayVar(&modelOverrides, "model", nil, "Override agent model: [agent=]provider/model (repeatable)") - cmd.PersistentFlags().BoolVar(&dryRun, "dry-run", false, "Initialize the agent without executing anything") + cmd.PersistentFlags().StringVarP(&flags.agentName, "agent", "a", "root", "Name of the agent to run") + cmd.PersistentFlags().StringVar(&flags.workingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)") + cmd.PersistentFlags().BoolVar(&flags.autoApprove, "yolo", false, "Automatically approve all tool calls without prompting") + cmd.PersistentFlags().StringVar(&flags.attachmentPath, "attach", "", "Attach an image file to the message") + cmd.PersistentFlags().StringArrayVar(&flags.modelOverrides, "model", nil, "Override agent model: [agent=]provider/model (repeatable)") + cmd.PersistentFlags().BoolVar(&flags.dryRun, "dry-run", false, "Initialize the agent without executing anything") _ = cmd.PersistentFlags().MarkHidden("dry-run") - addGatewayFlags(cmd) - addRuntimeConfigFlags(cmd) + addRuntimeConfigFlags(cmd, &flags.runConfig) return cmd } + +func (f *runExecFlags) runExecCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("exec", args) + + ctx := cmd.Context() + out := cli.NewPrinter(cmd.OutOrStdout()) + + return f.runOrExec(ctx, out, args, true) +} diff --git a/cmd/root/feedback.go b/cmd/root/feedback.go index e21f7b9e0..dc4449665 100644 --- a/cmd/root/feedback.go +++ b/cmd/root/feedback.go @@ -9,16 +9,19 @@ import ( "github.com/docker/cagent/pkg/telemetry" ) -// NewFeedbackCmd creates a new feedback command -func NewFeedbackCmd() *cobra.Command { +func newFeedbackCmd() *cobra.Command { return &cobra.Command{ Use: "feedback", Short: "Send feedback about cagent", Long: `Submit feedback or report issues with cagent`, Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - telemetry.TrackCommand("feedback", args) - fmt.Println("Feel free to give feedback:\n", feedback.FeedbackLink) - }, + RunE: runFeedbackCommand, } } + +func runFeedbackCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("feedback", args) + + fmt.Fprintln(cmd.OutOrStdout(), "Feel free to give feedback:\n", feedback.FeedbackLink) + return nil +} diff --git a/cmd/root/flags.go b/cmd/root/flags.go index e63c0f793..10e8255ce 100644 --- a/cmd/root/flags.go +++ b/cmd/root/flags.go @@ -6,14 +6,9 @@ import ( "github.com/docker/cagent/pkg/config" ) -var ( - listenAddr string - sessionDB string - runConfig config.RuntimeConfig -) - -func addRuntimeConfigFlags(cmd *cobra.Command) { +func addRuntimeConfigFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) { + addGatewayFlags(cmd, runConfig) cmd.PersistentFlags().StringSliceVar(&runConfig.EnvFiles, "env-from-file", nil, "Set environment variables from file") - cmd.PersistentFlags().StringVar(&runConfig.RedirectURI, "redirect-uri", "", "Set the redirect URI for OAuth2 flows") + cmd.PersistentFlags().StringVar(&runConfig.RedirectURI, "redirect-uri", "http://localhost:8083/oauth-callback", "Set the redirect URI for OAuth2 flows") cmd.PersistentFlags().BoolVar(&runConfig.GlobalCodeMode, "code-mode-tools", false, "Provide a single tool to call other tools via Javascript") } diff --git a/cmd/root/gateway.go b/cmd/root/gateway.go index b49c0f639..3100c3032 100644 --- a/cmd/root/gateway.go +++ b/cmd/root/gateway.go @@ -6,23 +6,17 @@ import ( "strings" "github.com/spf13/cobra" + + "github.com/docker/cagent/pkg/config" ) const ( - flagGateway = "gateway" flagModelsGateway = "models-gateway" - envGateway = "CAGENT_GATEWAY" envModelsGateway = "CAGENT_MODELS_GATEWAY" ) -type gatewayConfig struct { - mainGateway string -} - -var gwConfig gatewayConfig - func canonize(endpoint string) string { - return strings.TrimSpace(strings.TrimSuffix(endpoint, "/")) + return strings.TrimSuffix(strings.TrimSpace(endpoint), "/") } func logEnvvarShadowing(flagValue, varName, flagName string) { @@ -31,39 +25,16 @@ func logEnvvarShadowing(flagValue, varName, flagName string) { } } -func addGatewayFlags(cmd *cobra.Command) { - cmd.PersistentFlags().StringVar(&gwConfig.mainGateway, flagGateway, "", "Set the gateway address to use for models and tool calls") +func addGatewayFlags(cmd *cobra.Command, runConfig *config.RuntimeConfig) { cmd.PersistentFlags().StringVar(&runConfig.ModelsGateway, flagModelsGateway, "", "Set the models gateway address") - // Don't allow gateway to be specified if a qualified gateway flag is provided - cmd.MarkFlagsMutuallyExclusive(flagGateway, flagModelsGateway) - persistentPreRunE := cmd.PersistentPreRunE - cmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error { - // verify mutual exclusion for environment variables - if os.Getenv(envGateway) != "" && os.Getenv(envModelsGateway) != "" { - return fmt.Errorf("environment variables %s and %s cannot be set at the same time", envGateway, envModelsGateway) - } - - // Get gateway value from the environment. - // This behavior sets both the models and tools gateway - mainGateway := os.Getenv(envGateway) - if mainGateway != "" { - logEnvvarShadowing(gwConfig.mainGateway, envGateway, flagGateway) - gwConfig.mainGateway = mainGateway - runConfig.ModelsGateway = mainGateway - } - + cmd.PersistentPreRunE = func(_ *cobra.Command, args []string) error { if gateway := os.Getenv(envModelsGateway); gateway != "" { logEnvvarShadowing(runConfig.ModelsGateway, envModelsGateway, flagModelsGateway) runConfig.ModelsGateway = gateway } - // Set the qualified gateways to the main gateway if they haven't been set explicitly - if runConfig.ModelsGateway == "" { - runConfig.ModelsGateway = gwConfig.mainGateway - } - // Ensure the gateway url is canonical. runConfig.ModelsGateway = canonize(runConfig.ModelsGateway) diff --git a/cmd/root/gateway_test.go b/cmd/root/gateway_test.go index cc5893873..b1b69b29b 100644 --- a/cmd/root/gateway_test.go +++ b/cmd/root/gateway_test.go @@ -12,139 +12,53 @@ import ( func TestGatewayLogic(t *testing.T) { tests := []struct { - name string - envVars map[string]string - args []string - expectedModelsGateway string - expectError bool - errorContains string + name string + env string + args []string + expected string }{ { - name: "env_var_models_gateway", - envVars: map[string]string{"CAGENT_MODELS_GATEWAY": "https://models.example.com"}, - expectedModelsGateway: "https://models.example.com", - }, - { - name: "env_var_gateway", - envVars: map[string]string{"CAGENT_GATEWAY": "https://gateway.example.com"}, - expectedModelsGateway: "https://gateway.example.com", - }, - { - name: "cli_flag_models_gateway", - args: []string{"--models-gateway", "https://cli-models.example.com"}, - expectedModelsGateway: "https://cli-models.example.com", - }, - { - name: "cli_flag_gateway_mutually_exclusive_with_models_gateway", - args: []string{"--gateway", "https://gateway.example.com", "--models-gateway", "https://models.example.com"}, - expectError: true, - errorContains: "if any flags in the group [gateway models-gateway] are set none of the others can be", - }, - { - name: "gateway_url_canonicalization_with_main_gateway", - envVars: map[string]string{ - "CAGENT_GATEWAY": "https://gateway.example.com/", // Main gateway with trailing slash - }, - args: []string{}, - expectedModelsGateway: "https://gateway.example.com", - }, - // Tests for combinations of environment variables and CLI arguments - { - name: "env_var_overrides_same_cli_flag", - envVars: map[string]string{ - "CAGENT_MODELS_GATEWAY": "https://env-models.example.com", - }, - args: []string{"--models-gateway", "https://cli-models.example.com"}, - expectedModelsGateway: "https://env-models.example.com", - }, - { - name: "env_var_main_gateway_overrides_cli_flags", - envVars: map[string]string{ - "CAGENT_GATEWAY": "https://env-gateway.example.com", - }, - args: []string{"--models-gateway", "https://cli-gateway.example.com"}, - expectedModelsGateway: "https://env-gateway.example.com", + name: "env", + env: "https://models.example.com", + expected: "https://models.example.com", }, { - name: "cli_flag_gateway_sets_both_gateways", - args: []string{"--gateway", "https://cli-gateway.example.com"}, - expectedModelsGateway: "https://cli-gateway.example.com", + name: "cli", + args: []string{"--models-gateway", "https://cli-models.example.com"}, + expected: "https://cli-models.example.com", }, { - name: "env_vars_both_gateways_override_cli_gateway_flag", - envVars: map[string]string{ - "CAGENT_MODELS_GATEWAY": "https://env-models.example.com", - }, - args: []string{"--gateway", "https://cli-gateway.example.com"}, - expectedModelsGateway: "https://env-models.example.com", - }, - { - name: "env_var_main_gateway_mutually_exclusive_with_models_gateway", - envVars: map[string]string{ - "CAGENT_GATEWAY": "https://gateway.example.com", - "CAGENT_MODELS_GATEWAY": "https://models.example.com", - }, - args: []string{}, - expectError: true, - errorContains: "environment variables CAGENT_GATEWAY and CAGENT_MODELS_GATEWAY cannot be set at the same time", - }, - { - name: "env_var_main_gateway_mutually_exclusive_with_both_specific_gateways", - envVars: map[string]string{ - "CAGENT_GATEWAY": "https://gateway.example.com", - "CAGENT_MODELS_GATEWAY": "https://models.example.com", - }, - args: []string{}, - expectError: true, - errorContains: "environment variables CAGENT_GATEWAY and CAGENT_MODELS_GATEWAY cannot be set at the same time", + name: "env_overrides_cli", + env: "https://env-models.example.com", + args: []string{"--models-gateway", "https://cli-models.example.com"}, + expected: "https://env-models.example.com", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Set test environment variables using t.Setenv (automatically handles cleanup) - for key, value := range tt.envVars { - t.Setenv(key, value) - } - - // Reset global variables - runConfig = config.RuntimeConfig{} - gwConfig = gatewayConfig{} + t.Setenv("CAGENT_MODELS_GATEWAY", tt.env) - // Create a test command with gateway flags cmd := &cobra.Command{ - Use: "test", - RunE: func(cmd *cobra.Command, args []string) error { - // Command logic here - for testing, we just return nil + RunE: func(*cobra.Command, []string) error { return nil }, } + runConfig := config.RuntimeConfig{} + addGatewayFlags(cmd, &runConfig) - // Add gateway flags (this is the actual function being tested) - addGatewayFlags(cmd) - - // Set command arguments and execute cmd.SetArgs(tt.args) + err := cmd.Execute() - // Execute the command - this triggers flag parsing and PersistentPreRunE - _, err := cmd.ExecuteC() - - if tt.expectError { - require.Error(t, err) - if tt.errorContains != "" { - assert.Contains(t, err.Error(), tt.errorContains) - } - } else { - require.NoError(t, err) - - // Verify expected gateway configuration - assert.Equal(t, tt.expectedModelsGateway, runConfig.ModelsGateway, "Models gateway mismatch") - } + require.NoError(t, err) + assert.Equal(t, tt.expected, runConfig.ModelsGateway) }) } } func TestCanonize(t *testing.T) { + t.Parallel() + tests := []struct { name string input string @@ -163,7 +77,7 @@ func TestCanonize(t *testing.T) { { name: "trailing_slash_and_whitespace", input: " https://example.com/ ", - expected: "https://example.com/", // TrimSuffix doesn't work because string ends with " ", not "/" + expected: "https://example.com", }, { name: "no_trailing_slash", @@ -189,7 +103,10 @@ func TestCanonize(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { + t.Parallel() + result := canonize(tt.input) + assert.Equal(t, tt.expected, result) }) } diff --git a/cmd/root/mcp.go b/cmd/root/mcp.go new file mode 100644 index 000000000..bc42465b5 --- /dev/null +++ b/cmd/root/mcp.go @@ -0,0 +1,50 @@ +package root + +import ( + "io" + + "github.com/spf13/cobra" + + "github.com/docker/cagent/pkg/cli" + "github.com/docker/cagent/pkg/config" + "github.com/docker/cagent/pkg/mcp" + "github.com/docker/cagent/pkg/telemetry" +) + +type mcpFlags struct { + workingDir string + runConfig config.RuntimeConfig +} + +func newMCPCmd() *cobra.Command { + var flags mcpFlags + + cmd := &cobra.Command{ + Use: "mcp ", + Short: "Start an MCP (Model Context Protocol) server", + Long: `Start an MCP server that exposes agents as MCP tools via stdio`, + Example: ` cagent mcp ./agent.yaml + cagent mcp ./team.yaml + cagent mcp agentcatalog/pirate`, + Args: cobra.ExactArgs(1), + RunE: flags.runMCPCommand, + } + + cmd.PersistentFlags().StringVar(&flags.workingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)") + addRuntimeConfigFlags(cmd, &flags.runConfig) + + return cmd +} + +func (f *mcpFlags) runMCPCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("mcp", args) + + ctx := cmd.Context() + out := cli.NewPrinter(io.Discard) + + if err := setupWorkingDirectory(f.workingDir); err != nil { + return err + } + + return mcp.StartMCPServer(ctx, out, args[0], f.runConfig) +} diff --git a/cmd/root/new.go b/cmd/root/new.go index ee3ce2556..9e1cd219e 100644 --- a/cmd/root/new.go +++ b/cmd/root/new.go @@ -1,157 +1,122 @@ package root import ( - "fmt" "os" "strings" + tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" - "github.com/docker/cagent/pkg/cli" + "github.com/docker/cagent/pkg/app" + "github.com/docker/cagent/pkg/config" "github.com/docker/cagent/pkg/creator" - "github.com/docker/cagent/pkg/input" "github.com/docker/cagent/pkg/runtime" + "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/telemetry" + "github.com/docker/cagent/pkg/tui" ) -var ( +type newFlags struct { modelParam string maxTokensParam int maxIterationsParam int -) + runConfig config.RuntimeConfig +} + +func newNewCmd() *cobra.Command { + var flags newFlags -// NewNewCmd creates a new command to create a new agent configuration -func NewNewCmd() *cobra.Command { cmd := &cobra.Command{ Use: "new", Short: "Create a new agent configuration", Long: `Create a new agent configuration by asking questions and generating a YAML file`, - RunE: func(cmd *cobra.Command, args []string) error { - telemetry.TrackCommand("new", args) - - ctx := cmd.Context() - - var model string // final model name - var modelProvider string // final provider name - - // Parse provider from --model if specified as "provider/model" where provider is recognized - derivedProvider := "" - if idx := strings.Index(modelParam, "/"); idx > 0 { - candidate := strings.ToLower(modelParam[:idx]) - switch candidate { - case "anthropic", "openai", "google", "dmr": - derivedProvider = candidate - model = modelParam[idx+1:] - } - } + RunE: flags.runNewCommand, + } - // Determine provider - if derivedProvider != "" { - modelProvider = derivedProvider - } else { - if runConfig.ModelsGateway == "" { - // Prefer Anthropic, then OpenAI, then Google based on available API keys - // default to DMR if no provider credentials are found - switch { - case os.Getenv("ANTHROPIC_API_KEY") != "": - modelProvider = "anthropic" - fmt.Printf("%s\n\n", cli.White("ANTHROPIC_API_KEY found, using Anthropic")) - case os.Getenv("OPENAI_API_KEY") != "": - modelProvider = "openai" - fmt.Printf("%s\n\n", cli.White("OPENAI_API_KEY found, using OpenAI")) - case os.Getenv("GOOGLE_API_KEY") != "": - modelProvider = "google" - fmt.Printf("%s\n\n", cli.White("GOOGLE_API_KEY found, using Google")) - default: - modelProvider = "dmr" - fmt.Printf("%s\n\n", cli.Yellow("⚠️ No provider credentials found, defaulting to Docker Model Runner (DMR)")) - } - if modelParam == "" { - fmt.Printf("%s\n\n", cli.White("use \"--model provider/model\" to use a different model")) - } - } else { - // Using Models Gateway; default to Anthropic if not specified - modelProvider = "anthropic" - } - } + cmd.PersistentFlags().StringVar(&flags.modelParam, "model", "", "Model to use, optionally as provider/model where provider is one of: anthropic, openai, google, dmr. If omitted, provider is auto-selected based on available credentials or gateway") + cmd.PersistentFlags().IntVar(&flags.maxTokensParam, "max-tokens", 0, "Override max_tokens for the selected model (0 = default)") + cmd.PersistentFlags().IntVar(&flags.maxIterationsParam, "max-iterations", 0, "Maximum number of agentic loop iterations to prevent infinite loops (default: 20 for DMR, unlimited for other providers)") - prompt := "" - if len(args) > 0 { - prompt = strings.Join(args, " ") - } else { - fmt.Printf("%s\n", cli.Blue("------- Welcome to %s! -------", cli.Bold(AppName))) - fmt.Printf("%s\n\n", cli.White(" (Ctrl+C to exit)")) - fmt.Printf("%s\n\n", cli.Blue("What should your agent/agent team do? (describe its purpose)")) - fmt.Print(cli.Blue("> ")) - - var err error - prompt, err = input.ReadLine(ctx, os.Stdin) - if err != nil { - return fmt.Errorf("failed to read purpose: %w", err) - } - prompt = strings.TrimSpace(prompt) - fmt.Println() - } + return cmd +} - out, rt, err := creator.StreamCreateAgent(ctx, ".", prompt, runConfig, modelProvider, model, maxTokensParam, maxIterationsParam) - if err != nil { - return err - } +func (f *newFlags) runNewCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("new", args) + + ctx := cmd.Context() + + var model string // final model name + var modelProvider string // final provider name + + // Parse provider from --model if specified as "provider/model" where provider is recognized + derivedProvider := "" + if idx := strings.Index(f.modelParam, "/"); idx > 0 { + candidate := strings.ToLower(f.modelParam[:idx]) + switch candidate { + case "anthropic", "openai", "google", "mistral", "dmr": + derivedProvider = candidate + model = f.modelParam[idx+1:] + } + } - llmIsTyping := false - - for event := range out { - switch e := event.(type) { - case *runtime.AgentChoiceEvent: - if !llmIsTyping { - fmt.Println() - llmIsTyping = true - } - fmt.Printf("%s", e.Content) - case *runtime.ToolCallEvent: - if llmIsTyping { - fmt.Println() - llmIsTyping = false - } - cli.PrintToolCall(e.ToolCall) - case *runtime.ToolCallResponseEvent: - if llmIsTyping { - fmt.Println() - llmIsTyping = false - } - cli.PrintToolCallResponse(e.ToolCall, e.Response) - case *runtime.ErrorEvent: - if llmIsTyping { - fmt.Println() - llmIsTyping = false - } - cli.PrintError(fmt.Errorf("%s", e.Error)) - case *runtime.MaxIterationsReachedEvent: - if llmIsTyping { - fmt.Println() - llmIsTyping = false - } - - result := cli.PromptMaxIterationsContinue(ctx, e.MaxIterations) - switch result { - case cli.ConfirmationApprove: - rt.Resume(ctx, string(runtime.ResumeTypeApprove)) - case cli.ConfirmationReject: - rt.Resume(ctx, string(runtime.ResumeTypeReject)) - return nil - case cli.ConfirmationAbort: - rt.Resume(ctx, string(runtime.ResumeTypeReject)) - } - } + // Determine provider + if derivedProvider != "" { + modelProvider = derivedProvider + } else { + if f.runConfig.ModelsGateway == "" { + switch { + case os.Getenv("ANTHROPIC_API_KEY") != "": + modelProvider = "anthropic" + case os.Getenv("OPENAI_API_KEY") != "": + modelProvider = "openai" + case os.Getenv("GOOGLE_API_KEY") != "": + modelProvider = "google" + case os.Getenv("MISTRAL_API_KEY") != "": + modelProvider = "mistral" + default: + modelProvider = "dmr" } - fmt.Print("\n\n") - return nil - }, + } else { + // Using Models Gateway; default to Anthropic if not specified + modelProvider = "anthropic" + } } - addGatewayFlags(cmd) - cmd.PersistentFlags().StringVar(&modelParam, "model", "", "Model to use, optionally as provider/model where provider is one of: anthropic, openai, google, dmr. If omitted, provider is auto-selected based on available credentials or gateway") - cmd.PersistentFlags().IntVar(&maxTokensParam, "max-tokens", 0, "Override max_tokens for the selected model (0 = default)") - cmd.PersistentFlags().IntVar(&maxIterationsParam, "max-iterations", 0, "Maximum number of agentic loop iterations to prevent infinite loops (default: 20 for DMR, unlimited for other providers)") - return cmd + t, err := creator.Agent(ctx, ".", f.runConfig, modelProvider, f.maxTokensParam, model) + if err != nil { + return err + } + rt, err := runtime.New(t) + if err != nil { + return err + } + + var prompt *string + opts := []session.Opt{ + session.WithTitle("New agent"), + session.WithMaxIterations(f.maxIterationsParam), + session.WithToolsApproved(true), + } + if len(args) > 0 { + arg := strings.Join(args, " ") + opts = append(opts, session.WithUserMessage("", arg)) + prompt = &arg + } + + sess := session.New(opts...) + + a := app.New("", rt, sess, prompt) + m := tui.New(a) + + progOpts := []tea.ProgramOption{ + tea.WithContext(ctx), + tea.WithFilter(tui.MouseEventFilter), + } + + p := tea.NewProgram(m, progOpts...) + + go a.Subscribe(ctx, p) + + _, err = p.Run() + return err } diff --git a/cmd/root/otel.go b/cmd/root/otel.go index fee819d85..1d29573db 100644 --- a/cmd/root/otel.go +++ b/cmd/root/otel.go @@ -16,7 +16,7 @@ import ( const AppName = "cagent" // initOTelSDK initializes OpenTelemetry SDK with OTLP exporter -func initOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err error) { +func initOTelSDK(ctx context.Context) (err error) { res, err := resource.Merge( resource.Default(), resource.NewWithAttributes( @@ -26,7 +26,7 @@ func initOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err ), ) if err != nil { - return nil, fmt.Errorf("failed to create resource: %w", err) + return fmt.Errorf("failed to create resource: %w", err) } var traceExporter trace.SpanExporter @@ -39,7 +39,7 @@ func initOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err otlptracehttp.WithInsecure(), // TODO: make configurable ) if err != nil { - return nil, fmt.Errorf("failed to create trace exporter: %w", err) + return fmt.Errorf("failed to create trace exporter: %w", err) } } @@ -59,5 +59,10 @@ func initOTelSDK(ctx context.Context) (shutdown func(context.Context) error, err tp := trace.NewTracerProvider(tracerProviderOpts...) otel.SetTracerProvider(tp) - return tp.Shutdown, nil + go func() { + <-ctx.Done() + _ = tp.Shutdown(context.Background()) + }() + + return nil } diff --git a/cmd/root/print.go b/cmd/root/print.go index 091657800..cdae1551d 100644 --- a/cmd/root/print.go +++ b/cmd/root/print.go @@ -9,16 +9,16 @@ import ( "github.com/docker/cagent/pkg/telemetry" ) -func NewPrintCmd() *cobra.Command { +func newPrintCmd() *cobra.Command { return &cobra.Command{ Use: "print ", Short: "Print the canonical form of an agent file", Args: cobra.ExactArgs(1), - RunE: printCommand, + RunE: runPrintCommand, } } -func printCommand(cmd *cobra.Command, args []string) error { +func runPrintCommand(cmd *cobra.Command, args []string) error { telemetry.TrackCommand("print", args) agentFilename := args[0] diff --git a/cmd/root/pull.go b/cmd/root/pull.go index b605dbff7..b0c03d582 100644 --- a/cmd/root/pull.go +++ b/cmd/root/pull.go @@ -4,33 +4,35 @@ import ( "fmt" "log/slog" "os" - "path/filepath" "strings" "github.com/google/go-containerregistry/pkg/crane" "github.com/spf13/cobra" + "github.com/docker/cagent/pkg/agentfile" + "github.com/docker/cagent/pkg/cli" "github.com/docker/cagent/pkg/remote" "github.com/docker/cagent/pkg/telemetry" ) -func NewPullCmd() *cobra.Command { +func newPullCmd() *cobra.Command { return &cobra.Command{ Use: "pull ", Short: "Pull an artifact from Docker Hub", Long: `Pull an artifact from Docker Hub`, Args: cobra.ExactArgs(1), - RunE: func(cmd *cobra.Command, args []string) error { - telemetry.TrackCommand("pull", args) - return runPullCommand(args[0]) - }, + RunE: runPullCommand, } } -func runPullCommand(registryRef string) error { +func runPullCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("pull", args) + + out := cli.NewPrinter(cmd.OutOrStdout()) + registryRef := args[0] slog.Debug("Starting pull", "registry_ref", registryRef) - fmt.Println("Pulling agent", registryRef) + out.Println("Pulling agent", registryRef) var opts []crane.Option _, err := remote.Pull(registryRef, opts...) @@ -38,19 +40,19 @@ func runPullCommand(registryRef string) error { return fmt.Errorf("failed to pull artifact: %w", err) } - yamlFile, err := fromStore(registryRef) + yamlFile, err := agentfile.FromStore(registryRef) if err != nil { return fmt.Errorf("failed to get agent yaml: %w", err) } agentName := strings.ReplaceAll(registryRef, "/", "_") - fileName := filepath.Join(agentsDir, agentName+".yaml") + fileName := agentName + ".yaml" if err := os.WriteFile(fileName, []byte(yamlFile), 0o644); err != nil { return err } - fmt.Printf("Agent saved to %s\n", fileName) + out.Printf("Agent saved to %s\n", fileName) return nil } diff --git a/cmd/root/push.go b/cmd/root/push.go index b19627d68..bbf99e582 100644 --- a/cmd/root/push.go +++ b/cmd/root/push.go @@ -6,13 +6,14 @@ import ( "github.com/spf13/cobra" + "github.com/docker/cagent/pkg/cli" "github.com/docker/cagent/pkg/content" "github.com/docker/cagent/pkg/oci" "github.com/docker/cagent/pkg/remote" "github.com/docker/cagent/pkg/telemetry" ) -func NewPushCmd() *cobra.Command { +func newPushCmd() *cobra.Command { return &cobra.Command{ Use: "push ", Short: "Push an agent to an OCI registry", @@ -21,14 +22,17 @@ func NewPushCmd() *cobra.Command { The local identifier can be either a reference (tag) or a digest that was returned from the build command.`, Args: cobra.ExactArgs(2), - RunE: func(cmd *cobra.Command, args []string) error { - telemetry.TrackCommand("push", args) - return runPushCommand(args[0], args[1]) - }, + RunE: runPushCommand, } } -func runPushCommand(filePath, tag string) error { +func runPushCommand(cmd *cobra.Command, args []string) error { + telemetry.TrackCommand("push", args) + + filePath := args[0] + tag := args[1] + out := cli.NewPrinter(cmd.OutOrStdout()) + store, err := content.NewStore() if err != nil { return err @@ -41,13 +45,13 @@ func runPushCommand(filePath, tag string) error { slog.Debug("Starting push", "registry_ref", tag) - fmt.Printf("Pushing agent %s to %s\n", filePath, tag) + out.Printf("Pushing agent %s to %s\n", filePath, tag) err = remote.Push(tag) if err != nil { return fmt.Errorf("failed to push artifact: %w", err) } - fmt.Printf("Successfully pushed artifact to %s\n", tag) + out.Printf("Successfully pushed artifact to %s\n", tag) return nil } diff --git a/cmd/root/readme.go b/cmd/root/readme.go deleted file mode 100644 index 86f9e6ca3..000000000 --- a/cmd/root/readme.go +++ /dev/null @@ -1,35 +0,0 @@ -package root - -import ( - "fmt" - - "github.com/spf13/cobra" - - "github.com/docker/cagent/pkg/config" - "github.com/docker/cagent/pkg/filesystem" - "github.com/docker/cagent/pkg/telemetry" -) - -// NewReadmeCmd creates a command that prints the README of an agent. -func NewReadmeCmd() *cobra.Command { - return &cobra.Command{ - Use: "readme ", - Short: "Print the README of an agent", - Args: cobra.ExactArgs(1), - RunE: readmeAgentCommand, - } -} - -func readmeAgentCommand(_ *cobra.Command, args []string) error { - telemetry.TrackCommand("readme", args) - - agentFilename := args[0] - - cfg, err := config.LoadConfig(agentFilename, filesystem.AllowAll) - if err != nil { - return err - } - - _, err = fmt.Print(cfg.Metadata.Readme) - return err -} diff --git a/cmd/root/root.go b/cmd/root/root.go index ae699793d..09b30f92a 100644 --- a/cmd/root/root.go +++ b/cmd/root/root.go @@ -7,10 +7,8 @@ import ( "io" "log/slog" "os" - "os/signal" "path/filepath" "strings" - "syscall" "github.com/spf13/cobra" @@ -21,79 +19,49 @@ import ( "github.com/docker/cagent/pkg/version" ) -// RuntimeError wraps runtime errors to distinguish them from usage errors -type RuntimeError struct { - Err error -} - -func (e RuntimeError) Error() string { - return e.Err.Error() -} - -func (e RuntimeError) Unwrap() error { - return e.Err -} - -var ( - agentName string - debugMode bool +type rootFlags struct { enableOtel bool + debugMode bool logFilePath string logFile *os.File -) - -// isFirstRun checks if this is the first time cagent is being run -// It creates a marker file in the user's config directory -func isFirstRun() bool { - configDir := paths.GetConfigDir() - markerFile := filepath.Join(configDir, ".cagent_first_run") - - // Check if marker file exists - if _, err := os.Stat(markerFile); err == nil { - return false // File exists, not first run - } - - // Create marker file to indicate this run has happened - if err := os.MkdirAll(configDir, 0o755); err != nil { - return false // Can't create config dir, assume not first run - } - - if err := os.WriteFile(markerFile, []byte(""), 0o644); err != nil { - return false // Can't create marker file, assume not first run - } - - return true // Successfully created marker, this is first run } -// NewRootCmd creates the root command for cagent func NewRootCmd() *cobra.Command { + var flags rootFlags + cmd := &cobra.Command{ Use: "cagent", Short: "cagent - AI agent runner", Long: `cagent is a command-line tool for running AI agents`, PersistentPreRunE: func(cmd *cobra.Command, args []string) error { // Initialize logging before anything else so logs don't break TUI - if err := setupLogging(cmd); err != nil { + if err := flags.setupLogging(cmd); err != nil { // If logging setup fails, fall back to stderr so we still get logs - slog.SetDefault(slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ + slog.SetDefault(slog.New(slog.NewTextHandler(cmd.ErrOrStderr(), &slog.HandlerOptions{ Level: func() slog.Level { - if debugMode { + if flags.debugMode { return slog.LevelDebug } return slog.LevelInfo }(), }))) } - if cmd.DisplayName() != "exec" && os.Getenv("CAGENT_HIDE_FEEDBACK_LINK") != "1" { - _, _ = cmd.OutOrStdout().Write([]byte("\nFor any feedback, please visit: " + feedback.FeedbackLink + "\n\n")) + + telemetry.SetGlobalTelemetryDebugMode(flags.debugMode) + + if flags.enableOtel { + if err := initOTelSDK(cmd.Context()); err != nil { + slog.Warn("Failed to initialize OpenTelemetry SDK", "error", err) + } else { + slog.Debug("OpenTelemetry SDK initialized successfully") + } } - telemetry.SetGlobalTelemetryDebugMode(debugMode) return nil }, PersistentPostRunE: func(cmd *cobra.Command, args []string) error { - if logFile != nil { - _ = logFile.Close() + if flags.logFile != nil { + _ = flags.logFile.Close() } return nil }, @@ -106,34 +74,31 @@ func NewRootCmd() *cobra.Command { } // Add persistent debug flag available to all commands - cmd.PersistentFlags().BoolVarP(&debugMode, "debug", "d", false, "Enable debug logging") - cmd.PersistentFlags().BoolVarP(&enableOtel, "otel", "o", false, "Enable OpenTelemetry tracing") - cmd.PersistentFlags().StringVar(&logFilePath, "log-file", "", "Path to debug log file (default: ~/.cagent/cagent.debug.log; only used with --debug)") - - cmd.AddCommand(NewVersionCmd()) - cmd.AddCommand(NewRunCmd()) - cmd.AddCommand(NewExecCmd()) - cmd.AddCommand(NewNewCmd()) - cmd.AddCommand(NewAPICmd()) - cmd.AddCommand(NewACPCmd()) - cmd.AddCommand(NewEvalCmd()) - cmd.AddCommand(NewPushCmd()) - cmd.AddCommand(NewPullCmd()) - cmd.AddCommand(NewReadmeCmd()) - cmd.AddCommand(NewDebugCmd()) - cmd.AddCommand(NewFeedbackCmd()) - cmd.AddCommand(NewCatalogCmd()) - cmd.AddCommand(NewBuildCmd()) - cmd.AddCommand(NewPrintCmd()) - cmd.AddCommand(NewAliasCmd()) + cmd.PersistentFlags().BoolVarP(&flags.debugMode, "debug", "d", false, "Enable debug logging") + cmd.PersistentFlags().BoolVarP(&flags.enableOtel, "otel", "o", false, "Enable OpenTelemetry tracing") + cmd.PersistentFlags().StringVar(&flags.logFilePath, "log-file", "", "Path to debug log file (default: ~/.cagent/cagent.debug.log; only used with --debug)") + + cmd.AddCommand(newVersionCmd()) + cmd.AddCommand(newRunCmd()) + cmd.AddCommand(newExecCmd()) + cmd.AddCommand(newNewCmd()) + cmd.AddCommand(newAPICmd()) + cmd.AddCommand(newACPCmd()) + cmd.AddCommand(newMCPCmd()) + cmd.AddCommand(newEvalCmd()) + cmd.AddCommand(newPushCmd()) + cmd.AddCommand(newPullCmd()) + cmd.AddCommand(newDebugCmd()) + cmd.AddCommand(newFeedbackCmd()) + cmd.AddCommand(newCatalogCmd()) + cmd.AddCommand(newBuildCmd()) + cmd.AddCommand(newPrintCmd()) + cmd.AddCommand(newAliasCmd()) return cmd } -func Execute() error { - ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) - defer cancel() - +func Execute(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer, args ...string) error { // Set the version for automatic telemetry initialization telemetry.SetGlobalTelemetryVersion(version.Version) @@ -148,10 +113,15 @@ We collect anonymous usage data to help improve cagent. To disable: - Set environment variable: TELEMETRY_ENABLED=false `, feedback.FeedbackLink) - _, _ = os.Stderr.WriteString(startupMsg) + fmt.Fprint(stderr, startupMsg) } rootCmd := NewRootCmd() + rootCmd.SetArgs(args) + rootCmd.SetIn(stdin) + rootCmd.SetOut(stdout) + rootCmd.SetErr(stderr) + if err := rootCmd.ExecuteContext(ctx); err != nil { envErr := &environment.RequiredEnvError{} runtimeErr := RuntimeError{} @@ -160,18 +130,18 @@ We collect anonymous usage data to help improve cagent. To disable: case ctx.Err() != nil: return ctx.Err() case errors.As(err, &envErr): - fmt.Fprintln(os.Stderr, "The following environment variables must be set:") + fmt.Fprintln(stderr, "The following environment variables must be set:") for _, v := range envErr.Missing { - fmt.Fprintf(os.Stderr, " - %s\n", v) + fmt.Fprintf(stderr, " - %s\n", v) } - fmt.Fprintln(os.Stderr, "\nEither:\n - Set those environment variables before running cagent\n - Run cagent with --env-from-file\n - Store those secrets using one of the built-in environment variable providers.") + fmt.Fprintln(stderr, "\nEither:\n - Set those environment variables before running cagent\n - Run cagent with --env-from-file\n - Store those secrets using one of the built-in environment variable providers.") case errors.As(err, &runtimeErr): // Runtime errors have already been printed by the command itself // Don't print them again or show usage default: // Command line usage errors - show the error and usage - fmt.Fprintln(os.Stderr, err) - fmt.Fprintln(os.Stderr) + fmt.Fprintln(stderr, err) + fmt.Fprintln(stderr) if strings.HasPrefix(err.Error(), "unknown command ") || strings.HasPrefix(err.Error(), "accepts ") { _ = rootCmd.Usage() } @@ -186,9 +156,9 @@ We collect anonymous usage data to help improve cagent. To disable: // setupLogging configures slog logging behavior. // When --debug is enabled, logs are written to a single file /cagent.debug.log (append mode), // or to the file specified by --log-file. When in the TUI, structured logs are suppressed if not in --debug mode -func setupLogging(cmd *cobra.Command) error { +func (f *rootFlags) setupLogging(cmd *cobra.Command) error { level := slog.LevelInfo - if debugMode { + if f.debugMode { level = slog.LevelDebug } @@ -203,9 +173,9 @@ func setupLogging(cmd *cobra.Command) error { } var writer io.Writer - if debugMode { + if f.debugMode { // Determine path from flag or default to /cagent.debug.log - path := strings.TrimSpace(logFilePath) + path := strings.TrimSpace(f.logFilePath) if path == "" { dataDir := paths.GetDataDir() path = filepath.Join(dataDir, "cagent.debug.log") @@ -229,17 +199,17 @@ func setupLogging(cmd *cobra.Command) error { } // Open file for appending - f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) + logFile, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644) if err != nil { return err } - logFile = f + f.logFile = logFile // In debug mode, write to file; mirror to stderr when not in TUI if useTUI { - writer = f + writer = logFile } else { - writer = io.MultiWriter(f, os.Stderr) + writer = io.MultiWriter(logFile, os.Stderr) } } else { // Non-debug: discard logs in TUI to keep interface clean, else stderr @@ -253,3 +223,39 @@ func setupLogging(cmd *cobra.Command) error { slog.SetDefault(slog.New(slog.NewTextHandler(writer, &slog.HandlerOptions{Level: level}))) return nil } + +// RuntimeError wraps runtime errors to distinguish them from usage errors +type RuntimeError struct { + Err error +} + +func (e RuntimeError) Error() string { + return e.Err.Error() +} + +func (e RuntimeError) Unwrap() error { + return e.Err +} + +// isFirstRun checks if this is the first time cagent is being run +// It creates a marker file in the user's config directory +func isFirstRun() bool { + configDir := paths.GetConfigDir() + markerFile := filepath.Join(configDir, ".cagent_first_run") + + // Check if marker file exists + if _, err := os.Stat(markerFile); err == nil { + return false // File exists, not first run + } + + // Create marker file to indicate this run has happened + if err := os.MkdirAll(configDir, 0o755); err != nil { + return false // Can't create config dir, assume not first run + } + + if err := os.WriteFile(markerFile, []byte(""), 0o644); err != nil { + return false // Can't create marker file, assume not first run + } + + return true // Successfully created marker, this is first run +} diff --git a/cmd/root/run.go b/cmd/root/run.go index 5d1937f50..613dc2b2b 100644 --- a/cmd/root/run.go +++ b/cmd/root/run.go @@ -1,25 +1,20 @@ package root import ( - "bytes" "context" "fmt" "io" "log/slog" "os" - "path/filepath" - "strings" - "time" - tea "github.com/charmbracelet/bubbletea/v2" + tea "charm.land/bubbletea/v2" "github.com/spf13/cobra" "go.opentelemetry.io/otel" - "github.com/docker/cagent/pkg/aliases" + "github.com/docker/cagent/pkg/agentfile" "github.com/docker/cagent/pkg/app" "github.com/docker/cagent/pkg/cli" - "github.com/docker/cagent/pkg/content" - "github.com/docker/cagent/pkg/remote" + "github.com/docker/cagent/pkg/config" "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/team" @@ -28,19 +23,21 @@ import ( "github.com/docker/cagent/pkg/tui" ) -var ( - agentsDir string +type runExecFlags struct { + agentName string + workingDir string autoApprove bool attachmentPath string - workingDir string useTUI bool remoteAddress string - dryRun bool modelOverrides []string -) + dryRun bool + runConfig config.RuntimeConfig +} + +func newRunCmd() *cobra.Command { + var flags runExecFlags -// NewRunCmd creates a new run command -func NewRunCmd() *cobra.Command { cmd := &cobra.Command{ Use: "run [message|-]", Short: "Run an agent", @@ -50,311 +47,236 @@ func NewRunCmd() *cobra.Command { cagent run ./echo.yaml "INSTRUCTIONS" echo "INSTRUCTIONS" | cagent run ./echo.yaml -`, Args: cobra.RangeArgs(1, 2), - RunE: runCommand, + RunE: flags.runRunCommand, } - cmd.PersistentFlags().StringVarP(&agentName, "agent", "a", "root", "Name of the agent to run") - cmd.PersistentFlags().StringVar(&workingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)") - cmd.PersistentFlags().BoolVar(&autoApprove, "yolo", false, "Automatically approve all tool calls without prompting") - cmd.PersistentFlags().StringVar(&attachmentPath, "attach", "", "Attach an image file to the message") - cmd.PersistentFlags().BoolVar(&useTUI, "tui", true, "Run the agent with a Terminal User Interface (TUI)") - cmd.PersistentFlags().StringVar(&remoteAddress, "remote", "", "Use remote runtime with specified address (only supported with TUI)") - cmd.PersistentFlags().StringArrayVar(&modelOverrides, "model", nil, "Override agent model: [agent=]provider/model (repeatable)") - addGatewayFlags(cmd) - addRuntimeConfigFlags(cmd) + cmd.PersistentFlags().StringVarP(&flags.agentName, "agent", "a", "root", "Name of the agent to run") + cmd.PersistentFlags().StringVar(&flags.workingDir, "working-dir", "", "Set the working directory for the session (applies to tools and relative paths)") + cmd.PersistentFlags().BoolVar(&flags.autoApprove, "yolo", false, "Automatically approve all tool calls without prompting") + cmd.PersistentFlags().StringVar(&flags.attachmentPath, "attach", "", "Attach an image file to the message") + cmd.PersistentFlags().BoolVar(&flags.useTUI, "tui", true, "Run the agent with a Terminal User Interface (TUI)") + cmd.PersistentFlags().StringVar(&flags.remoteAddress, "remote", "", "Use remote runtime with specified address (only supported with TUI)") + cmd.PersistentFlags().StringArrayVar(&flags.modelOverrides, "model", nil, "Override agent model: [agent=]provider/model (repeatable)") + + addRuntimeConfigFlags(cmd, &flags.runConfig) return cmd } -func runCommand(cmd *cobra.Command, args []string) error { +func (f *runExecFlags) runRunCommand(cmd *cobra.Command, args []string) error { telemetry.TrackCommand("run", args) - return doRunCommand(cmd.Context(), args, false) -} -func execCommand(cmd *cobra.Command, args []string) error { - telemetry.TrackCommand("exec", args) - return doRunCommand(cmd.Context(), args, true) -} + ctx := cmd.Context() + out := cli.NewPrinter(cmd.OutOrStdout()) -func doRunCommand(ctx context.Context, args []string, exec bool) error { - slog.Debug("Starting agent", "agent", agentName, "debug_mode", debugMode) + return f.runOrExec(ctx, out, args, false) +} - agentFilename := args[0] +func (f *runExecFlags) runOrExec(ctx context.Context, out *cli.Printer, args []string, exec bool) error { + slog.Debug("Starting agent", "agent", f.agentName) - // Try to resolve as an alias first - if aliasStore, err := aliases.Load(); err == nil { - if resolvedPath, ok := aliasStore.Get(agentFilename); ok { - slog.Debug("Resolved alias", "alias", agentFilename, "path", resolvedPath) - agentFilename = resolvedPath - } + if err := f.validateRemoteFlag(exec); err != nil { + return err } - if !strings.Contains(agentFilename, "\n") && (strings.Contains(agentFilename, ".yaml") || strings.Contains(agentFilename, ".yml")) { - if abs, err := filepath.Abs(agentFilename); err == nil { - agentFilename = abs - } + if err := f.setupWorkingDirectory(); err != nil { + return err } - if enableOtel { - shutdown, err := initOTelSDK(ctx) - if err != nil { - slog.Warn("Failed to initialize OpenTelemetry SDK", "error", err) - } else if shutdown != nil { - defer func() { - shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) - defer cancel() - if err := shutdown(shutdownCtx); err != nil { - slog.Warn("Failed to shutdown OpenTelemetry SDK", "error", err) - } - }() - slog.Debug("OpenTelemetry SDK initialized successfully") - } - } + agentFileName := "" - // If working-dir was provided, validate and change process working directory - if workingDir != "" { - absWd, err := filepath.Abs(workingDir) + var rt runtime.Runtime + var sess *session.Session + var err error + if f.remoteAddress != "" { + rt, sess, err = f.createRemoteRuntimeAndSession(ctx, args[0]) if err != nil { - return fmt.Errorf("invalid working directory: %w", err) - } - info, err := os.Stat(absWd) - if err != nil || !info.IsDir() { - return fmt.Errorf("working directory does not exist or is not a directory: %s", absWd) - } - if err := os.Chdir(absWd); err != nil { - return fmt.Errorf("failed to change working directory: %w", err) + return err } - _ = os.Setenv("PWD", absWd) - slog.Debug("Working directory set", "dir", absWd) - } - - // Skip agent file loading when using remote runtime - var agents *team.Team - var err error - if remoteAddress == "" { - // Determine how to obtain the agent definition - ext := strings.ToLower(filepath.Ext(agentFilename)) - if ext == ".yaml" || ext == ".yml" || strings.HasPrefix(agentFilename, "/dev/fd/") { - // Treat as local YAML file: resolve to absolute path so later chdir doesn't break it - if !strings.Contains(agentFilename, "\n") { - if abs, err := filepath.Abs(agentFilename); err == nil { - agentFilename = abs - } - } - if !fileExists(agentFilename) { - return fmt.Errorf("agent file not found: %s", agentFilename) - } - } else { - // Treat as an OCI image reference. Try local store first, otherwise pull then load. - a, err := fromStore(agentFilename) - if err != nil { - fmt.Println("Pulling agent", agentFilename) - if _, pullErr := remote.Pull(agentFilename); pullErr != nil { - return fmt.Errorf("failed to pull OCI image %s: %w", agentFilename, pullErr) - } - // Retry after pull - a, err = fromStore(agentFilename) - if err != nil { - return fmt.Errorf("failed to load agent from store after pull: %w", err) - } - } - - // Write the fetched content to a temporary YAML file - tmpFile, err := os.CreateTemp("", "agentfile-*.yaml") - if err != nil { - return err - } - defer os.Remove(tmpFile.Name()) - if _, err := tmpFile.WriteString(a); err != nil { - tmpFile.Close() - return err - } - if err := tmpFile.Close(); err != nil { - return err - } - agentFilename = tmpFile.Name() + } else { + agentFileName, err = f.resolveAgentFile(ctx, out, args[0]) + if err != nil { + return err } - if runConfig.RedirectURI == "" { - runConfig.RedirectURI = "http://localhost:8083/oauth-callback" + t, err := f.loadAgents(ctx, agentFileName) + if err != nil { + return err } - agents, err = teamloader.Load(ctx, agentFilename, runConfig, teamloader.WithModelOverrides(modelOverrides)) + rt, sess, err = f.createLocalRuntimeAndSession(t) if err != nil { return err } - defer func() { - if err := agents.StopToolSets(ctx); err != nil { - slog.Error("Failed to stop tool sets", "error", err) - } - }() - } else { - // For remote runtime, just store the original agent filename - // The remote server will handle agent loading - slog.Debug("Skipping local agent file loading for remote runtime", "filename", agentFilename) } - // Validate remote flag usage - if remoteAddress != "" && (!useTUI || exec) { - return fmt.Errorf("--remote flag can only be used with TUI mode") + if exec { + return f.handleExecMode(ctx, out, agentFileName, rt, sess, args) } - tracer := otel.Tracer(AppName) + if !f.useTUI { + return f.handleCLIMode(ctx, out, agentFileName, rt, sess, args) + } - var sess *session.Session + return handleTUIMode(ctx, agentFileName, rt, sess, args) +} - // Create runtime based on whether remote flag is specified - var rt runtime.Runtime - if remoteAddress != "" && useTUI && !exec { - // Create remote runtime for TUI mode - remoteClient, err := runtime.NewClient(remoteAddress) - if err != nil { - return fmt.Errorf("failed to create remote client: %w", err) - } +func (f *runExecFlags) setupWorkingDirectory() error { + return setupWorkingDirectory(f.workingDir) +} - sessTemplate := session.New() - sessTemplate.ToolsApproved = autoApprove - sess, err = remoteClient.CreateSession(ctx, sessTemplate) - if err != nil { - return err - } +// resolveAgentFile is a wrapper method that calls the agentfile.Resolve function +// after checking for remote address +func (f *runExecFlags) resolveAgentFile(ctx context.Context, out *cli.Printer, agentFilename string) (string, error) { + if f.remoteAddress != "" { + return agentFilename, nil + } + return agentfile.Resolve(ctx, out, agentFilename) +} - remoteRt, err := runtime.NewRemoteRuntime(remoteClient, - runtime.WithRemoteCurrentAgent(agentName), - runtime.WithRemoteAgentFilename(args[0]), - ) - if err != nil { - return fmt.Errorf("failed to create remote runtime: %w", err) - } - rt = remoteRt - slog.Debug("Using remote runtime", "address", remoteAddress, "agent", agentName) - } else { - agent, err := agents.Agent(agentName) - if err != nil { - return err +func (f *runExecFlags) loadAgents(ctx context.Context, agentFilename string) (*team.Team, error) { + t, err := teamloader.Load(ctx, agentFilename, f.runConfig, teamloader.WithModelOverrides(f.modelOverrides)) + if err != nil { + return nil, err + } + + go func() { + <-ctx.Done() + if err := t.StopToolSets(ctx); err != nil { + slog.Error("Failed to stop tool sets", "error", err) } + }() - // Create session first to get its ID for OAuth state encoding - sess = session.New(session.WithMaxIterations(agent.MaxIterations())) - sess.ToolsApproved = autoApprove + return t, nil +} - // Create local runtime with root session ID for OAuth state encoding - localRt, err := runtime.New(agents, - runtime.WithCurrentAgent(agentName), - runtime.WithTracer(tracer), - runtime.WithRootSessionID(sess.ID), - ) - if err != nil { - return fmt.Errorf("failed to create runtime: %w", err) - } - rt = localRt - slog.Debug("Using local runtime", "agent", agentName) +func (f *runExecFlags) validateRemoteFlag(exec bool) error { + if f.remoteAddress != "" && (!f.useTUI || exec) { + return fmt.Errorf("--remote flag can only be used with TUI mode") } + return nil +} - // For `cagent exec` - if exec { - execArgs := []string{"exec"} - if len(args) == 2 { - execArgs = append(execArgs, args[1]) - } else { - execArgs = append(execArgs, "Follow the default instructions") - } +func (f *runExecFlags) createRemoteRuntimeAndSession(ctx context.Context, originalFilename string) (runtime.Runtime, *session.Session, error) { + remoteClient, err := runtime.NewClient(f.remoteAddress) + if err != nil { + return nil, nil, fmt.Errorf("failed to create remote client: %w", err) + } - if dryRun { - fmt.Println("Dry run mode enabled. Agent initialized but will not execute.") - return nil - } - err := cli.Run(ctx, cli.Config{ - AppName: AppName, - AttachmentPath: attachmentPath, - }, agentFilename, rt, sess, execArgs) - if cliErr, ok := err.(cli.RuntimeError); ok { - return RuntimeError{Err: cliErr.Err} - } - return err + sessTemplate := session.New( + session.WithToolsApproved(f.autoApprove), + ) + + sess, err := remoteClient.CreateSession(ctx, sessTemplate) + if err != nil { + return nil, nil, err } - // For `cagent run --tui=false` - if !useTUI { - err := cli.Run(ctx, cli.Config{ - AppName: AppName, - AttachmentPath: attachmentPath, - }, agentFilename, rt, sess, args) - if cliErr, ok := err.(cli.RuntimeError); ok { - return RuntimeError{Err: cliErr.Err} - } - return err + remoteRt, err := runtime.NewRemoteRuntime(remoteClient, + runtime.WithRemoteCurrentAgent(f.agentName), + runtime.WithRemoteAgentFilename(originalFilename), + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to create remote runtime: %w", err) } - // The default is to use the TUI - var firstMessage *string - if len(args) == 2 { - // TODO: attachments - if args[1] == "-" { - buf, err := io.ReadAll(os.Stdin) - if err != nil { - return fmt.Errorf("failed to read from stdin: %w", err) - } - text := string(buf) - firstMessage = &text - } else { - firstMessage = &args[1] - } + slog.Debug("Using remote runtime", "address", f.remoteAddress, "agent", f.agentName) + return remoteRt, sess, nil +} + +func (f *runExecFlags) createLocalRuntimeAndSession(t *team.Team) (runtime.Runtime, *session.Session, error) { + agent, err := t.Agent(f.agentName) + if err != nil { + return nil, nil, err } - a := app.New("cagent", agentFilename, rt, agents, sess, firstMessage) - m := tui.New(a) + sess := session.New( + session.WithMaxIterations(agent.MaxIterations()), + session.WithToolsApproved(f.autoApprove), + ) - progOpts := []tea.ProgramOption{ - tea.WithAltScreen(), - tea.WithContext(ctx), - tea.WithFilter(tui.MouseEventFilter), - tea.WithMouseCellMotion(), - tea.WithMouseAllMotion(), + localRt, err := runtime.New(t, + runtime.WithCurrentAgent(f.agentName), + runtime.WithTracer(otel.Tracer(AppName)), + runtime.WithRootSessionID(sess.ID), + ) + if err != nil { + return nil, nil, fmt.Errorf("failed to create runtime: %w", err) } - p := tea.NewProgram(m, progOpts...) + slog.Debug("Using local runtime", "agent", f.agentName) + return localRt, sess, nil +} - go a.Subscribe(ctx, p) +func (f *runExecFlags) handleExecMode(ctx context.Context, out *cli.Printer, agentFilename string, rt runtime.Runtime, sess *session.Session, args []string) error { + execArgs := []string{"exec"} + if len(args) == 2 { + execArgs = append(execArgs, args[1]) + } else { + execArgs = append(execArgs, "Follow the default instructions") + } - _, err = p.Run() + if f.dryRun { + out.Println("Dry run mode enabled. Agent initialized but will not execute.") + return nil + } + + err := cli.Run(ctx, out, cli.Config{ + AppName: AppName, + AttachmentPath: f.attachmentPath, + }, agentFilename, rt, sess, execArgs) + if cliErr, ok := err.(cli.RuntimeError); ok { + return RuntimeError{Err: cliErr.Err} + } return err } -func fileExists(path string) bool { - _, err := os.Stat(path) - exists := err == nil - return exists +func (f *runExecFlags) handleCLIMode(ctx context.Context, out *cli.Printer, agentFilename string, rt runtime.Runtime, sess *session.Session, args []string) error { + err := cli.Run(ctx, out, cli.Config{ + AppName: AppName, + AttachmentPath: f.attachmentPath, + }, agentFilename, rt, sess, args) + if cliErr, ok := err.(cli.RuntimeError); ok { + return RuntimeError{Err: cliErr.Err} + } + return err } -func fromStore(reference string) (string, error) { - store, err := content.NewStore() - if err != nil { - return "", err +func readInitialMessage(args []string) (*string, error) { + if len(args) < 2 { + return nil, nil } - img, err := store.GetArtifactImage(reference) - if err != nil { - return "", err + if args[1] == "-" { + buf, err := io.ReadAll(os.Stdin) + if err != nil { + return nil, fmt.Errorf("failed to read from stdin: %w", err) + } + text := string(buf) + return &text, nil } - layers, err := img.Layers() - if err != nil { - return "", err - } + return &args[1], nil +} - var buf bytes.Buffer - layer := layers[0] - b, err := layer.Uncompressed() +func handleTUIMode(ctx context.Context, agentFilename string, rt runtime.Runtime, sess *session.Session, args []string) error { + firstMessage, err := readInitialMessage(args) if err != nil { - return "", err + return err } - _, err = io.Copy(&buf, b) - if err != nil { - return "", err + a := app.New(agentFilename, rt, sess, firstMessage) + m := tui.New(a) + + progOpts := []tea.ProgramOption{ + tea.WithContext(ctx), + tea.WithFilter(tui.MouseEventFilter), } - b.Close() - return buf.String(), nil + p := tea.NewProgram(m, progOpts...) + + go a.Subscribe(ctx, p) + + _, err = p.Run() + return err } diff --git a/cmd/root/run_test.go b/cmd/root/run_test.go new file mode 100644 index 000000000..ea23911f6 --- /dev/null +++ b/cmd/root/run_test.go @@ -0,0 +1,266 @@ +package root + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/pkg/agentfile" + "github.com/docker/cagent/pkg/content" + "github.com/docker/cagent/pkg/oci" +) + +func TestOciRefToFilename(t *testing.T) { + tests := []struct { + name string + ociRef string + expected string + }{ + { + name: "simple reference", + ociRef: "myagent", + expected: "myagent.yaml", + }, + { + name: "reference with registry and tag", + ociRef: "docker.io/myorg/agent:v1", + expected: "docker.io_myorg_agent_v1.yaml", + }, + { + name: "localhost with port", + ociRef: "localhost:5000/test", + expected: "localhost_5000_test.yaml", + }, + { + name: "reference with digest", + ociRef: "myregistry.io/org/app@sha256:abc123", + expected: "myregistry.io_org_app_sha256_abc123.yaml", + }, + { + name: "already has .yaml extension", + ociRef: "myagent.yaml", + expected: "myagent.yaml", + }, + { + name: "complex path", + ociRef: "registry.example.com:443/project/subproject/agent:latest", + expected: "registry.example.com_443_project_subproject_agent_latest.yaml", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := agentfile.OciRefToFilename(tt.ociRef) + assert.Equal(t, tt.expected, result) + }) + } +} + +func TestResolveAgentFile_LocalFile(t *testing.T) { + tmpDir := t.TempDir() + yamlFile := filepath.Join(tmpDir, "test-agent.yaml") + yamlContent := `version: "1" +agents: + root: + model: openai/gpt-4o + description: Test agent + instruction: You are a test agent +` + require.NoError(t, os.WriteFile(yamlFile, []byte(yamlContent), 0o644)) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + resolved, err := agentfile.Resolve(ctx, nil, yamlFile) + require.NoError(t, err) + + // Should return absolute path + absPath, err := filepath.Abs(yamlFile) + require.NoError(t, err) + assert.Equal(t, absPath, resolved) +} + +func TestResolveAgentFile_OCIRef_ConsistentFilename(t *testing.T) { + storeDir := t.TempDir() + t.Setenv("CAGENT_CONTENT_STORE", storeDir) + + store, err := content.NewStore() + require.NoError(t, err) + + agentContent := `version: "1" +agents: + root: + model: openai/gpt-4o + description: Test OCI agent + instruction: You are a test OCI agent +` + agentFile := filepath.Join(t.TempDir(), "oci-agent.yaml") + require.NoError(t, os.WriteFile(agentFile, []byte(agentContent), 0o644)) + + // Package as OCI artifact + ociRef := "test.registry.io/myorg/testagent:v1" + _, err = oci.PackageFileAsOCIToStore(agentFile, ociRef, store) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + // First resolution + resolved1, err := agentfile.Resolve(ctx, nil, ociRef) + require.NoError(t, err) + assert.NotEmpty(t, resolved1) + + content1, err := os.ReadFile(resolved1) + require.NoError(t, err) + assert.Equal(t, agentContent, string(content1)) + + // Expected filename based on OCI ref + expectedFilename := agentfile.OciRefToFilename(ociRef) + assert.Equal(t, expectedFilename, filepath.Base(resolved1)) + + // Store the first resolved path + firstResolvedPath := resolved1 + + // Second resolution (simulating a reload) + resolved2, err := agentfile.Resolve(ctx, nil, ociRef) + require.NoError(t, err) + + // Should return the SAME filename + assert.Equal(t, resolved1, resolved2, "Subsequent resolutions should return the same file path") + assert.Equal(t, filepath.Base(resolved1), filepath.Base(resolved2), "Filenames should be identical") + + content2, err := os.ReadFile(resolved2) + require.NoError(t, err) + assert.Equal(t, agentContent, string(content2)) + + // Update the agent content in the OCI store + updatedContent := `version: "1" +agents: + root: + model: openai/gpt-4o-mini + description: Updated test OCI agent + instruction: You are an updated test OCI agent +` + updatedFile := filepath.Join(t.TempDir(), "updated-agent.yaml") + require.NoError(t, os.WriteFile(updatedFile, []byte(updatedContent), 0o644)) + _, err = oci.PackageFileAsOCIToStore(updatedFile, ociRef, store) + require.NoError(t, err) + + // Third resolution (simulating reload after update) + resolved3, err := agentfile.Resolve(ctx, nil, ociRef) + require.NoError(t, err) + + // Should STILL use the same filename + assert.Equal(t, firstResolvedPath, resolved3, "Even after OCI update, should use same file path") + + // But content should be updated + content3, err := os.ReadFile(resolved3) + require.NoError(t, err) + assert.Equal(t, updatedContent, string(content3), "Content should be updated from OCI store") +} + +func TestResolveAgentFile_MultipleOCIRefs_DifferentFilenames(t *testing.T) { + storeDir := t.TempDir() + t.Setenv("CAGENT_CONTENT_STORE", storeDir) + + store, err := content.NewStore() + require.NoError(t, err) + + agent1Content := `version: "1" +agents: + root: + model: openai/gpt-4o + description: Agent 1 + instruction: You are agent 1 +` + agent2Content := `version: "1" +agents: + root: + model: anthropic/claude-sonnet-4-0 + description: Agent 2 + instruction: You are agent 2 +` + + agent1File := filepath.Join(t.TempDir(), "agent1.yaml") + agent2File := filepath.Join(t.TempDir(), "agent2.yaml") + require.NoError(t, os.WriteFile(agent1File, []byte(agent1Content), 0o644)) + require.NoError(t, os.WriteFile(agent2File, []byte(agent2Content), 0o644)) + + // Package as different OCI artifacts + ociRef1 := "test.io/org/agent1:v1" + ociRef2 := "test.io/org/agent2:v1" + _, err = oci.PackageFileAsOCIToStore(agent1File, ociRef1, store) + require.NoError(t, err) + _, err = oci.PackageFileAsOCIToStore(agent2File, ociRef2, store) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + + // Resolve both OCI refs + resolved1, err := agentfile.Resolve(ctx, nil, ociRef1) + require.NoError(t, err) + + resolved2, err := agentfile.Resolve(ctx, nil, ociRef2) + require.NoError(t, err) + + // Should have DIFFERENT filenames + assert.NotEqual(t, resolved1, resolved2, "Different OCI refs should produce different file paths") + assert.NotEqual(t, filepath.Base(resolved1), filepath.Base(resolved2), "Different OCI refs should produce different filenames") + + content1, err := os.ReadFile(resolved1) + require.NoError(t, err) + assert.Equal(t, agent1Content, string(content1)) + + content2, err := os.ReadFile(resolved2) + require.NoError(t, err) + assert.Equal(t, agent2Content, string(content2)) + + assert.Equal(t, agentfile.OciRefToFilename(ociRef1), filepath.Base(resolved1)) + assert.Equal(t, agentfile.OciRefToFilename(ociRef2), filepath.Base(resolved2)) +} + +func TestResolveAgentFile_ContextCancellation(t *testing.T) { + storeDir := t.TempDir() + t.Setenv("CAGENT_CONTENT_STORE", storeDir) + + store, err := content.NewStore() + require.NoError(t, err) + + agentContent := `version: "1" +agents: + root: + model: openai/gpt-4o + description: Test agent + instruction: You are a test agent +` + agentFile := filepath.Join(t.TempDir(), "agent.yaml") + require.NoError(t, os.WriteFile(agentFile, []byte(agentContent), 0o644)) + + // Package as OCI artifact + ociRef := "test.io/cleanup/agent:v1" + _, err = oci.PackageFileAsOCIToStore(agentFile, ociRef, store) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(t.Context()) + + // Resolve the OCI ref + resolved, err := agentfile.Resolve(ctx, nil, ociRef) + require.NoError(t, err) + assert.FileExists(t, resolved) + + // Cancel the context to trigger cleanup + cancel() + + // Give the cleanup goroutine time to execute + time.Sleep(100 * time.Millisecond) + + // File should be deleted + _, err = os.Stat(resolved) + assert.True(t, os.IsNotExist(err), "File should be cleaned up after context cancellation") +} diff --git a/cmd/root/version.go b/cmd/root/version.go index 5309947c0..3a039a585 100644 --- a/cmd/root/version.go +++ b/cmd/root/version.go @@ -9,19 +9,20 @@ import ( "github.com/docker/cagent/pkg/version" ) -// NewVersionCmd creates a new version command -func NewVersionCmd() *cobra.Command { +func newVersionCmd() *cobra.Command { return &cobra.Command{ Use: "version", Short: "Print the version information", Long: `Display the version, build time, and commit hash`, Args: cobra.NoArgs, - Run: func(cmd *cobra.Command, args []string) { - // Track the version command - telemetry.TrackCommand("version", args) - - fmt.Printf("cagent version %s\n", version.Version) - fmt.Printf("Commit: %s\n", version.Commit) - }, + Run: runVersionCommand, } } + +func runVersionCommand(cmd *cobra.Command, args []string) { + telemetry.TrackCommand("version", args) + + out := cmd.OutOrStdout() + fmt.Fprintf(out, "cagent version %s\n", version.Version) + fmt.Fprintf(out, "Commit: %s\n", version.Commit) +} diff --git a/diff.txt b/diff.txt new file mode 100644 index 000000000..8032cc449 --- /dev/null +++ b/diff.txt @@ -0,0 +1,626 @@ +diff --git a/pkg/runtime/event.go b/pkg/runtime/event.go +index 726e8e0..9816cab 100644 +--- a/pkg/runtime/event.go ++++ b/pkg/runtime/event.go +@@ -183,8 +183,16 @@ func Warning(message, agentName string) Event { + } + + type TokenUsageEvent struct { +- Type string `json:"type"` +- Usage *Usage `json:"usage"` ++ // Type stays "token_usage" for backward compatibility with existing clients. ++ Type string `json:"type"` ++ // SessionID lets consumers correlate usage snapshots with a specific session/sub-session. ++ SessionID string `json:"session_id"` ++ // Usage retains the legacy aggregate payload so older UIs do not break immediately. ++ Usage *Usage `json:"usage,omitempty"` ++ // SelfUsage captures the tokens/cost generated directly by the emitting session. ++ SelfUsage *Usage `json:"self_usage,omitempty"` ++ // InclusiveUsage represents the session plus any merged child usage for team totals. ++ InclusiveUsage *Usage `json:"inclusive_usage,omitempty"` + AgentContext + } + +@@ -196,16 +204,27 @@ type Usage struct { + Cost float64 `json:"cost"` + } + +-func TokenUsage(inputTokens, outputTokens, contextLength, contextLimit int, cost float64) Event { ++func TokenUsage(sessionID, agentName string, selfUsage, inclusiveUsage *Usage) Event { ++ if selfUsage == nil && inclusiveUsage == nil { ++ return &TokenUsageEvent{Type: "token_usage"} ++ } ++ ++ // Default to inclusive usage when only one snapshot is provided. ++ if selfUsage == nil { ++ selfUsage = inclusiveUsage ++ } ++ if inclusiveUsage == nil { ++ inclusiveUsage = selfUsage ++ } ++ ++ // Emit both snapshots so the UI can show per-session and team totals simultaneously. + return &TokenUsageEvent{ +- Type: "token_usage", +- Usage: &Usage{ +- ContextLength: contextLength, +- ContextLimit: contextLimit, +- InputTokens: inputTokens, +- OutputTokens: outputTokens, +- Cost: cost, +- }, ++ Type: "token_usage", ++ SessionID: sessionID, ++ Usage: inclusiveUsage, ++ SelfUsage: selfUsage, ++ InclusiveUsage: inclusiveUsage, ++ AgentContext: AgentContext{AgentName: agentName}, + } + } + +diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go +index 565b32f..0bd3f57 100644 +--- a/pkg/runtime/runtime.go ++++ b/pkg/runtime/runtime.go +@@ -7,6 +7,7 @@ import ( + "fmt" + "io" + "log/slog" ++ "os" + "strings" + "sync" + "time" +@@ -28,6 +29,8 @@ import ( + "github.com/docker/cagent/pkg/tools/builtin" + ) + ++const tokenUsageLogFile = "token_usage_chunks.log" ++ + type ResumeType string + + type modelStore interface { +@@ -375,7 +378,10 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c + if m != nil { + contextLimit = m.Limit.Context + } +- events <- TokenUsage(sess.InputTokens, sess.OutputTokens, sess.InputTokens+sess.OutputTokens, contextLimit, sess.Cost) ++ // Emit a snapshot that downstream components can use for both self and inclusive totals. ++ inclusiveUsage := buildInclusiveUsageSnapshot(sess, contextLimit) ++ selfUsage := buildSelfUsageSnapshot(sess, contextLimit) ++ events <- TokenUsage(sess.ID, a.Name(), selfUsage, inclusiveUsage) + + if m != nil && r.sessionCompaction { + if sess.InputTokens+sess.OutputTokens > int(float64(contextLimit)*0.9) { +@@ -384,7 +390,10 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c + if len(res.Calls) == 0 { + events <- SessionCompaction(sess.ID, "start", r.currentAgent) + r.Summarize(ctx, sess, events) +- events <- TokenUsage(sess.InputTokens, sess.OutputTokens, sess.InputTokens+sess.OutputTokens, contextLimit, sess.Cost) ++ // Refresh usage after compaction since token counts may have changed. ++ inclusiveUsage := buildInclusiveUsageSnapshot(sess, contextLimit) ++ selfUsage := buildSelfUsageSnapshot(sess, contextLimit) ++ events <- TokenUsage(sess.ID, a.Name(), selfUsage, inclusiveUsage) + events <- SessionCompaction(sess.ID, "completed", r.currentAgent) + } + } +@@ -398,7 +407,10 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c + if sess.InputTokens+sess.OutputTokens > int(float64(contextLimit)*0.9) { + events <- SessionCompaction(sess.ID, "start", r.currentAgent) + r.Summarize(ctx, sess, events) +- events <- TokenUsage(sess.InputTokens, sess.OutputTokens, sess.InputTokens+sess.OutputTokens, contextLimit, sess.Cost) ++ // Emit the post-compaction snapshot as well for consistency. ++ inclusiveUsage := buildInclusiveUsageSnapshot(sess, contextLimit) ++ selfUsage := buildSelfUsageSnapshot(sess, contextLimit) ++ events <- TokenUsage(sess.ID, a.Name(), selfUsage, inclusiveUsage) + events <- SessionCompaction(sess.ID, "completed", r.currentAgent) + } + } +@@ -538,15 +550,27 @@ func (r *LocalRuntime) handleStream(ctx context.Context, stream chat.MessageStre + } + + if response.Usage != nil { ++ childInputTotal, childOutputTotal := childTokenTotals(sess) ++ ++ selfInput := response.Usage.InputTokens + response.Usage.CachedInputTokens ++ selfOutput := response.Usage.OutputTokens + response.Usage.CachedOutputTokens + response.Usage.ReasoningTokens ++ ++ logTokenUsageChunk(sess.ID, a.Name(), response.Usage) ++ ++ var callCost float64 + if m != nil { +- sess.Cost += (float64(response.Usage.InputTokens)*m.Cost.Input + ++ callCost = (float64(response.Usage.InputTokens)*m.Cost.Input + + float64(response.Usage.OutputTokens+response.Usage.ReasoningTokens)*m.Cost.Output + + float64(response.Usage.CachedInputTokens)*m.Cost.CacheRead + + float64(response.Usage.CachedOutputTokens)*m.Cost.CacheWrite) / 1e6 ++ sess.Cost += callCost + } + +- sess.InputTokens = response.Usage.InputTokens + response.Usage.CachedInputTokens +- sess.OutputTokens = response.Usage.OutputTokens + response.Usage.CachedOutputTokens + response.Usage.ReasoningTokens ++ sess.SelfCost = callCost ++ sess.SelfInputTokens = selfInput ++ sess.SelfOutputTokens = selfOutput ++ sess.InputTokens = childInputTotal + selfInput ++ sess.OutputTokens = childOutputTotal + selfOutput + + modelName := "unknown" + if m != nil { +@@ -658,6 +682,66 @@ func (r *LocalRuntime) handleStream(ctx context.Context, stream chat.MessageStre + }, nil + } + ++// buildInclusiveUsageSnapshot captures the session's current inclusive usage in the shared event format. ++func buildInclusiveUsageSnapshot(sess *session.Session, contextLimit int) *Usage { ++ return &Usage{ ++ ContextLength: sess.InputTokens + sess.OutputTokens, ++ ContextLimit: contextLimit, ++ InputTokens: sess.InputTokens, ++ OutputTokens: sess.OutputTokens, ++ Cost: sess.Cost, ++ } ++} ++ ++func buildSelfUsageSnapshot(sess *session.Session, contextLimit int) *Usage { ++ return &Usage{ ++ ContextLength: sess.SelfInputTokens + sess.SelfOutputTokens, ++ ContextLimit: contextLimit, ++ InputTokens: sess.SelfInputTokens, ++ OutputTokens: sess.SelfOutputTokens, ++ Cost: sess.SelfCost, ++ } ++} ++ ++func childTokenTotals(sess *session.Session) (int, int) { ++ childInput := sess.InputTokens - sess.SelfInputTokens ++ if childInput < 0 { ++ childInput = 0 ++ } ++ childOutput := sess.OutputTokens - sess.SelfOutputTokens ++ if childOutput < 0 { ++ childOutput = 0 ++ } ++ return childInput, childOutput ++} ++ ++func logTokenUsageChunk(sessionID, agentName string, usage *chat.Usage) { ++ if usage == nil { ++ return ++ } ++ entry := fmt.Sprintf("%s session=%s agent=%s input=%d output=%d cached_input=%d cached_output=%d reasoning=%d\n", ++ time.Now().Format(time.RFC3339Nano), ++ sessionID, ++ agentName, ++ usage.InputTokens, ++ usage.OutputTokens, ++ usage.CachedInputTokens, ++ usage.CachedOutputTokens, ++ usage.ReasoningTokens, ++ ) ++ ++ file, err := os.OpenFile(tokenUsageLogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) ++ if err != nil { ++ slog.Warn("Failed to open token usage log file", "error", err) ++ return ++ } ++ defer file.Close() ++ ++ if _, err := file.WriteString(entry); err != nil { ++ slog.Warn("Failed to write token usage log entry", "error", err) ++ } ++} ++ + // processToolCalls handles the execution of tool calls for an agent + func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Session, calls []tools.ToolCall, agentTools []tools.Tool, events chan Event) { + a := r.CurrentAgent() +@@ -979,10 +1063,40 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses + } + + sess.ToolsApproved = s.ToolsApproved ++ parentCostBefore := sess.Cost // capture parent values for debug logging ++ parentInputBefore := sess.InputTokens ++ parentOutputBefore := sess.OutputTokens ++ + sess.Cost += s.Cost ++ // Mirror cost behavior: once the child finishes, fold its token usage into the parent totals. ++ childInputTotal, childOutputTotal := childTokenTotals(sess) ++ childInputTotal += s.InputTokens ++ childOutputTotal += s.OutputTokens ++ sess.InputTokens = childInputTotal + sess.SelfInputTokens ++ sess.OutputTokens = childOutputTotal + sess.SelfOutputTokens ++ ++ slog.Debug("Merged sub-session usage into parent", ++ "parent_session_id", sess.ID, ++ "child_session_id", s.ID, ++ "parent_cost_before", parentCostBefore, ++ "parent_cost_after", sess.Cost, ++ "child_cost", s.Cost, ++ "parent_input_before", parentInputBefore, ++ "parent_input_after", sess.InputTokens, ++ "child_input", s.InputTokens, ++ "parent_output_before", parentOutputBefore, ++ "parent_output_after", sess.OutputTokens, ++ "child_output", s.OutputTokens, ++ ) + + sess.AddSubSession(s) + ++ // Emit an updated token usage snapshot so the UI sees the merged totals immediately. ++ inclusiveUsage := buildInclusiveUsageSnapshot(sess, 0) ++ selfUsage := buildSelfUsageSnapshot(sess, 0) ++ parentAgentName := ca ++ evts <- TokenUsage(sess.ID, parentAgentName, selfUsage, inclusiveUsage) ++ + slog.Debug("Task transfer completed", "agent", params.Agent, "task", params.Task) + + span.SetStatus(codes.Ok, "task transfer completed") +diff --git a/pkg/session/session.go b/pkg/session/session.go +index b6084cb..75d0881 100644 +--- a/pkg/session/session.go ++++ b/pkg/session/session.go +@@ -68,6 +68,10 @@ type Session struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + Cost float64 `json:"cost"` ++ // Self* fields track the most recent provider-reported usage for this session only (no children). ++ SelfInputTokens int `json:"self_input_tokens"` ++ SelfOutputTokens int `json:"self_output_tokens"` ++ SelfCost float64 `json:"self_cost"` + } + + // Message is a message from an agent +diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go +index 4b1dd45..c125255 100644 +--- a/pkg/tui/components/sidebar/sidebar.go ++++ b/pkg/tui/components/sidebar/sidebar.go +@@ -3,11 +3,13 @@ package sidebar + import ( + "fmt" + "os" ++ "sort" // ensure deterministic breakdown ordering + "strings" + + "github.com/charmbracelet/bubbles/v2/spinner" + tea "github.com/charmbracelet/bubbletea/v2" + "github.com/charmbracelet/lipgloss/v2" ++ "github.com/dustin/go-humanize" // provides comma-separated number formatting + + "github.com/docker/cagent/pkg/runtime" + "github.com/docker/cagent/pkg/tools" +@@ -24,11 +26,11 @@ const ( + ) + + // Model represents a sidebar component +-type Model interface { ++type Model interface { // interface defines sidebar contract + layout.Model + layout.Sizeable + +- SetTokenUsage(usage *runtime.Usage) ++ SetTokenUsage(event *runtime.TokenUsageEvent) // accepts enriched runtime events for usage tracking + SetTodos(toolCall tools.ToolCall) error + SetWorking(working bool) tea.Cmd + SetMode(mode Mode) +@@ -36,26 +38,38 @@ type Model interface { + } + + // model implements Model +-type model struct { +- width int +- height int +- usage *runtime.Usage +- todoComp *todo.Component +- working bool +- mcpInit bool +- spinner spinner.Model +- mode Mode +- sessionTitle string ++type model struct { // tea model for sidebar component ++ width int // viewport width ++ height int // viewport height ++ usageState usageState // aggregated usage tracking state ++ todoComp *todo.Component // embedded todo component ++ working bool // indicates if runtime is working ++ mcpInit bool // indicates MCP initialization state ++ spinner spinner.Model // spinner for busy indicator ++ mode Mode // layout mode ++ sessionTitle string // current session title ++} ++ ++type usageState struct { // holds all token usage snapshots for sidebar ++ sessions map[string]*runtime.Usage // per-session self usage snapshots ++ sessionAgents map[string]string // optional agent name mapping per session ++ rootInclusive *runtime.Usage // inclusive usage snapshot emitted by root ++ rootSessionID string // session ID associated with root agent ++ rootAgentName string // resolved root agent name for comparisons ++ activeSessionID string // currently active session ID for highlighting + } + + func New() Model { + return &model{ +- width: 20, +- height: 24, +- usage: &runtime.Usage{}, +- todoComp: todo.NewComponent(), +- spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), +- sessionTitle: "New session", ++ width: 20, // default width matches initial layout ++ height: 24, // default height matches initial layout ++ usageState: usageState{ // initialize usage tracking containers ++ sessions: make(map[string]*runtime.Usage), // allocate map to avoid nil lookups ++ sessionAgents: make(map[string]string), // track agent names per session ++ }, ++ todoComp: todo.NewComponent(), // instantiate todo component ++ spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), // configure spinner visuals ++ sessionTitle: "New session", // initial placeholder title + } + } + +@@ -63,8 +77,33 @@ func (m *model) Init() tea.Cmd { + return nil + } + +-func (m *model) SetTokenUsage(usage *runtime.Usage) { +- m.usage = usage ++func (m *model) SetTokenUsage(event *runtime.TokenUsageEvent) { // updates usage state from runtime events ++ if event == nil { // guard against nil events ++ return // nothing to do when event missing ++ } ++ ++ if event.AgentContext.AgentName != "" && m.usageState.rootAgentName == "" { // capture root agent name from first event ++ m.usageState.rootAgentName = event.AgentContext.AgentName // remember orchestrator name to identify later events ++ } ++ ++ if event.SessionID != "" { // update currently active session ID ++ m.usageState.activeSessionID = event.SessionID // track active session for totals/highlighting ++ } ++ ++ if event.SelfUsage != nil && event.SessionID != "" { // store self snapshot per session ++ m.usageState.sessions[event.SessionID] = cloneUsage(event.SelfUsage) // clone to avoid aliasing runtime memory ++ } ++ ++ if event.AgentContext.AgentName != "" && event.SessionID != "" { // map session ID to agent name for breakdown rows ++ m.usageState.sessionAgents[event.SessionID] = event.AgentContext.AgentName // remember descriptive label for later rendering ++ } ++ ++ if event.AgentContext.AgentName == m.usageState.rootAgentName && event.InclusiveUsage != nil { // update root inclusive snapshot when orchestrator reports ++ m.usageState.rootInclusive = cloneUsage(event.InclusiveUsage) // persist inclusive totals for team view ++ if event.SessionID != "" { // also note root session ID for comparisons ++ m.usageState.rootSessionID = event.SessionID // record root session identifier ++ } ++ } + } + + func (m *model) SetTodos(toolCall tools.ToolCall) error { +@@ -81,14 +120,9 @@ func (m *model) SetWorking(working bool) tea.Cmd { + return nil + } + +-// formatTokenCount formats a token count with K/M suffixes for readability ++// formatTokenCount formats a token count with grouping separators for readability + func formatTokenCount(count int) string { +- if count >= 1000000 { +- return fmt.Sprintf("%.1fM", float64(count)/1000000) +- } else if count >= 1000 { +- return fmt.Sprintf("%.1fK", float64(count)/1000) +- } +- return fmt.Sprintf("%d", count) ++ return humanize.Comma(int64(count)) + } + + // getCurrentWorkingDirectory returns the current working directory with home directory replaced by ~/ +@@ -192,18 +226,34 @@ func (m *model) workingIndicator() string { + return "" + } + +-func (m *model) tokenUsage() string { +- totalTokens := m.usage.InputTokens + m.usage.OutputTokens +- var usagePercent float64 +- if m.usage.ContextLimit > 0 { +- usagePercent = (float64(m.usage.ContextLength) / float64(m.usage.ContextLimit)) * 100 +- } ++func (m *model) tokenUsage() string { // renders aggregate usage summary line + breakdown ++ label, totals := m.renderTotals() // get friendly label plus computed totals ++ totalTokens := totals.InputTokens + totals.OutputTokens // sum user + assistant tokens for display + +- percentageText := styles.MutedStyle.Render(fmt.Sprintf("%.0f%%", usagePercent)) +- totalTokensText := styles.SubtleStyle.Render(fmt.Sprintf("(%s)", formatTokenCount(totalTokens))) +- costText := styles.MutedStyle.Render(fmt.Sprintf("$%.2f", m.usage.Cost)) ++ // var usagePercent float64 ++ // if totals.ContextLimit > 0 { ++ // usagePercent = (float64(totals.ContextLength) / float64(totals.ContextLimit)) * 100 ++ // } ++ // percentageText := styles.MutedStyle.Render(fmt.Sprintf("%.0f%%", usagePercent)) + +- return fmt.Sprintf("%s %s %s", percentageText, totalTokensText, costText) ++ var builder strings.Builder // assemble multiline output ++ builder.WriteString(styles.SubtleStyle.Render("TOTAL USAGE")) // heading for total usage ++ if label != "" { // append contextual label when available ++ builder.WriteString(fmt.Sprintf(" (%s)", label)) // show whether totals are team/session scoped ++ } ++ builder.WriteString(fmt.Sprintf("\n Tokens: %s | Cost: $%.2f\n", formatTokenCount(totalTokens), totals.Cost)) // display totals line ++ builder.WriteString("--------------------------------\n") // visual separator ++ builder.WriteString(styles.SubtleStyle.Render("SESSION BREAKDOWN")) // heading for per-session details ++ ++ breakdown := m.sessionBreakdownLines() // fetch breakdown blocks ++ if len(breakdown) > 0 { // append breakdown when data available ++ builder.WriteString("\n") // ensure newline before blocks ++ builder.WriteString(strings.Join(breakdown, "\n\n")) // place blank line between blocks ++ } else { ++ builder.WriteString("\n No session usage yet") // fallback text when no sessions reported ++ } ++ ++ return builder.String() // return composed view + } + + // SetSize sets the dimensions of the component +@@ -222,3 +272,169 @@ func (m *model) GetSize() (width, height int) { + func (m *model) SetMode(mode Mode) { + m.mode = mode + } ++ ++func cloneUsage(u *runtime.Usage) *runtime.Usage { // helper to copy runtime usage structs safely ++ if u == nil { // avoid panics on nil usage snapshots ++ return nil // nothing to clone when nil ++ } ++ clone := *u // copy by value to detach from original pointer ++ return &clone // return pointer to independent copy ++} ++ ++func (m *model) renderTotals() (string, *runtime.Usage) { // resolves label + totals for display ++ totals := m.computeTeamTotals() // compute aggregate usage first ++ if totals == nil { // ensure downstream code always receives a struct ++ totals = &runtime.Usage{} // fall back to zero snapshot ++ } ++ ++ label := "Session Total" // default label when only one session present ++ if m.usageState.rootInclusive != nil { // when root inclusive exists we can show team wording ++ label = "Team Total" // highlight that totals represent the whole team ++ if m.usageState.activeSessionID != "" && m.usageState.activeSessionID != m.usageState.rootSessionID { // active child contributes live usage ++ label = "Team Total (incl. active child)" // clarify that active child is included ++ } ++ } ++ ++ return label, totals // return computed label with totals ++} ++ ++func (m *model) computeTeamTotals() *runtime.Usage { // derives aggregate totals for the team line ++ base := cloneUsage(m.usageState.rootInclusive) // start with root inclusive snapshot, if any ++ active := m.currentSessionUsage() // get self usage for currently active session ++ ++ if base == nil { // when root has not reported yet ++ return cloneUsage(active) // either return active session usage or nil ++ } ++ ++ if active != nil && m.usageState.activeSessionID != "" && m.usageState.activeSessionID != m.usageState.rootSessionID { // only add active child when it differs from root session ++ base = mergeUsageTotals(base, active) // merge child self usage into inclusive total for live view ++ } ++ ++ return base // return computed totals (may still be nil if nothing reported) ++} ++ ++func (m *model) currentSessionUsage() *runtime.Usage { // fetches usage snapshot for active session ++ if m.usageState.activeSessionID == "" { // when no active session tracked ++ return nil // nothing to return ++ } ++ return m.usageState.sessions[m.usageState.activeSessionID] // look up snapshot in map (may be nil) ++} ++ ++func mergeUsageTotals(base, delta *runtime.Usage) *runtime.Usage { // adds token/cost fields from delta into base ++ if base == nil { // handle nil base by cloning delta ++ return cloneUsage(delta) // ensure caller gets independent struct ++ } ++ if delta == nil { // nothing to add if delta missing ++ return base // return base unchanged ++ } ++ base.InputTokens += delta.InputTokens // accumulate input tokens ++ base.OutputTokens += delta.OutputTokens // accumulate output tokens ++ base.ContextLength += delta.ContextLength // accumulate context length for completeness ++ if delta.ContextLimit > base.ContextLimit { // prefer higher limit to avoid regressions ++ base.ContextLimit = delta.ContextLimit // update context limit when child limit is larger ++ } ++ base.Cost += delta.Cost // accumulate cost for overall spend ++ return base // return augmented total ++} ++ ++func (m *model) sessionBreakdownLines() []string { // renders per-session self usage rows ++ if len(m.usageState.sessions) == 0 { // nothing to render when map empty ++ return nil // keep caller logic simple ++ } ++ ++ ids := make([]string, 0, len(m.usageState.sessions)) // gather session IDs for deterministic ordering ++ for id := range m.usageState.sessions { // iterate known sessions ++ ids = append(ids, id) // record id for sorting ++ } ++ sort.Strings(ids) // ensure stable ordering regardless of map iteration ++ ++ lines := make([]string, 0, len(ids)+1) // include space for root block ++ ++ if rootBlock := m.rootSessionBlock(); rootBlock != "" { // prepend root block when available ++ lines = append(lines, rootBlock) ++ } ++ ++ for _, id := range ids { // build block for each session ++ if id == m.usageState.rootSessionID { // skip root session since totals already shown above ++ continue ++ } ++ usage := m.usageState.sessions[id] // fetch stored snapshot ++ if usage == nil { // skip if snapshot missing ++ continue // nothing to render for this id ++ } ++ agentName := m.usageState.sessionAgents[id] // resolve display name ++ if agentName == "" { // fallback when agent name unknown ++ agentName = id // show session ID as identifier ++ } ++ ++ if block := formatSessionBlock(agentName, usage, id == m.usageState.activeSessionID); block != "" { // compose + style block ++ lines = append(lines, block) // add block to breakdown list ++ } ++ } ++ ++ return lines // return composed rows ++} ++ ++func (m *model) rootSessionBlock() string { // formats root agent entry with exclusive usage ++ exclusive := m.computeRootExclusiveUsage() // derive exclusive self usage ++ if exclusive == nil { ++ return "" ++ } ++ ++ name := m.usageState.rootAgentName // prefer configured agent name ++ if name == "" { ++ name = "Root" ++ } ++ ++ return formatSessionBlock(name, exclusive, m.usageState.activeSessionID == m.usageState.rootSessionID) ++} ++ ++func (m *model) computeRootExclusiveUsage() *runtime.Usage { // subtracts child usage from root inclusive totals ++ if m.usageState.rootInclusive == nil { ++ return nil ++ } ++ ++ exclusive := cloneUsage(m.usageState.rootInclusive) // operate on a copy ++ for id, usage := range m.usageState.sessions { ++ if id == m.usageState.rootSessionID || usage == nil { ++ continue // skip root entry and nil snapshots ++ } ++ exclusive = subtractUsage(exclusive, usage) // remove child contribution ++ } ++ ++ return exclusive ++} ++ ++func subtractUsage(base, delta *runtime.Usage) *runtime.Usage { // subtracts usage safely ++ if base == nil || delta == nil { ++ return base ++ } ++ ++ base.InputTokens -= delta.InputTokens ++ if base.InputTokens < 0 { ++ base.InputTokens = 0 ++ } ++ base.OutputTokens -= delta.OutputTokens ++ if base.OutputTokens < 0 { ++ base.OutputTokens = 0 ++ } ++ base.ContextLength = base.InputTokens + base.OutputTokens ++ base.Cost -= delta.Cost ++ if base.Cost < 0 { ++ base.Cost = 0 ++ } ++ ++ return base ++} ++ ++func formatSessionBlock(agentName string, usage *runtime.Usage, isActive bool) string { // helper to render a single block ++ if usage == nil { ++ return "" ++ } ++ ++ block := fmt.Sprintf(" %s\n Tokens: %s | Cost: $%.2f", agentName, formatTokenCount(usage.InputTokens+usage.OutputTokens), usage.Cost) ++ if isActive { ++ return styles.ActiveStyle.Render(block) ++ } ++ return block ++} diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index 9fbd2f18f..a70b1ea74 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Development environment setup -> We currently support `cagent` development on macOS and linux based systems. Windows support coming soon +> We currently support `cagent` development on macOS and Linux-based systems. Windows support coming soon #### Build from source @@ -13,7 +13,7 @@ If you're hacking on `cagent`, or just want to be on the bleeding edge, then bui - Go 1.25 or higher - API key(s) for your chosen AI provider (OpenAI, Anthropic, Gemini, etc.) - [Task 3.44 or higher](https://taskfile.dev/installation/) -- [`golangci-lint`](https://golangci-lint.run/docs/welcome/install/#binaries`) +- [`golangci-lint`](https://golangci-lint.run/docs/welcome/install/#binaries) ##### Build commands @@ -25,8 +25,9 @@ task build # Set keys for remote inference services export OPENAI_API_KEY=your_api_key_here # For OpenAI models -export ANTHROPIC_API_KEY=your_api_key_here # For Anthopic models +export ANTHROPIC_API_KEY=your_api_key_here # For Anthropic models export GOOGLE_API_KEY=your_api_key_here # For Gemini models +export MISTRAL_API_KEY=your_api_key_here # For Mistral models # Run with a sample configuration ./bin/cagent run examples/code.yaml @@ -44,7 +45,7 @@ Binary builds can also be made using `docker` itself. Start a build via docker using `task build-local` (for only your local architecture), or use `task cross` to build for all supported platforms. -Builds done via `docker` will the placed in the `./dist` directory +Builds done via `docker` will be placed in the `./dist` directory ```sh $ task build-local diff --git a/docs/USAGE.md b/docs/USAGE.md index 69120d13d..0440888f9 100644 --- a/docs/USAGE.md +++ b/docs/USAGE.md @@ -56,6 +56,9 @@ $ cagent exec config.yaml --yolo # Run the agent once and auto-accept a # API Server (HTTP REST API) $ cagent api config.yaml $ cagent api config.yaml --listen :8080 +$ cagent api ociReference # start API from oci reference +## start API from oci reference, auto-pull every 10 mins and reload if a new team was pulled +$ cagent api ociReference --pull-interval 10 # ACP Server (Agent Client Protocol via stdio) $ cagent acp config.yaml # Start ACP server on stdio @@ -172,20 +175,20 @@ Determine how much the model should think by setting the `thinking_budget` - **OpenAI**: use effort levels — `minimal`, `low`, `medium`, `high` - **Anthropic**: set an integer token budget. Range is 1024–32768; must be strictly less than `max_tokens`. -- **Google (Gemini)**: set an integer token budget. `0` -> disable thinking, `-1` -> dynamic thinking (model decides). Most models: 0–24576 tokens. Gemini 2.5 Pro: 128–32768 tokens (and cannot disabled thinking). +- **Google (Gemini)**: set an integer token budget. `0` -> disable thinking, `-1` -> dynamic thinking (model decides). Most models: 0–24576 tokens. Gemini 2.5 Pro: 128–32768 tokens (and cannot disable thinking). Examples (OpenAI): ```yaml models: - openai: + gpt: provider: openai model: gpt-5-mini thinking_budget: low agents: root: - model: openai + model: gpt instruction: you are a helpful assistant ``` @@ -265,7 +268,7 @@ See `examples/thinking_budget.yaml` for a complete runnable demo. # OpenAI models: - openai: + gpt: provider: openai model: gpt-5-mini diff --git a/e2e/cagent_debug_test.go b/e2e/cagent_debug_test.go new file mode 100644 index 000000000..5da2fe6de --- /dev/null +++ b/e2e/cagent_debug_test.go @@ -0,0 +1,49 @@ +package e2e_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/cmd/root" +) + +func TestDebug_Toolsets_None(t *testing.T) { + t.Parallel() + + output := cagentDebug(t, "toolsets", "testdata/no_tools.yaml") + + require.Equal(t, "No tools for root\n", output) +} + +func TestDebug_Toolsets_Todo(t *testing.T) { + t.Parallel() + + output := cagentDebug(t, "toolsets", "testdata/todo_tools.yaml") + + require.Equal(t, "2 tool(s) for root:\n + create_todo\n + list_todos\n", output) +} + +func cagentDebug(t *testing.T, moreArgs ...string) string { + t.Helper() + + // `cagent debug ...` + args := []string{"debug"} + + // Use .env file to set DUMMY OPENAI key + dotEnv := filepath.Join(t.TempDir(), ".env") + err := os.WriteFile(dotEnv, []byte("OPENAI_API_KEY=DUMMY"), 0o644) + require.NoError(t, err) + args = append(args, "--env-from-file", dotEnv) + + // Run cagent debug + var stdout bytes.Buffer + err = root.Execute(t.Context(), nil, &stdout, io.Discard, append(args, moreArgs...)...) + require.NoError(t, err) + + return stdout.String() +} diff --git a/e2e/cagent_exec_test.go b/e2e/cagent_exec_test.go new file mode 100644 index 000000000..43b26ee0e --- /dev/null +++ b/e2e/cagent_exec_test.go @@ -0,0 +1,73 @@ +package e2e_test + +import ( + "bytes" + "io" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/cmd/root" +) + +func TestExec_BasicOpenAI(t *testing.T) { + out := cagentExec(t, "testdata/basic.yaml", "Who's djordje?") + + require.Equal(t, ` +--- Agent: root --- + +Djordje is a common Serbian given name. It may refer to different individuals depending on the context. If you provide more information or details, I can help you identify the specific Djordje you are referring to.`, + out) +} + +func TestExec_BasicAnthropic(t *testing.T) { + out := cagentExec(t, "testdata/basic.yaml", "--model=anthropic/claude-sonnet-4-0", "Who's djordje?. Be super concise.") + + require.Equal(t, ` +--- Agent: root --- + +I need more context. There are many people named Djordje (a Serbian name). Could you specify which Djordje you're asking about?`, + out) +} + +func TestExec_BasicGemini(t *testing.T) { + out := cagentExec(t, "testdata/basic.yaml", "--model=google/gemini-2.5-flash", "Who's djordje?. Be super concise.") + + require.Equal(t, ` +--- Agent: root --- + +Serbian equivalent of the name George.`, + out) +} + +func TestExec_ToolCallsNeedAcceptance(t *testing.T) { + out := cagentExec(t, "testdata/file_writer.yaml", "Create a hello.txt file with \"Hello, World!\" content. Try only once. On error, exit without further message.") + + require.Contains(t, out, `Can I run this tool? ([y]es/[a]ll/[n]o)`) +} + +func cagentExec(t *testing.T, moreArgs ...string) string { + t.Helper() + + // `cagent exec ...` + args := []string{"exec"} + + // Use a dummy .env file to avoid using real JWT. Our proxy server doesn't need it. + dotEnv := filepath.Join(t.TempDir(), ".env") + err := os.WriteFile(dotEnv, []byte("DOCKER_TOKEN=DUMMY"), 0o644) + require.NoError(t, err) + args = append(args, "--env-from-file", dotEnv) + + // Start a recording AI proxy to record and replay traffic. + svr, _ := startRecordingAIProxy(t) + args = append(args, "--models-gateway", svr.URL) + + // Run cagent exec + var stdout bytes.Buffer + err = root.Execute(t.Context(), nil, &stdout, io.Discard, append(args, moreArgs...)...) + require.NoError(t, err) + + return stdout.String() +} diff --git a/e2e/dependencies_test.go b/e2e/dependencies_test.go new file mode 100644 index 000000000..9fe7bbcfa --- /dev/null +++ b/e2e/dependencies_test.go @@ -0,0 +1,47 @@ +package e2e + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDependencies(t *testing.T) { + t.Run("TUI musn't know about teams", func(t *testing.T) { + imports := listImports(t, "../pkg/tui") + + assert.True(t, imports["github.com/docker/cagent/pkg/runtime"]) + assert.False(t, imports["github.com/docker/cagent/pkg/team"]) + }) +} + +func listImports(t *testing.T, pkg string) map[string]bool { + t.Helper() + + imports := map[string]bool{} + + fileSet := token.NewFileSet() + err := filepath.WalkDir(pkg, func(path string, d os.DirEntry, err error) error { + if err != nil || !strings.HasSuffix(path, ".go") || d.IsDir() { + return err + } + + ast, err := parser.ParseFile(fileSet, path, nil, parser.ImportsOnly) + require.NoError(t, err) + + for _, i := range ast.Imports { + imports[strings.Trim(i.Path.Value, `"`)] = true + } + + return nil + }) + require.NoError(t, err) + + return imports +} diff --git a/e2e/mcp_test.go b/e2e/mcp_test.go new file mode 100644 index 000000000..89e81fccc --- /dev/null +++ b/e2e/mcp_test.go @@ -0,0 +1,53 @@ +package e2e_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/pkg/mcp" + "github.com/docker/cagent/pkg/teamloader" +) + +func TestMCP_SingleAgent(t *testing.T) { + t.Parallel() + + ctx := t.Context() + _, runtimeConfig := startRecordingAIProxy(t) + + team, err := teamloader.Load(ctx, "testdata/basic.yaml", runtimeConfig) + require.NoError(t, err, "Failed to load agent") + t.Cleanup(func() { + require.NoError(t, team.StopToolSets(ctx)) + }) + + handler := mcp.CreateToolHandler(team, "root", "testdata/basic.yaml") + _, output, err := handler(ctx, nil, mcp.ToolInput{ + Message: "What is 2+2? Answer in one sentence.", + }) + + require.NoError(t, err) + assert.Equal(t, "2+2 equals 4.", output.Response) +} + +func TestMCP_MultiAgent(t *testing.T) { + t.Parallel() + + ctx := t.Context() + _, runtimeConfig := startRecordingAIProxy(t) + + team, err := teamloader.Load(ctx, "testdata/multi.yaml", runtimeConfig) + require.NoError(t, err, "Failed to load team") + t.Cleanup(func() { + require.NoError(t, team.StopToolSets(ctx)) + }) + + handler := mcp.CreateToolHandler(team, "web", "testdata/multi.yaml") + _, output, err := handler(ctx, nil, mcp.ToolInput{ + Message: "Say hello in one sentence.", + }) + + require.NoError(t, err) + assert.Equal(t, "Hello, nice to meet you.", output.Response) +} diff --git a/e2e/proxy_test.go b/e2e/proxy_test.go new file mode 100644 index 000000000..226033c63 --- /dev/null +++ b/e2e/proxy_test.go @@ -0,0 +1,199 @@ +package e2e_test + +import ( + "bytes" + "context" + "io" + "log/slog" + "maps" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/labstack/echo/v4" + "github.com/stretchr/testify/require" + "gopkg.in/dnaeon/go-vcr.v4/pkg/cassette" + "gopkg.in/dnaeon/go-vcr.v4/pkg/recorder" + + "github.com/docker/cagent/pkg/config" + "github.com/docker/cagent/pkg/environment" +) + +func removeHeadersHook(i *cassette.Interaction) error { + i.Request.Headers = map[string][]string{} + i.Response.Headers = map[string][]string{} + return nil +} + +func customMatcher(t *testing.T) recorder.MatcherFunc { + t.Helper() + + return func(r *http.Request, i cassette.Request) bool { + if r.Body == nil || r.Body == http.NoBody { + return cassette.DefaultMatcher(r, i) + } + + reqBody, err := io.ReadAll(r.Body) + require.NoError(t, err) + + r.Body.Close() + r.Body = io.NopCloser(bytes.NewBuffer(reqBody)) + return r.Method == i.Method && r.URL.String() == i.URL && string(reqBody) == i.Body + } +} + +func startRecordingAIProxy(t *testing.T) (*httptest.Server, config.RuntimeConfig) { + t.Helper() + + transport, err := recorder.New(filepath.Join("testdata", "cassettes", t.Name()), + recorder.WithMode(recorder.ModeRecordOnce), + recorder.WithMatcher(customMatcher(t)), + recorder.WithSkipRequestLatency(true), + recorder.WithHook(removeHeadersHook, recorder.AfterCaptureHook), + ) + require.NoError(t, err) + + t.Cleanup(func() { require.NoError(t, transport.Stop()) }) + + e := echo.New() + e.Any("/*", handle(transport)) + + httpServer := httptest.NewServer(e) + t.Cleanup(httpServer.Close) + + return httpServer, config.RuntimeConfig{ + ModelsGateway: httpServer.URL, + DefaultEnvProvider: &testEnvProvider{ + environment.DockerDesktopTokenEnv: "DUMMY", + }, + } +} + +func handle(transport http.RoundTripper) echo.HandlerFunc { + return func(c echo.Context) error { + ctx := c.Request().Context() + + host := c.Request().Header.Get("X-Cagent-Forward") + host = strings.TrimSuffix(host, "/") + + var toTargetURL func(req *http.Request) string + var updateHeaders func(req *http.Request) + switch host { + case "https://api.openai.com/v1": + toTargetURL = func(req *http.Request) string { + return "https://api.openai.com" + req.URL.Redacted() + } + updateHeaders = func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+os.Getenv("OPENAI_API_KEY")) + } + case "https://api.anthropic.com": + toTargetURL = func(req *http.Request) string { + return "https://api.anthropic.com" + req.URL.Redacted() + } + updateHeaders = func(req *http.Request) { + req.Header.Del("Authorization") + req.Header.Set("X-Api-Key", os.Getenv("ANTHROPIC_API_KEY")) + } + case "https://generativelanguage.googleapis.com": + toTargetURL = func(req *http.Request) string { + return "https://generativelanguage.googleapis.com" + req.URL.Redacted() + } + updateHeaders = func(req *http.Request) { + req.Header.Del("Authorization") + req.Header.Set("X-Goog-Api-Key", os.Getenv("GOOGLE_API_KEY")) + } + case "https://api.mistral.ai/v1": + toTargetURL = func(req *http.Request) string { + return "https://api.mistral.ai" + req.URL.Redacted() + } + updateHeaders = func(req *http.Request) { + req.Header.Set("Authorization", "Bearer "+os.Getenv("MISTRAL_API_KEY")) + } + default: + return echo.NewHTTPError(http.StatusBadRequest, "unknown service host "+host) + } + + targetURL := toTargetURL(c.Request()) + + req, err := http.NewRequestWithContext(ctx, c.Request().Method, targetURL, c.Request().Body) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to create new request") + } + + maps.Copy(req.Header, c.Request().Header) + updateHeaders(req) + + client := &http.Client{ + Timeout: 0, // no timeout, let ctx control it + Transport: transport, + } + + resp, err := client.Do(req) + if err != nil { + return echo.NewHTTPError(http.StatusInternalServerError, "Failed to run request"+err.Error()) + } + defer resp.Body.Close() + + maps.Copy(c.Response().Header(), resp.Header) + + c.Response().WriteHeader(resp.StatusCode) + + if isStreamResponse(resp) { + return streamCopy(c, resp) + } + + _, err = io.Copy(c.Response().Writer, resp.Body) + return err + } +} + +func streamCopy(c echo.Context, resp *http.Response) error { + ctx := c.Request().Context() + + writer := c.Response().Writer.(io.ReaderFrom) + + for { + select { + case <-ctx.Done(): + slog.WarnContext(ctx, "client disconnected, stop streaming") + return nil + default: + n, err := writer.ReadFrom(io.LimitReader(resp.Body, 256)) + if n > 0 { + c.Response().Flush() // keep flushing to client + } + if err != nil { + if err == io.EOF || ctx.Err() != nil { + return nil + } + slog.ErrorContext(ctx, "stream read error", "error", err) + return err + } + } + } +} + +func isStreamResponse(resp *http.Response) bool { + ct := strings.ToLower(resp.Header.Get("Content-Type")) + if strings.Contains(ct, "text/event-stream") { + return true + } + + te := strings.ToLower(resp.Header.Get("Transfer-Encoding")) + if strings.Contains(te, "chunked") && !strings.Contains(ct, "application/json") { + return true + } + + return strings.Contains(ct, "application/octet-stream") || + strings.Contains(ct, "application/x-ndjson") || + strings.Contains(ct, "application/stream+json") +} + +type testEnvProvider map[string]string + +func (p *testEnvProvider) Get(_ context.Context, name string) string { + return (*p)[name] +} diff --git a/e2e/runtime_test.go b/e2e/runtime_test.go new file mode 100644 index 000000000..ad6909962 --- /dev/null +++ b/e2e/runtime_test.go @@ -0,0 +1,54 @@ +package e2e_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/docker/cagent/pkg/runtime" + "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/teamloader" +) + +func TestRuntime_OpenAI_Basic(t *testing.T) { + t.Parallel() + + ctx := t.Context() + _, runtimeConfig := startRecordingAIProxy(t) + + team, err := teamloader.Load(ctx, "testdata/basic.yaml", runtimeConfig) + require.NoError(t, err) + + rt, err := runtime.New(team) + require.NoError(t, err) + + sess := session.New(session.WithUserMessage("", "Who's djordje?")) + _, err = rt.Run(ctx, sess) + require.NoError(t, err) + + response := sess.GetLastAssistantMessageContent() + assert.Equal(t, "Djordje is a popular given name in some Eastern European countries, such as Serbia. If you have more specific information or context, I'd be happy to help further.", response) + assert.Equal(t, "Understanding identity: Who is Djordje?", sess.Title) +} + +func TestRuntime_Mistral_Basic(t *testing.T) { + t.Parallel() + + ctx := t.Context() + _, runtimeConfig := startRecordingAIProxy(t) + + team, err := teamloader.Load(ctx, "testdata/basic.yaml", runtimeConfig, teamloader.WithModelOverrides([]string{"mistral/mistral-small"})) + require.NoError(t, err) + + rt, err := runtime.New(team) + require.NoError(t, err) + + sess := session.New(session.WithUserMessage("", "Who's djordje?")) + _, err = rt.Run(ctx, sess) + require.NoError(t, err) + + response := sess.GetLastAssistantMessageContent() + assert.Equal(t, `It seems like "djordje" is a name, most likely of Slavic origin. It is commonly spelled as "Đorđe" in Serbian language, and it means "farmer" or "earthworker". It is a masculine given name, and it is quite popular in Serbia, Montenegro, and other countries in the region. Without more context, it's hard to say exactly who "djordje" is, as it could refer to any person by that name.`, response) + assert.Equal(t, `"Inquiry About the Identity of 'Djordje'"`, sess.Title) +} diff --git a/e2e/testdata/basic.yaml b/e2e/testdata/basic.yaml new file mode 100644 index 000000000..8427f162c --- /dev/null +++ b/e2e/testdata/basic.yaml @@ -0,0 +1,9 @@ +version: "2" + +agents: + root: + model: openai/gpt-3.5-turbo + description: A helpful AI assistant + instruction: | + You are a knowledgeable assistant that helps users with various tasks. + Be helpful, accurate, and concise in your responses. diff --git a/e2e/testdata/cassettes/TestExec_BasicAnthropic.yaml b/e2e/testdata/cassettes/TestExec_BasicAnthropic.yaml new file mode 100644 index 000000000..193959b76 --- /dev/null +++ b/e2e/testdata/cassettes/TestExec_BasicAnthropic.yaml @@ -0,0 +1,43 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.anthropic.com + body: "{\"messages\":[{\"content\":[{\"text\":\"Who's djordje?. Be super concise.\",\"type\":\"text\"}],\"role\":\"user\"}],\"model\":\"claude-sonnet-4-0\",\"system\":[{\"text\":\"You are a knowledgeable assistant that helps users with various tasks.\\nBe helpful, accurate, and concise in your responses.\",\"type\":\"text\"}]}" + url: https://api.anthropic.com/v1/messages/count_tokens + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: 19 + body: "{\"input_tokens\":47}" + headers: {} + status: 200 OK + code: 200 + duration: 322.217375ms +- id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.anthropic.com + body: "{\"max_tokens\":8192,\"messages\":[{\"content\":[{\"text\":\"Who's djordje?. Be super concise.\",\"type\":\"text\"}],\"role\":\"user\"}],\"model\":\"claude-sonnet-4-0\",\"system\":[{\"text\":\"You are a knowledgeable assistant that helps users with various tasks.\\nBe helpful, accurate, and concise in your responses.\",\"type\":\"text\"}],\"tools\":[],\"stream\":true}" + url: https://api.anthropic.com/v1/messages + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"model\":\"claude-sonnet-4-20250514\",\"id\":\"msg_01NvGU2o6REjWRdHUU4ZWYS6\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":47,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"cache_creation\":{\"ephemeral_5m_input_tokens\":0,\"ephemeral_1h_input_tokens\":0},\"output_tokens\":2,\"service_tier\":\"standard\"}} }\n\nevent: content_block_start\ndata: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"I nee\"} }\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"d more context. There are many people named Djordje (\"} }\n\nevent: ping\ndata: {\"type\": \"ping\"}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"a Serbian name). Could you specify which Djordje you're asking\"}}\n\nevent: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\" about?\"} }\n\nevent: content_block_stop\ndata: {\"type\":\"content_block_stop\",\"index\":0 }\n\nevent: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":47,\"cache_creation_input_tokens\":0,\"cache_read_input_tokens\":0,\"output_tokens\":35} }\n\nevent: message_stop\ndata: {\"type\":\"message_stop\" }\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 1.590032s diff --git a/e2e/testdata/cassettes/TestExec_BasicGemini.yaml b/e2e/testdata/cassettes/TestExec_BasicGemini.yaml new file mode 100644 index 000000000..96c01d979 --- /dev/null +++ b/e2e/testdata/cassettes/TestExec_BasicGemini.yaml @@ -0,0 +1,26 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: generativelanguage.googleapis.com + body: "{\"contents\":[{\"parts\":[{\"text\":\"You are a knowledgeable assistant that helps users with various tasks.\\nBe helpful, accurate, and concise in your responses.\\n\"}],\"role\":\"user\"},{\"parts\":[{\"text\":\"Who's djordje?. Be super concise.\"}],\"role\":\"user\"}],\"generationConfig\":{\"frequencyPenalty\":0,\"presencePenalty\":0,\"temperature\":0,\"topP\":0}}\n" + form: + alt: + - sse + url: https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"candidates\": [{\"content\": {\"parts\": [{\"text\": \"Serbian equivalent of the name George.\"}],\"role\": \"model\"},\"finishReason\": \"STOP\",\"index\": 0}],\"usageMetadata\": {\"promptTokenCount\": 38,\"candidatesTokenCount\": 8,\"totalTokenCount\": 105,\"promptTokensDetails\": [{\"modality\": \"TEXT\",\"tokenCount\": 38}],\"thoughtsTokenCount\": 59},\"modelVersion\": \"gemini-2.5-flash\",\"responseId\": \"Z7wIadPPD73BnsEPk-SmgAU\"}\r\n\r\n" + headers: {} + status: 200 OK + code: 200 + duration: 692.427375ms diff --git a/e2e/testdata/cassettes/TestExec_BasicOpenAI.yaml b/e2e/testdata/cassettes/TestExec_BasicOpenAI.yaml new file mode 100644 index 000000000..0dcad6d58 --- /dev/null +++ b/e2e/testdata/cassettes/TestExec_BasicOpenAI.yaml @@ -0,0 +1,23 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.openai.com + body: "{\"model\":\"gpt-3.5-turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a knowledgeable assistant that helps users with various tasks.\\nBe helpful, accurate, and concise in your responses.\\n\"},{\"role\":\"user\",\"content\":\"Who's djordje?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}" + url: https://api.openai.com/v1/chat/completions + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"qQk8uRDw\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"D\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"2xVsyHFZE\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"j\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"mnG6xpb8P\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ord\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QikHsf7\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"je\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"w7KupSVV\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ARIFcwE\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"u23IZYOW\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" common\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QHM\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Serbian\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"4v\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" given\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"dMlm\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" name\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"n1vYg\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"N0tC2hJ83\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" It\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"KJj7j2j\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" may\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"jJvx2y\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" refer\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"hMHr\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"8OioblP\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" different\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" individuals\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"vOsz3nIFtArKLL\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" depending\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" on\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"iJCijT4\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" the\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"9qxp4v\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" context\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lm\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"f6D3XuZXI\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" If\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"8qe12vq\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" you\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"7IFl0t\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" provide\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"dE\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" more\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"0Sg0X\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" information\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"UutnojY3rhsIZQ\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" or\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"W10idue\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" details\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"rL\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"4jGJHrQhb\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"KNZLCx5i\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" can\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"SMAZzo\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" help\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"aYfSA\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" you\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"sjjkQ6\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" identify\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"o\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" the\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Jlkgl4\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" specific\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"D\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Dj\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"WbVO4Ze\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ord\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"cKwcyJJ\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"je\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"oyfu50Bf\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" you\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"chK2e5\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" are\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"xUHZiq\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" referring\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"VbMiXUR\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"qaHjDgfKh\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"wuZR\"}\n\ndata: {\"id\":\"chatcmpl-CXp6O4wl5wIzpD5rlnZOHq1ZhhoVI\",\"object\":\"chat.completion.chunk\",\"created\":1762177296,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[],\"usage\":{\"prompt_tokens\":40,\"completion_tokens\":45,\"total_tokens\":85,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"zsHgmWah9\"}\n\ndata: [DONE]\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 978.163875ms diff --git a/e2e/testdata/cassettes/TestExec_ToolCallsNeedAcceptance.yaml b/e2e/testdata/cassettes/TestExec_ToolCallsNeedAcceptance.yaml new file mode 100644 index 000000000..6865ed133 --- /dev/null +++ b/e2e/testdata/cassettes/TestExec_ToolCallsNeedAcceptance.yaml @@ -0,0 +1,43 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.openai.com + body: "{\"model\":\"gpt-5-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a knowledgeable assistant that can write test files.\"},{\"role\":\"system\",\"content\":\"## Filesystem Tool Instructions\\n\\nThis toolset provides comprehensive filesystem operations with built-in security restrictions.\\n\\n### Security Model\\n- All operations are restricted to allowed directories only\\n- Use list_allowed_directories to see available paths\\n- Subdirectories within allowed directories are accessible\\n- Use add_allowed_directory to request access to new directories (requires user consent)\\n\\n### Directory Access Management\\n- If you need access to a directory outside the allowed list, use add_allowed_directory\\n- This will request user consent before expanding filesystem access\\n- Always provide a clear reason when requesting new directory access\\n\\n### Common Patterns\\n- Always check if directories exist before creating files\\n- Prefer read_multiple_files for batch operations\\n- Use search_files_content for finding specific code or text\\n\\n### Performance Tips\\n- Use read_multiple_files instead of multiple read_file calls\\n- Use directory_tree with max_depth to limit large traversals\\n- Use appropriate exclude patterns in search operations\"},{\"role\":\"user\",\"content\":\"Create a hello.txt file with \\\"Hello, World!\\\" content. Try only once. On error, exit without further message.\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"write_file\",\"description\":\"Create a new file or completely overwrite an existing file with new content.\",\"parameters\":{\"additionalProperties\":false,\"properties\":{\"content\":{\"description\":\"The content to write to the file\",\"type\":\"string\"},\"path\":{\"description\":\"The file path to write\",\"type\":\"string\"}},\"required\":[\"path\",\"content\"],\"type\":\"object\"}}}],\"stream_options\":{\"include_usage\":true}}" + url: https://api.openai.com/v1/chat/completions + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":null,\"tool_calls\":[{\"index\":0,\"id\":\"call_1l99RCuSJIgyl6aJhcQcB3w1\",\"type\":\"function\",\"function\":{\"name\":\"write_file\",\"arguments\":\"\"}}],\"refusal\":null},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"r\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"{\\\"\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"gzZjus3bzI\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"path\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Medr4Mxej\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"sH0dbYG4\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"hello\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"FhISNOGA\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\".txt\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"tHXTZLdH1\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\",\\\"\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"vzx32xpB\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"content\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"s4gSeG\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"\\\":\\\"\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"f2yh0ka7\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"Hello\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"K0kOhxiq\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\",\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"TB4rnVGhSVxI\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\" World\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"OMtriny\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"!\\\"\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Eiq6bc66WL\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"tool_calls\":[{\"index\":0,\"function\":{\"arguments\":\"}\"}}]},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"bZWwxAh59SfZ\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"tool_calls\"}],\"usage\":null,\"obfuscation\":\"vpofJxEZMZn\"}\n\ndata: {\"id\":\"chatcmpl-CYCUv3FroLFXPfMEnAfFmd7SUlNT6\",\"object\":\"chat.completion.chunk\",\"created\":1762267229,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[],\"usage\":{\"prompt_tokens\":381,\"completion_tokens\":799,\"total_tokens\":1180,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":768,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"\"}\n\ndata: [DONE]\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 14.522897375s +- id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.openai.com + body: "{\"model\":\"gpt-5-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a knowledgeable assistant that can write test files.\"},{\"role\":\"system\",\"content\":\"## Filesystem Tool Instructions\\n\\nThis toolset provides comprehensive filesystem operations with built-in security restrictions.\\n\\n### Security Model\\n- All operations are restricted to allowed directories only\\n- Use list_allowed_directories to see available paths\\n- Subdirectories within allowed directories are accessible\\n- Use add_allowed_directory to request access to new directories (requires user consent)\\n\\n### Directory Access Management\\n- If you need access to a directory outside the allowed list, use add_allowed_directory\\n- This will request user consent before expanding filesystem access\\n- Always provide a clear reason when requesting new directory access\\n\\n### Common Patterns\\n- Always check if directories exist before creating files\\n- Prefer read_multiple_files for batch operations\\n- Use search_files_content for finding specific code or text\\n\\n### Performance Tips\\n- Use read_multiple_files instead of multiple read_file calls\\n- Use directory_tree with max_depth to limit large traversals\\n- Use appropriate exclude patterns in search operations\"},{\"role\":\"user\",\"content\":\"Create a hello.txt file with \\\"Hello, World!\\\" content. Try only once. On error, exit without further message.\"},{\"role\":\"assistant\",\"tool_calls\":[{\"id\":\"call_1l99RCuSJIgyl6aJhcQcB3w1\",\"type\":\"function\",\"function\":{\"name\":\"write_file\",\"arguments\":\"{\\\"path\\\":\\\"hello.txt\\\",\\\"content\\\":\\\"Hello, World!\\\"}\"}}]},{\"role\":\"tool\",\"content\":\"The user rejected the tool call.\",\"tool_call_id\":\"call_1l99RCuSJIgyl6aJhcQcB3w1\"}],\"stream\":true,\"tools\":[{\"type\":\"function\",\"function\":{\"name\":\"write_file\",\"description\":\"Create a new file or completely overwrite an existing file with new content.\",\"parameters\":{\"additionalProperties\":false,\"properties\":{\"content\":{\"description\":\"The content to write to the file\",\"type\":\"string\"},\"path\":{\"description\":\"The file path to write\",\"type\":\"string\"}},\"required\":[\"path\",\"content\"],\"type\":\"object\"}}}],\"stream_options\":{\"include_usage\":true}}" + url: https://api.openai.com/v1/chat/completions + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"id\":\"chatcmpl-CYCVAeaL5P1w77PGELqdglolCtsTy\",\"object\":\"chat.completion.chunk\",\"created\":1762267244,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"8P3BX\"}\n\ndata: {\"id\":\"chatcmpl-CYCVAeaL5P1w77PGELqdglolCtsTy\",\"object\":\"chat.completion.chunk\",\"created\":1762267244,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"s\"}\n\ndata: {\"id\":\"chatcmpl-CYCVAeaL5P1w77PGELqdglolCtsTy\",\"object\":\"chat.completion.chunk\",\"created\":1762267244,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[],\"usage\":{\"prompt_tokens\":424,\"completion_tokens\":137,\"total_tokens\":561,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":128,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"H\"}\n\ndata: [DONE]\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 3.793669625s diff --git a/e2e/testdata/cassettes/TestMCP_MultiAgent.yaml b/e2e/testdata/cassettes/TestMCP_MultiAgent.yaml new file mode 100644 index 000000000..536a3c2b5 --- /dev/null +++ b/e2e/testdata/cassettes/TestMCP_MultiAgent.yaml @@ -0,0 +1,23 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.openai.com + body: "{\"model\":\"gpt-5-mini\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a knowledgeable assistant that helps users with web tasks.\\n\"},{\"role\":\"user\",\"content\":\"Say hello in one sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}" + url: https://api.openai.com/v1/chat/completions + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"9o3Tj\"}\n\ndata: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Hello\"},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ZQ\"}\n\ndata: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"S9ksF0\"}\n\ndata: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" nice\"},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"ny\"}\n\ndata: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" to\"},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"mgzx\"}\n\ndata: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" meet\"},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"7n\"}\n\ndata: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" you\"},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"YAh\"}\n\ndata: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"0Kc5Id\"}\n\ndata: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"r\"}\n\ndata: {\"id\":\"chatcmpl-CY9HhLy35p8nQ0Ul63kaF9VFmdtEb\",\"object\":\"chat.completion.chunk\",\"created\":1762254877,\"model\":\"gpt-5-mini-2025-08-07\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[],\"usage\":{\"prompt_tokens\":28,\"completion_tokens\":80,\"total_tokens\":108,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":64,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"fd8D\"}\n\ndata: [DONE]\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 3.244890916s diff --git a/e2e/testdata/cassettes/TestMCP_SingleAgent.yaml b/e2e/testdata/cassettes/TestMCP_SingleAgent.yaml new file mode 100644 index 000000000..6cb39a9b9 --- /dev/null +++ b/e2e/testdata/cassettes/TestMCP_SingleAgent.yaml @@ -0,0 +1,23 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.openai.com + body: "{\"model\":\"gpt-3.5-turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a knowledgeable assistant that helps users with various tasks.\\nBe helpful, accurate, and concise in your responses.\\n\"},{\"role\":\"user\",\"content\":\"What is 2+2? Answer in one sentence.\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}" + url: https://api.openai.com/v1/chat/completions + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"SFuaKROI\"}\n\ndata: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"2\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"jFqvhM8CQ\"}\n\ndata: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"+\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Eq3WK0zEA\"}\n\ndata: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"2\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"K8ySC4buM\"}\n\ndata: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" equals\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"GLM\"}\n\ndata: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Fot5FepkW\"}\n\ndata: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"4\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"crJhnXxGC\"}\n\ndata: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"B52kPvlng\"}\n\ndata: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"WZBb\"}\n\ndata: {\"id\":\"chatcmpl-CY7sFgZVIXou7zbnYzI3fXr2w47qf\",\"object\":\"chat.completion.chunk\",\"created\":1762249455,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[],\"usage\":{\"prompt_tokens\":46,\"completion_tokens\":7,\"total_tokens\":53,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"9zKk8YAMqH\"}\n\ndata: [DONE]\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 497.3025ms diff --git a/e2e/testdata/cassettes/TestRuntime_Mistral_Basic.yaml b/e2e/testdata/cassettes/TestRuntime_Mistral_Basic.yaml new file mode 100644 index 000000000..1ad9341da --- /dev/null +++ b/e2e/testdata/cassettes/TestRuntime_Mistral_Basic.yaml @@ -0,0 +1,43 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.mistral.ai + body: "{\"model\":\"mistral-small\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a knowledgeable assistant that helps users with various tasks.\\nBe helpful, accurate, and concise in your responses.\\n\"},{\"role\":\"user\",\"content\":\"Who's djordje?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}" + url: https://api.mistral.ai/v1/chat/completions + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"It\"},\"finish_reason\":null}],\"p\":\"abcd\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" seems\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234567\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" like\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \\\"\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"d\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"j\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456789\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ord\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"je\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrs\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\\\"\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"finish_reason\":null}],\"p\":\"a\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" name\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234567\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" most\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" likely\"},\"finish_reason\":null}],\"p\":\"a\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"finish_reason\":null}],\"p\":\"abc\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Sl\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"av\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ic\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" origin\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":null}],\"p\":\"ab\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" It\"},\"finish_reason\":null}],\"p\":\"abc\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" commonly\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" sp\"},\"finish_reason\":null}],\"p\":\"abcde\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"elled\"},\"finish_reason\":null}],\"p\":\"abc\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" as\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \\\"\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Đ\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopq\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"or\"},\"finish_reason\":null}],\"p\":\"abcdefg\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"đ\"},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"e\"},\"finish_reason\":null}],\"p\":\"abcde\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\\\"\"},\"finish_reason\":null}],\"p\":\"abc\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ser\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopq\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"bian\"},\"finish_reason\":null}],\"p\":\"abcdefgh\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" language\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmno\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvw\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnop\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" it\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" means\"},\"finish_reason\":null}],\"p\":\"a\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \\\"\"},\"finish_reason\":null}],\"p\":\"abcd\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"far\"},\"finish_reason\":null}],\"p\":\"abcde\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"mer\"},\"finish_reason\":null}],\"p\":\"abcde\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\\\"\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopq\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" or\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \\\"\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxy\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ear\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"th\"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"worker\"},\"finish_reason\":null}],\"p\":\"abcdefghi\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\\\".\"},\"finish_reason\":null}],\"p\":\"abcdefghi\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" It\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" mascul\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ine\"},\"finish_reason\":null}],\"p\":\"abcdefgh\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" given\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" name\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"finish_reason\":null}],\"p\":\"abcdefghij\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" it\"},\"finish_reason\":null}],\"p\":\"abcdefghi\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrst\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" quite\"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" popular\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvw\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Ser\"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"bia\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456789\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345678\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Mont\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"en\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"eg\"},\"finish_reason\":null}],\"p\":\"abcdefghijk\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ro\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234567\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"finish_reason\":null}],\"p\":\"abcdefghij\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" and\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" other\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopq\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" countries\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxy\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234567\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" the\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" region\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456789\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Without\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnop\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" more\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" context\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"finish_reason\":null}],\"p\":\"a\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" it\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345678\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"'\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"s\"},\"finish_reason\":null}],\"p\":\"abcde\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" hard\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxy\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" to\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234567\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" say\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrst\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" exactly\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" who\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwx\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" \\\"\"},\"finish_reason\":null}],\"p\":\"abcdefghijk\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"d\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"j\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqr\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ord\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"je\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxy\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\\\"\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnop\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" as\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz0123456\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" it\"},\"finish_reason\":null}],\"p\":\"abcde\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" could\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmn\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" refer\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstu\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" to\"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" any\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvw\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" person\"},\"finish_reason\":null}],\"p\":\"a\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" by\"},\"finish_reason\":null}],\"p\":\"abc\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" that\"},\"finish_reason\":null}],\"p\":\"abcdefghijk\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" name\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuv\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvw\"}\n\ndata: {\"id\":\"bc65a48dcb5f4ceb87bf4dfecbbd98df\",\"object\":\"chat.completion.chunk\",\"created\":1762205476,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":45,\"total_tokens\":157,\"completion_tokens\":112},\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: [DONE]\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 194.357125ms +- id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.mistral.ai + body: "{\"model\":\"mistral-small\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a title that captures the main topic.\"},{\"role\":\"user\",\"content\":\"Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text, nothing else.\\n\\nUser message:\\nuser: Who's djordje?\\n\\n\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}" + url: https://api.mistral.ai/v1/chat/completions + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\"},\"finish_reason\":null}]}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\\\"\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"In\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"quiry\"},\"finish_reason\":null}],\"p\":\"abcdefghijk\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" About\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012345678\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" the\"},\"finish_reason\":null}],\"p\":\"abcdefgh\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Identity\"},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" of\"},\"finish_reason\":null}],\"p\":\"abcdefghijkl\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\" '\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"D\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz01234\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"j\"},\"finish_reason\":null}],\"p\":\"abcdefghijklmnopqrstuvwxyz012\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ord\"},\"finish_reason\":null}],\"p\":\"abcdefghij\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"je\"},\"finish_reason\":null}],\"p\":\"abcdefghi\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"'\\\"\"},\"finish_reason\":null}],\"p\":\"abcdef\"}\n\ndata: {\"id\":\"172cd1b7656c4d5fb127fd0f2877700b\",\"object\":\"chat.completion.chunk\",\"created\":1762205478,\"model\":\"mistral-small\",\"choices\":[{\"index\":0,\"delta\":{\"content\":\"\"},\"finish_reason\":\"stop\"}],\"usage\":{\"prompt_tokens\":117,\"total_tokens\":131,\"completion_tokens\":14},\"p\":\"abcdefghijklmnopqrstuvwxyz\"}\n\ndata: [DONE]\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 184.777791ms diff --git a/e2e/testdata/cassettes/TestRuntime_OpenAI_Basic.yaml b/e2e/testdata/cassettes/TestRuntime_OpenAI_Basic.yaml new file mode 100644 index 000000000..a4f5a0a77 --- /dev/null +++ b/e2e/testdata/cassettes/TestRuntime_OpenAI_Basic.yaml @@ -0,0 +1,43 @@ +--- +version: 2 +interactions: +- id: 0 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.openai.com + body: "{\"model\":\"gpt-3.5-turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a knowledgeable assistant that helps users with various tasks.\\nBe helpful, accurate, and concise in your responses.\\n\"},{\"role\":\"user\",\"content\":\"Who's djordje?\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}" + url: https://api.openai.com/v1/chat/completions + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"LqOY4WRW\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"D\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"iJKdGub89\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"j\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"S1CUkxaof\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ord\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"FWpbkdz\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"je\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"woZeSwiJ\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"sLe2wCX\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" a\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"w5skKO0i\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" popular\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"3N\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" given\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"LlGM\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" name\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"0mDjs\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" in\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"YYNAppo\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" some\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"KDVhf\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Eastern\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"NB\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" European\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"6\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" countries\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"qlAAmjQjS\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" such\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"UWK6W\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" as\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"KhVujJf\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Serbia\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Hi2\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"J5P81ZVea\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" If\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"fvVNIrs\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" you\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"TqSEMM\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" have\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"iymrZ\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" more\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"aRyN4\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" specific\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"W\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" information\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"eZcxTe3FZQBQsV\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" or\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"Ts5oytf\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" context\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"j9\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\",\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"JzCE8I1Xp\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" I\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"chr4T0yH\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"'d\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"OtZ9Xfev\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" be\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"oHXqIeo\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" happy\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"mYXW\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" to\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"CARXVQp\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" help\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"TF2Qz\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" further\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"lS\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\".\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"QX22AcIMj\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"Wea5\"}\n\ndata: {\"id\":\"chatcmpl-CXwYBz8YV5SJhj9qsJtYhs1JccJFh\",\"object\":\"chat.completion.chunk\",\"created\":1762205927,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[],\"usage\":{\"prompt_tokens\":40,\"completion_tokens\":36,\"total_tokens\":76,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"oXVmbYeJd\"}\n\ndata: [DONE]\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 655.3085ms +- id: 1 + request: + proto: HTTP/1.1 + proto_major: 1 + proto_minor: 1 + content_length: 0 + host: api.openai.com + body: "{\"model\":\"gpt-3.5-turbo\",\"messages\":[{\"role\":\"system\",\"content\":\"You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a title that captures the main topic.\"},{\"role\":\"user\",\"content\":\"Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text, nothing else.\\n\\nUser message:\\nuser: Who's djordje?\\n\\n\"}],\"stream\":true,\"stream_options\":{\"include_usage\":true}}" + url: https://api.openai.com/v1/chat/completions + method: POST + response: + proto: HTTP/2.0 + proto_major: 2 + proto_minor: 0 + content_length: -1 + body: "data: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"\",\"refusal\":null},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"BBK5l77G\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"Understanding\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"dOhlvB8DPwPTW\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" identity\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"A\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\":\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"7L5QjtnaB\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Who\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"yfvZ6e\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" is\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"waEodPL\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\" Dj\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"D2zIOum\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"ord\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"5fnFvj4\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"je\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"tRk53yLt\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{\"content\":\"?\"},\"logprobs\":null,\"finish_reason\":null}],\"usage\":null,\"obfuscation\":\"bCo328Pxi\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[{\"index\":0,\"delta\":{},\"logprobs\":null,\"finish_reason\":\"stop\"}],\"usage\":null,\"obfuscation\":\"fAco\"}\n\ndata: {\"id\":\"chatcmpl-CXwYCRFlSir5vFGTcKJapa2Esk9gB\",\"object\":\"chat.completion.chunk\",\"created\":1762205928,\"model\":\"gpt-3.5-turbo-0125\",\"service_tier\":\"default\",\"system_fingerprint\":null,\"choices\":[],\"usage\":{\"prompt_tokens\":101,\"completion_tokens\":9,\"total_tokens\":110,\"prompt_tokens_details\":{\"cached_tokens\":0,\"audio_tokens\":0},\"completion_tokens_details\":{\"reasoning_tokens\":0,\"audio_tokens\":0,\"accepted_prediction_tokens\":0,\"rejected_prediction_tokens\":0}},\"obfuscation\":\"sz71LogV\"}\n\ndata: [DONE]\n\n" + headers: {} + status: 200 OK + code: 200 + duration: 504.911125ms diff --git a/e2e/testdata/file_writer.yaml b/e2e/testdata/file_writer.yaml new file mode 100644 index 000000000..c2d812a91 --- /dev/null +++ b/e2e/testdata/file_writer.yaml @@ -0,0 +1,10 @@ +version: "2" + +agents: + root: + model: openai/gpt-5-mini + instruction: You are a knowledgeable assistant that can write test files. + toolsets: + - type: filesystem + tools: + - write_file diff --git a/e2e/testdata/multi.yaml b/e2e/testdata/multi.yaml new file mode 100755 index 000000000..0e349477f --- /dev/null +++ b/e2e/testdata/multi.yaml @@ -0,0 +1,15 @@ +version: "2" + +agents: + root: + model: openai/gpt-5-mini + instruction: | + You are a knowledgeable assistant that helps users with various tasks. + Be helpful, accurate, and concise in your responses. + sub_agents: + - web + + web: + model: openai/gpt-5-mini + instruction: | + You are a knowledgeable assistant that helps users with web tasks. diff --git a/e2e/testdata/no_tools.yaml b/e2e/testdata/no_tools.yaml new file mode 100644 index 000000000..8427f162c --- /dev/null +++ b/e2e/testdata/no_tools.yaml @@ -0,0 +1,9 @@ +version: "2" + +agents: + root: + model: openai/gpt-3.5-turbo + description: A helpful AI assistant + instruction: | + You are a knowledgeable assistant that helps users with various tasks. + Be helpful, accurate, and concise in your responses. diff --git a/e2e/testdata/todo_tools.yaml b/e2e/testdata/todo_tools.yaml new file mode 100644 index 000000000..693752c42 --- /dev/null +++ b/e2e/testdata/todo_tools.yaml @@ -0,0 +1,12 @@ +version: "2" + +agents: + root: + model: openai/gpt-3.5-turbo + description: A helpful AI assistant + instruction: | + You are a knowledgeable assistant that helps users with various tasks. + Be helpful, accurate, and concise in your responses. + toolsets: + - type: todo + tools: ["create_todo", "list_todos"] diff --git a/examples/42.yaml b/examples/42.yaml index d7e447f41..b1467cffe 100755 --- a/examples/42.yaml +++ b/examples/42.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/README.md b/examples/README.md index c385a72b5..df51cdcea 100644 --- a/examples/README.md +++ b/examples/README.md @@ -36,7 +36,7 @@ These are more advanced examples, most of them involve some sort of MCP server t | [github.yaml](github.yaml) | GitHub assistance using MCP tools | | | | | | [github-official](https://hub.docker.com/mcp/server/github-official/overview) | | | [review.yaml](review.yaml) | Dockerfile review specialist | ✓ | | | | | | | | [code.yaml](code.yaml) | Code analysis and development assistant | ✓ | ✓ | ✓ | | | | | -| [go_packages.yml](go_packages.yml) | Golang packages expert | | | | | | | | +| [go_packages.yaml](go_packages.yaml) | Golang packages expert | | | | | | | | | [moby.yaml](moby.yaml) | Moby Project Expert | | | | | | `gitmcp.io/moby/moby` | | | [image_text_extractor.yaml](image_text_extractor.yaml) | Image text extraction | ✓ | | | | | | | | [doc_generator.yaml](doc_generator.yaml) | Documentation generation from codebases | | ✓ | | ✓ | | | | diff --git a/examples/airbnb.yaml b/examples/airbnb.yaml index 5b6603f7c..ec5da1023 100755 --- a/examples/airbnb.yaml +++ b/examples/airbnb.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/alloy.yaml b/examples/alloy.yaml index 8c7950fd6..977239320 100755 --- a/examples/alloy.yaml +++ b/examples/alloy.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/api-tool.yaml b/examples/api-tool.yaml new file mode 100644 index 000000000..ba4d2559e --- /dev/null +++ b/examples/api-tool.yaml @@ -0,0 +1,14 @@ +version: "2" + +agents: + root: + description: Chess.com daily puzzle agent + instruction: Call the daily-puzzle tool and print the puzzle in ascii with unicode chess pieces. Do not solve the puzzle for them, only provide hints and guidance. + model: google/gemini-2.5-pro + toolsets: + - type: api + api_config: + instruction: Get todos + name: daily-puzzle + endpoint: https://api.chess.com/pub/puzzle + method: GET diff --git a/examples/apify.yaml b/examples/apify.yaml index 2cc118a19..49801d513 100755 --- a/examples/apify.yaml +++ b/examples/apify.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/basic_agent.yaml b/examples/basic_agent.yaml index 82dcba810..50b119541 100644 --- a/examples/basic_agent.yaml +++ b/examples/basic_agent.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/bio.yaml b/examples/bio.yaml index f4eb8ba4c..bf9aa3874 100755 --- a/examples/bio.yaml +++ b/examples/bio.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/blog.yaml b/examples/blog.yaml index c00e0a07a..11e9086ce 100755 --- a/examples/blog.yaml +++ b/examples/blog.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/code.yaml b/examples/code.yaml index ebabf3f2c..0fa0ec08b 100755 --- a/examples/code.yaml +++ b/examples/code.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/code_mode.yaml b/examples/code_mode.yaml index 9ca838b19..f1cc21fb3 100755 --- a/examples/code_mode.yaml +++ b/examples/code_mode.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/contradict.yaml b/examples/contradict.yaml index d03a36521..1b6daf329 100755 --- a/examples/contradict.yaml +++ b/examples/contradict.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/couchbase_agent.yaml b/examples/couchbase_agent.yaml index 4a3654b0e..5433100ff 100644 --- a/examples/couchbase_agent.yaml +++ b/examples/couchbase_agent.yaml @@ -1,8 +1,8 @@ -version: "2" +#!/usr/bin/env cagent run agents: root: - model: openai + model: gpt description: Agent for answering questions, executing queries, and exploring data in your Couchbase database using the Docker MCP Couchbase server as a tool. instruction: | You are an expert Couchbase database assistant. Your job is to answer user questions related to the Couchbase database, execute N1QL queries, summarize data, help with troubleshooting, or provide documentation-style answers as requested. Use the Couchbase MCP server to run queries and fetch schema/data for better answers. If a user asks for query samples, data exploration, or troubleshooting, make sure to clarify the specific request if not clear, then use the tools as needed, and present results clearly and understandably. @@ -13,7 +13,7 @@ agents: add_environment_info: true models: - openai: + gpt: provider: openai model: gpt-4.1 max_tokens: 32000 diff --git a/examples/dev-team.yaml b/examples/dev-team.yaml index 01c82f454..6bc8be401 100755 --- a/examples/dev-team.yaml +++ b/examples/dev-team.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" models: model: diff --git a/examples/dhi/dhi.yaml b/examples/dhi/dhi.yaml index c1ebc920c..87b667d49 100755 --- a/examples/dhi/dhi.yaml +++ b/examples/dhi/dhi.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: @@ -78,10 +77,6 @@ agents: - type: todo models: - openai: - provider: openai - model: gpt-4o - claude: provider: anthropic model: claude-3-7-sonnet-latest diff --git a/examples/diag.yaml b/examples/diag.yaml index 500d0c19f..38552ef60 100755 --- a/examples/diag.yaml +++ b/examples/diag.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/dmr.yaml b/examples/dmr.yaml index d5340e276..658e8e009 100755 --- a/examples/dmr.yaml +++ b/examples/dmr.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/doc_generator.yaml b/examples/doc_generator.yaml index 1c764449d..0cc7e3d96 100755 --- a/examples/doc_generator.yaml +++ b/examples/doc_generator.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/echo-agent.yaml b/examples/echo-agent.yaml index 7de1e1e8b..f595f913b 100755 --- a/examples/echo-agent.yaml +++ b/examples/echo-agent.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" metadata: readme: | diff --git a/examples/env_placeholders.yaml b/examples/env_placeholders.yaml index 510376c95..d09a66a2d 100644 --- a/examples/env_placeholders.yaml +++ b/examples/env_placeholders.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" # Example demonstrating environment variable placeholder expansion in commands # diff --git a/examples/fetch_docker.yaml b/examples/fetch_docker.yaml index b998a039c..77791edaf 100644 --- a/examples/fetch_docker.yaml +++ b/examples/fetch_docker.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/filesystem.yaml b/examples/filesystem.yaml index 48531d16e..8a2095ea3 100644 --- a/examples/filesystem.yaml +++ b/examples/filesystem.yaml @@ -1,4 +1,4 @@ -version: "2" +#!/usr/bin/env cagent run agents: root: diff --git a/examples/finance.yaml b/examples/finance.yaml index 18d4abcc1..217612dd9 100755 --- a/examples/finance.yaml +++ b/examples/finance.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/github-toon.yaml b/examples/github-toon.yaml index 4a8958806..481f9a153 100644 --- a/examples/github-toon.yaml +++ b/examples/github-toon.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/github.yaml b/examples/github.yaml index 2a3408a41..f4201dcc0 100755 --- a/examples/github.yaml +++ b/examples/github.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/github_issue_manager.yaml b/examples/github_issue_manager.yaml index 453510153..0be1f36c9 100644 --- a/examples/github_issue_manager.yaml +++ b/examples/github_issue_manager.yaml @@ -1,4 +1,4 @@ -version: "2" +#!/usr/bin/env cagent run models: claude: diff --git a/examples/go_packages.yaml b/examples/go_packages.yaml index cf7184e66..625b2a39a 100644 --- a/examples/go_packages.yaml +++ b/examples/go_packages.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/golibrary/builtintool/main.go b/examples/golibrary/builtintool/main.go index 8d0d662d2..25e02d835 100644 --- a/examples/golibrary/builtintool/main.go +++ b/examples/golibrary/builtintool/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "log" "os" "os/signal" "syscall" @@ -22,7 +23,7 @@ func main() { defer cancel() if err := run(ctx); err != nil { - fmt.Println(err) + log.Println(err) } } diff --git a/examples/golibrary/multi/main.go b/examples/golibrary/multi/main.go index 7a1e2a0f5..99272a69d 100644 --- a/examples/golibrary/multi/main.go +++ b/examples/golibrary/multi/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "log" "os/signal" "syscall" @@ -21,7 +22,7 @@ func main() { defer cancel() if err := run(ctx); err != nil { - fmt.Println(err) + log.Println(err) } } diff --git a/examples/golibrary/simple/main.go b/examples/golibrary/simple/main.go index dd1ce80f7..27a9d8003 100644 --- a/examples/golibrary/simple/main.go +++ b/examples/golibrary/simple/main.go @@ -3,6 +3,7 @@ package main import ( "context" "fmt" + "log" "os/signal" "syscall" @@ -20,7 +21,7 @@ func main() { defer cancel() if err := run(ctx); err != nil { - fmt.Println(err) + log.Println(err) } } diff --git a/examples/golibrary/stream/main.go b/examples/golibrary/stream/main.go index d942a7d9c..7fd1a5ad1 100644 --- a/examples/golibrary/stream/main.go +++ b/examples/golibrary/stream/main.go @@ -2,7 +2,6 @@ package main import ( "context" - "fmt" "log" "os/signal" "syscall" @@ -21,7 +20,7 @@ func main() { defer cancel() if err := run(ctx); err != nil { - fmt.Println(err) + log.Println(err) } } diff --git a/examples/golibrary/tool/main.go b/examples/golibrary/tool/main.go index 61faca447..8a4bb2eac 100644 --- a/examples/golibrary/tool/main.go +++ b/examples/golibrary/tool/main.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "fmt" + "log" "os/signal" "syscall" @@ -22,7 +23,7 @@ func main() { defer cancel() if err := run(ctx); err != nil { - fmt.Println(err) + log.Println(err) } } diff --git a/examples/gopher.yaml b/examples/gopher.yaml index 0f30a5cf4..626e2c4d6 100755 --- a/examples/gopher.yaml +++ b/examples/gopher.yaml @@ -1,13 +1,12 @@ #!/usr/bin/env cagent run -version: "2" agents: root: model: anthropic/claude-sonnet-4-5 - description: Expert Golang developer specializing in implementing features and improving code quality. + description: Expert Golang Developer specialized in implementing features and improving code quality. instruction: | - Your main goal is to help with go code-related tasks by examining, modifying, and validating code changes. - Always use conversation context/state or tools to get information. Prefer tools over your own internal knowledge. + **Goal:** + Help with Go code-related tasks by examining, modifying, and validating code changes. **Workflow:** @@ -24,39 +23,24 @@ agents: - Maintain code style consistency 4. **Validation Loop**: - - Run linters or tests to check code quality + - Run linters and tests to check code quality - Verify changes meet requirements - If issues found, return to step 3 - Continue until all requirements are met 5. **Summary**: - - Very quickly summarize the changes made (not in a file) - - Highlight any areas for future improvement - - For trivial tasks, provide a very concise summary. Focus on answering the question, without extra information + - Very concisely summarize the changes made (not in a file) + - For trivial tasks, answer the question without extra information - **Tools:** - You have access to the following tools to assist you: - - `ast-grep` MCP tool for code pattern matching and transformations. Use it to search and modify code. Always prefer it over `filesystem` tools for such usecase. - - `filesystem` tools for reading and writing code files - - `shell` access for running linters and validators - - `todo` tools to organize your work. Use TODOs to break down tasks and track progress. Don't use them for trivial tasks. - - **Constraints:** + **Details:** - Be thorough in code examination before making changes - Always validate changes before considering the task complete - - Follow Go best practices and maintain code quality + - Follow Go best practices + - Maintain or improve code quality - Be proactive in identifying potential issues - Only ask for clarification if necessary, try your best to use all the tools to get the info you need - ## Core Responsibilities - - Develop, maintain, and enhance Go applications following best practices - - Build and test applications using the task-based build system - - Debug and optimize Go code with proper error handling and logging - - ## Best practices - - When searching or manipulating constants, variables or imports, don't forget that Go supports two forms: single and grouped. - add_date: true add_environment_info: true add_prompt_files: @@ -68,9 +52,11 @@ agents: - type: mcp command: gopls args: ["mcp"] + tools: ["go_diagnostics", "go_file_context", "go_search", "go_symbol_references"] - type: mcp ref: docker:ast-grep config: path: . commands: - fix-lint: "fix the lint issues" + fix-lint: "Fix the lint issues" + remove-comments-tests: "Remove useless comments in test files (*_test.go)" diff --git a/examples/grok.yaml b/examples/grok.yaml index 2d656e660..c4dcce482 100644 --- a/examples/grok.yaml +++ b/examples/grok.yaml @@ -1,4 +1,4 @@ -version: "2" +#!/usr/bin/env cagent run agents: root: diff --git a/examples/haiku.yaml b/examples/haiku.yaml index 9cb3fe589..b69d7638e 100755 --- a/examples/haiku.yaml +++ b/examples/haiku.yaml @@ -1,5 +1,4 @@ -#!/usr/bin/env cagent exec -version: "2" +#!/usr/bin/env cagent run agents: root: diff --git a/examples/image_text_extractor.yaml b/examples/image_text_extractor.yaml index d29faa065..475a7420e 100644 --- a/examples/image_text_extractor.yaml +++ b/examples/image_text_extractor.yaml @@ -1,4 +1,4 @@ -version: "2" +#!/usr/bin/env cagent run models: gpt4_vision: diff --git a/examples/k8s_debugger.yaml b/examples/k8s_debugger.yaml index 28db69c2f..83981a8ea 100644 --- a/examples/k8s_debugger.yaml +++ b/examples/k8s_debugger.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/mcp_generator.yaml b/examples/mcp_generator.yaml index 77d1124bf..8aed58522 100755 --- a/examples/mcp_generator.yaml +++ b/examples/mcp_generator.yaml @@ -1,9 +1,8 @@ #!/usr/bin/env cagent run -version: "2" agents: root: - model: openai + model: gpt description: Expert Python developer and code generator instruction: | You are an expert Python developer and code generator. @@ -42,7 +41,7 @@ agents: - type: shell models: - openai: + gpt: provider: openai model: gpt-4o temperature: 0.7 diff --git a/examples/mem.yaml b/examples/mem.yaml index 849ed4c9f..34fb92f2b 100755 --- a/examples/mem.yaml +++ b/examples/mem.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/mistral.yaml b/examples/mistral.yaml new file mode 100644 index 000000000..8c076a885 --- /dev/null +++ b/examples/mistral.yaml @@ -0,0 +1,9 @@ +#!/usr/bin/env cagent run + +agents: + root: + description: Omelette du Fromage + instruction: You are Le Chat. + model: mistral/mistral-small-latest + commands: + cheese: What is the best French cheese? diff --git a/examples/moby.yaml b/examples/moby.yaml index ded364e92..69d14f329 100755 --- a/examples/moby.yaml +++ b/examples/moby.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/multi-code.yaml b/examples/multi-code.yaml index 57fd32112..d348a2593 100755 --- a/examples/multi-code.yaml +++ b/examples/multi-code.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/nebius.yaml b/examples/nebius.yaml new file mode 100644 index 000000000..3138d43b5 --- /dev/null +++ b/examples/nebius.yaml @@ -0,0 +1,17 @@ +#!/usr/bin/env cagent run + +agents: + root: + model: grok-model + description: A helpful AI assistant powered by Nebius + instruction: | + You are a LLM model, a helpful and maximally truthful AI built by Moonshotai. + Be helpful, accurate, and concise in your responses. + You are not based on any other companies and their models. + +models: + grok-model: + provider: nebius + model: moonshotai/kimi-k2-instruct + max_tokens: 110000 + temperature: 0.7 \ No newline at end of file diff --git a/examples/notion-expert.yaml b/examples/notion-expert.yaml index ae3c897b4..ddea875cf 100644 --- a/examples/notion-expert.yaml +++ b/examples/notion-expert.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/pirate.yaml b/examples/pirate.yaml index f08805a42..e2f4ddaea 100755 --- a/examples/pirate.yaml +++ b/examples/pirate.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/post_edit.yaml b/examples/post_edit.yaml index f7a301b26..1f8c4a45c 100644 --- a/examples/post_edit.yaml +++ b/examples/post_edit.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/professional/professional_writing_agent.yaml b/examples/professional/professional_writing_agent.yaml index a3dab35ad..d6ec85c23 100644 --- a/examples/professional/professional_writing_agent.yaml +++ b/examples/professional/professional_writing_agent.yaml @@ -1,4 +1,4 @@ -version: "2" +#!/usr/bin/env cagent run models: claude: diff --git a/examples/pythonist.yaml b/examples/pythonist.yaml index 71c14514f..3c7d2cef3 100755 --- a/examples/pythonist.yaml +++ b/examples/pythonist.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/review.yaml b/examples/review.yaml index 78399c832..9bf17744b 100755 --- a/examples/review.yaml +++ b/examples/review.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/script_shell.yaml b/examples/script_shell.yaml index d6ed16538..ed2f7be14 100644 --- a/examples/script_shell.yaml +++ b/examples/script_shell.yaml @@ -1,9 +1,8 @@ #!/usr/bin/env cagent run -version: "2" agents: root: - model: openai + model: gpt description: An agent with custom shell commands instruction: You are a helpful assistant with access to custom shell tools. toolsets: @@ -31,7 +30,7 @@ agents: type: string models: - openai: + gpt: provider: openai model: gpt-4o max_tokens: 1500 diff --git a/examples/search.yaml b/examples/search.yaml index 0c71ed460..9f6ba7d91 100755 --- a/examples/search.yaml +++ b/examples/search.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/shared-todo.yaml b/examples/shared-todo.yaml index 579c337ad..7e7ffe171 100755 --- a/examples/shared-todo.yaml +++ b/examples/shared-todo.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/shell.yaml b/examples/shell.yaml index d89385b62..2d799d31e 100755 --- a/examples/shell.yaml +++ b/examples/shell.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/silvia.yaml b/examples/silvia.yaml index a259dd3e6..ec071abf8 100755 --- a/examples/silvia.yaml +++ b/examples/silvia.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/structured-output.yaml b/examples/structured-output.yaml index ac48005a5..3fe58e8e5 100644 --- a/examples/structured-output.yaml +++ b/examples/structured-output.yaml @@ -1,4 +1,4 @@ -version: "2" +#!/usr/bin/env cagent run models: gpt4_structured: diff --git a/examples/thinking_budget.yaml b/examples/thinking_budget.yaml index fb5034209..3cbd7f0a0 100644 --- a/examples/thinking_budget.yaml +++ b/examples/thinking_budget.yaml @@ -3,8 +3,6 @@ # Run the demo command with: # cagent run thinking_budget.yaml -c demo -version: "2" - agents: root: model: gpt-5-mini-min # <- try with gpt-5-mini-high diff --git a/examples/todo.yaml b/examples/todo.yaml index e971ec92d..9ecdb3d0c 100755 --- a/examples/todo.yaml +++ b/examples/todo.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: diff --git a/examples/typo.yaml b/examples/typo.yaml new file mode 100644 index 000000000..3836e3466 --- /dev/null +++ b/examples/typo.yaml @@ -0,0 +1,20 @@ +#!/usr/bin/env cagent run + +agents: + root: + model: model + description: Fix typos. + instruction: | + Your goal is to fix typos in a project. + Read as many files as you can at once and fix typos in them. + Fix typos and don't print any summary. + toolsets: + - type: filesystem + tools: ["list_directory", "edit_file", "read_multiple_files", "write_file"] + commands: + - fix: Fix every typo, in every file, in the current directory. + +models: + model: + provider: anthropic + model: claude-sonnet-4-5 diff --git a/examples/welcome_message.yaml b/examples/welcome_message.yaml new file mode 100644 index 000000000..5eeb3def7 --- /dev/null +++ b/examples/welcome_message.yaml @@ -0,0 +1,23 @@ +#!/usr/bin/env cagent run +version: "2" + +agents: + root: + model: openai/gpt-4o-mini + description: A helpful AI assistant with a welcome message + welcome_message: | + 👋 **Welcome to cagent!** + + I'm your AI assistant, ready to help you with various tasks. + + Here are some things I can do: + - Answer questions and provide information + - Help with coding and technical problems + - Assist with writing and content creation + - And much more! + + Just type your question or request below to get started. + instruction: | + You are a knowledgeable and helpful assistant. + Be friendly, accurate, and concise in your responses. + Always aim to provide value to the user. diff --git a/examples/writer.yaml b/examples/writer.yaml index 0d1bf3900..7739868c2 100755 --- a/examples/writer.yaml +++ b/examples/writer.yaml @@ -1,9 +1,8 @@ #!/usr/bin/env cagent run -version: "2" agents: root: - model: openai + model: gpt description: Writes a nice story about a subject instruction: | You are the leader of a team of AI agents for a daily writing workflow. @@ -33,7 +32,7 @@ agents: - type: think prompt_chooser: - model: openai + model: gpt description: Selects the best prompt from the 5 generated by the root agent. instruction: | You are an agent that receives 5 writing prompts and selects the one that is most interesting, challenging, or suitable for a 750-word writing exercise. @@ -44,7 +43,7 @@ agents: - Once you are done, transfer the session to the `writer` agent. writer: - model: openai + model: gpt description: Writes a 750-word text based on the chosen prompt. instruction: | You are an agent that receives a single writing prompt and generates a detailed, engaging, and well-structured 750-word text in response. @@ -52,6 +51,6 @@ agents: - Ensure the response is creative, reflective, analytical, or imaginative as appropriate for the prompt. models: - openai: + gpt: provider: openai model: gpt-4o diff --git a/go.mod b/go.mod index c897ca662..f95cf6d68 100644 --- a/go.mod +++ b/go.mod @@ -3,20 +3,20 @@ module github.com/docker/cagent go 1.25.3 require ( + charm.land/bubbles/v2 v2.0.0-beta.1.0.20251104200223-da0b892d1759 + charm.land/bubbletea/v2 v2.0.0-rc.1 + charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 dario.cat/mergo v1.0.2 github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0 github.com/Microsoft/go-winio v0.6.2 github.com/alecthomas/chroma/v2 v2.20.0 github.com/alpkeskin/gotoon v0.1.0 - github.com/anthropics/anthropic-sdk-go v1.14.0 + github.com/anthropics/anthropic-sdk-go v1.16.0 github.com/atotto/clipboard v0.1.4 github.com/aymanbagabas/go-udiff v0.3.1 - github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 - github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7 - github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 - github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea - github.com/charmbracelet/x/ansi v0.10.2 - github.com/coder/acp-go-sdk v0.4.9 + github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930 + github.com/charmbracelet/x/ansi v0.11.0 + github.com/coder/acp-go-sdk v0.6.3 github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 github.com/fatih/color v1.18.0 github.com/goccy/go-yaml v1.18.0 @@ -27,7 +27,7 @@ require ( github.com/k3a/html2text v1.2.1 github.com/labstack/echo/v4 v4.13.4 github.com/mattn/go-runewidth v0.0.19 - github.com/modelcontextprotocol/go-sdk v1.0.0 + github.com/modelcontextprotocol/go-sdk v1.1.0 github.com/sashabaranov/go-openai v1.41.2 github.com/spf13/cobra v1.10.1 github.com/stretchr/testify v1.11.1 @@ -40,41 +40,43 @@ require ( golang.org/x/oauth2 v0.32.0 golang.org/x/sys v0.37.0 golang.org/x/term v0.36.0 - google.golang.org/genai v1.31.0 + google.golang.org/genai v1.33.0 + gopkg.in/dnaeon/go-vcr.v4 v4.0.5 + gotest.tools/v3 v3.0.3 modernc.org/sqlite v1.39.1 ) require ( - cloud.google.com/go v0.121.6 // indirect - cloud.google.com/go/auth v0.16.5 // indirect - cloud.google.com/go/compute/metadata v0.8.0 // indirect + cloud.google.com/go v0.116.0 // indirect + cloud.google.com/go/auth v0.9.3 // indirect + cloud.google.com/go/compute/metadata v0.7.0 // indirect github.com/JohannesKaufmann/dom v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect github.com/cenkalti/backoff/v5 v5.0.3 // indirect - github.com/charmbracelet/colorprofile v0.3.2 // indirect - github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef // indirect - github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a // indirect - github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d // indirect - github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/charmbracelet/colorprofile v0.3.3 // indirect + github.com/charmbracelet/ultraviolet v0.0.0-20251106193841-7889546fc720 // indirect + github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf // indirect + github.com/charmbracelet/x/term v0.2.2 // indirect github.com/charmbracelet/x/termios v0.1.1 // indirect github.com/charmbracelet/x/windows v0.2.2 // indirect - github.com/clipperhouse/uax29/v2 v2.2.0 // indirect - github.com/containerd/stargz-snapshotter/estargz v0.17.0 // indirect + github.com/clipperhouse/displaywidth v0.4.1 // indirect + github.com/clipperhouse/stringish v0.1.1 // indirect + github.com/clipperhouse/uax29/v2 v2.3.0 // indirect + github.com/containerd/stargz-snapshotter/estargz v0.16.3 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/dlclark/regexp2 v1.11.5 // indirect - github.com/docker/cli v28.3.3+incompatible // indirect + github.com/docker/cli v28.2.2+incompatible // indirect github.com/docker/distribution v2.8.3+incompatible // indirect github.com/docker/docker-credential-helpers v0.9.3 // indirect github.com/dustin/go-humanize v1.0.1 // indirect - github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/go-sourcemap/sourcemap v2.1.3+incompatible // indirect + github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e // indirect - github.com/google/s2a-go v0.1.9 // indirect - github.com/googleapis/enterprise-certificate-proxy v0.3.6 // indirect - github.com/googleapis/gax-go/v2 v2.15.0 // indirect + github.com/google/s2a-go v0.1.8 // indirect + github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/websocket v1.5.3 // indirect github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.2 // indirect @@ -109,20 +111,20 @@ require ( github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/yosida95/uritemplate/v3 v3.0.2 // indirect github.com/yuin/goldmark v1.7.13 // indirect - github.com/yuin/goldmark-emoji v1.0.6 // indirect + github.com/yuin/goldmark-emoji v1.0.5 // indirect + go.opencensus.io v0.24.0 // indirect go.opentelemetry.io/auto/sdk v1.1.0 // indirect - go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 // indirect go.opentelemetry.io/otel/metric v1.38.0 // indirect go.opentelemetry.io/proto/otlp v1.7.1 // indirect - golang.org/x/crypto v0.43.0 // indirect - golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b // indirect - golang.org/x/net v0.46.0 // indirect + golang.org/x/crypto v0.41.0 // indirect + golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect + golang.org/x/net v0.43.0 // indirect golang.org/x/sync v0.17.0 // indirect - golang.org/x/text v0.30.0 // indirect - golang.org/x/time v0.14.0 // indirect - google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 // indirect + golang.org/x/text v0.28.0 // indirect + golang.org/x/time v0.11.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 // indirect google.golang.org/grpc v1.75.0 // indirect google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index ce8b39667..dedd5c4dc 100644 --- a/go.sum +++ b/go.sum @@ -1,11 +1,19 @@ -cloud.google.com/go v0.121.6 h1:waZiuajrI28iAf40cWgycWNgaXPO06dupuS+sgibK6c= -cloud.google.com/go v0.121.6/go.mod h1:coChdst4Ea5vUpiALcYKXEpR1S9ZgXbhEzzMcMR66vI= -cloud.google.com/go/auth v0.16.5 h1:mFWNQ2FEVWAliEQWpAdH80omXFokmrnbDhUS9cBywsI= -cloud.google.com/go/auth v0.16.5/go.mod h1:utzRfHMP+Vv0mpOkTRQoWD2q3BatTOoWbA7gCc2dUhQ= -cloud.google.com/go/compute/metadata v0.8.0 h1:HxMRIbao8w17ZX6wBnjhcDkW6lTFpgcaobyVfZWqRLA= -cloud.google.com/go/compute/metadata v0.8.0/go.mod h1:sYOGTp851OV9bOFJ9CH7elVvyzopvWQFNNghtDQ/Biw= +charm.land/bubbles/v2 v2.0.0-beta.1.0.20251104200223-da0b892d1759 h1:P1MxkVl8ZeI9tHmmrn9UzV/5Mz7heoiTgqECHRFsUcs= +charm.land/bubbles/v2 v2.0.0-beta.1.0.20251104200223-da0b892d1759/go.mod h1:G7JWaj3kDT0BDB+h5BLDUhhBLpDoRLKrpOp5QrA2SQs= +charm.land/bubbletea/v2 v2.0.0-rc.1 h1:T+59BPDHkDJ1rjkWluTol3pKW0LcZoIMNosyALOIwZY= +charm.land/bubbletea/v2 v2.0.0-rc.1/go.mod h1:001PaYn0OSAHMEEZ5d2Oh3E6hA6vs5LtYvQOAZgIiao= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410 h1:D9PbaszZYpB4nj+d6HTWr1onlmlyuGVNfL9gAi8iB3k= +charm.land/lipgloss/v2 v2.0.0-beta.3.0.20251106193318-19329a3e8410/go.mod h1:1qZyvvVCenJO2M1ac2mX0yyiIZJoZmDM4DG4s0udJkU= +cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE= +cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U= +cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U= +cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk= +cloud.google.com/go/compute/metadata v0.7.0 h1:PBWF+iiAerVNe8UCHxdOt6eHLVc3ydFeOCw78U8ytSU= +cloud.google.com/go/compute/metadata v0.7.0/go.mod h1:j5MvL9PprKL39t166CoB1uVHfQMs4tFQZZcKwksXUjo= dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= +github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ= github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo= github.com/JohannesKaufmann/html-to-markdown/v2 v2.4.0 h1:C0/TerKdQX9Y9pbYi1EsLr5LDNANsqunyI/btpyfCg8= @@ -24,8 +32,8 @@ github.com/alecthomas/repr v0.5.1 h1:E3G4t2QbHTSNpPKBgMTln5KLkZHLOcU7r37J4pXBuIg github.com/alecthomas/repr v0.5.1/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= github.com/alpkeskin/gotoon v0.1.0 h1:qtKx9kqpTYycxEolgHctyOmu2L5CUNwEeXfMizscO/k= github.com/alpkeskin/gotoon v0.1.0/go.mod h1:eCkjhBz/wmCoXAWKERuhPSb3+jW7ajluruYIgsfbriU= -github.com/anthropics/anthropic-sdk-go v1.14.0 h1:EzNQvnZlaDHe2UPkoUySDz3ixRgNbwKdH8KtFpv7pi4= -github.com/anthropics/anthropic-sdk-go v1.14.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= +github.com/anthropics/anthropic-sdk-go v1.16.0 h1:nRkOFDqYXsHteoIhjdJr/5dsiKbFF3rflSv8ax50y8o= +github.com/anthropics/anthropic-sdk-go v1.16.0/go.mod h1:WTz31rIUHUHqai2UslPpw5CwXrQP3geYBioRV4WOLvE= github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4= github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI= github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY= @@ -34,46 +42,45 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2 h1:973OHYuq2Jx9deyuPwe/6lsuQrDCatOsjP8uCd02URE= -github.com/charmbracelet/bubbles/v2 v2.0.0-beta.1.0.20250820203609-601216f68ee2/go.mod h1:6HamsBKWqEC/FVHuQMHgQL+knPyvHH55HwJDHl/adMw= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7 h1:wH4F+UvxcZSDOxy8j45tghiRo8amrYHejbE9+1C6xv0= -github.com/charmbracelet/bubbletea/v2 v2.0.0-beta.4.0.20250930175933-4cafc092c5e7/go.mod h1:5IzIGXU1n0foRc8bRAherC8ZuQCQURPlwx3ANLq1138= -github.com/charmbracelet/colorprofile v0.3.2 h1:9J27WdztfJQVAQKX2WOlSSRB+5gaKqqITmrvb1uTIiI= -github.com/charmbracelet/colorprofile v0.3.2/go.mod h1:mTD5XzNeWHj8oqHb+S1bssQb7vIHbepiebQ2kPKVKbI= -github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018 h1:PU4Zvpagsk5sgaDxn5W4sxHuLp9QRMBZB3bFSk40A4w= -github.com/charmbracelet/glamour/v2 v2.0.0-20250811143442-a27abb32f018/go.mod h1:Z/GLmp9fzaqX4ze3nXG7StgWez5uBM5XtlLHK8V/qSk= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea h1:g1HfUgSMvye8mgecMD1mPscpt+pzJoDEiSA+p2QXzdQ= -github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.3.0.20250917201909-41ff0bf215ea/go.mod h1:ngHerf1JLJXBrDXdphn5gFrBPriCL437uwukd5c93pM= -github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef h1:VrWaUi2LXYLjfjCHowdSOEc6dQ9Ro14KY7Bw4IWd19M= -github.com/charmbracelet/ultraviolet v0.0.0-20250915111650-81d4262876ef/go.mod h1:AThRsQH1t+dfyOKIwXRoJBniYFQUkUpQq4paheHMc2o= -github.com/charmbracelet/x/ansi v0.10.2 h1:ith2ArZS0CJG30cIUfID1LXN7ZFXRCww6RUvAPA+Pzw= -github.com/charmbracelet/x/ansi v0.10.2/go.mod h1:HbLdJjQH4UH4AqA2HpRWuWNluRE6zxJH/yteYEYCFa8= -github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a h1:zYSNtEJM9jwHbJts2k+Hroj+xQwsW1yxc4Wopdv7KaI= -github.com/charmbracelet/x/cellbuf v0.0.14-0.20250811133356-e0c5dbe5ea4a/go.mod h1:rc2bsPC6MWae3LdOxNO1mOb443NlMrrDL0xEya48NNc= -github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a h1:FsHEJ52OC4VuTzU8t+n5frMjLvpYWEznSr/u8tnkCYw= -github.com/charmbracelet/x/exp/golden v0.0.0-20250207160936-21c02780d27a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U= -github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d h1:H2oh4WlSsXy8qwLd7I3eAvPd/X3S40aM9l+h47WF1eA= -github.com/charmbracelet/x/exp/slice v0.0.0-20250829135019-44e44e21330d/go.mod h1:vI5nDVMWi6veaYH+0Fmvpbe/+cv/iJfMntdh+N0+Tms= -github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= -github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= +github.com/charmbracelet/colorprofile v0.3.3 h1:DjJzJtLP6/NZ8p7Cgjno0CKGr7wwRJGxWUwh2IyhfAI= +github.com/charmbracelet/colorprofile v0.3.3/go.mod h1:nB1FugsAbzq284eJcjfah2nhdSLppN2NqvfotkfRYP4= +github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930 h1:+47Z2jVAWPSLGjPRbfZizW3OpcAYsu7EUk2DR+66FyM= +github.com/charmbracelet/glamour/v2 v2.0.0-20251106195642-800eb8175930/go.mod h1:izs11tnkYaT3DTEH2E0V/lCb18VGZ7k9HLYEGuvgXGA= +github.com/charmbracelet/ultraviolet v0.0.0-20251106193841-7889546fc720 h1:Pny/vp+ySKst82CWEME1oP6YEFs/17tlH+QOjqW7VUY= +github.com/charmbracelet/ultraviolet v0.0.0-20251106193841-7889546fc720/go.mod h1:Y8B4DzWeTb0ama8l3+KyopZtkE8fZjwRQ3aEAPEXHE0= +github.com/charmbracelet/x/ansi v0.11.0 h1:uuIVK7GIplwX6UBIz8S2TF8nkr7xRlygSsBRjSJqIvA= +github.com/charmbracelet/x/ansi v0.11.0/go.mod h1:uQt8bOrq/xgXjlGcFMc8U2WYbnxyjrKhnvTQluvfCaE= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA= +github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf h1:rLG0Yb6MQSDKdB52aGX55JT1oi0P0Kuaj7wi1bLUpnI= +github.com/charmbracelet/x/exp/slice v0.0.0-20250327172914-2fdc97757edf/go.mod h1:B3UgsnsBZS/eX42BlaNiJkD1pPOUa+oF1IYC6Yd2CEU= +github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk= +github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI= github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY= github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo= github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM= github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k= -github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= -github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= -github.com/coder/acp-go-sdk v0.4.9 h1:F4sKT2up4sMqNYt6yt2L9g4MaE09VPgt3eRqDFnoY5k= -github.com/coder/acp-go-sdk v0.4.9/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= -github.com/containerd/stargz-snapshotter/estargz v0.17.0 h1:+TyQIsR/zSFI1Rm31EQBwpAA1ovYgIKHy7kctL3sLcE= -github.com/containerd/stargz-snapshotter/estargz v0.17.0/go.mod h1:s06tWAiJcXQo9/8AReBCIo/QxcXFZ2n4qfsRnpl71SM= +github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw= +github.com/clipperhouse/displaywidth v0.4.1 h1:uVw9V8UDfnggg3K2U84VWY1YLQ/x2aKSCtkRyYozfoU= +github.com/clipperhouse/displaywidth v0.4.1/go.mod h1:R+kHuzaYWFkTm7xoMmK1lFydbci4X2CicfbGstSGg0o= +github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs= +github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA= +github.com/clipperhouse/uax29/v2 v2.3.0 h1:SNdx9DVUqMoBuBoW3iLOj4FQv3dN5mDtuqwuhIGpJy4= +github.com/clipperhouse/uax29/v2 v2.3.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g= +github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= +github.com/coder/acp-go-sdk v0.6.3 h1:LsXQytehdjKIYJnoVWON/nf7mqbiarnyuyE3rrjBsXQ= +github.com/coder/acp-go-sdk v0.6.3/go.mod h1:yKzM/3R9uELp4+nBAwwtkS0aN1FOFjo11CNPy37yFko= +github.com/containerd/stargz-snapshotter/estargz v0.16.3 h1:7evrXtoh1mSbGj/pfRccTampEyKpjpOnS3CyiV1Ebr8= +github.com/containerd/stargz-snapshotter/estargz v0.16.3/go.mod h1:uyr4BfYfOj3G9WBVE8cOlQmXAbPN9VEQpBBeJIuOipU= github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 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= github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ= github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= -github.com/docker/cli v28.3.3+incompatible h1:fp9ZHAr1WWPGdIWBM1b3zLtgCF+83gRdVMTJsUeiyAo= -github.com/docker/cli v28.3.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= +github.com/docker/cli v28.2.2+incompatible h1:qzx5BNUDFqlvyq4AHzdNB7gSyVTmU4cgsyN9SdInc1A= +github.com/docker/cli v28.2.2+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/distribution v2.8.3+incompatible h1:AtKxIZ36LoNK51+Z6RpzLpddBirtxJnzDrHLEKxTAYk= github.com/docker/distribution v2.8.3+incompatible/go.mod h1:J2gT2udsDAN96Uj4KfcMRqY0/ypR+oyYUYmja8H+y+w= github.com/docker/docker-credential-helpers v0.9.3 h1:gAm/VtF9wgqJMoxzT3Gj5p4AqIjCBS4wrsOh9yRqcz8= @@ -82,10 +89,12 @@ github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6 h1:6dE1TmjqkY6tehR4A67 github.com/dop251/goja v0.0.0-20251008123653-cf18d89f3cf6/go.mod h1:MxLav0peU43GgvwVgNbLAj1s/bSGboKkhuULvq/7hx4= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= +github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= +github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= -github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= -github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= @@ -95,8 +104,28 @@ github.com/go-sourcemap/sourcemap v2.1.3+incompatible h1:W1iEw64niKVGogNgBN3ePyL github.com/go-sourcemap/sourcemap v2.1.3+incompatible/go.mod h1:F8jJfvm2KbVjc5NqelyYJmf/v5J0dwNLS2mL4sNA1Jg= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q= +github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= +github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= +github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8= +github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-containerregistry v0.20.6 h1:cvWX87UxxLgaH76b4hIvya6Dzz9qHB31qAwjAohdSTU= @@ -105,14 +134,13 @@ github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIy github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= -github.com/google/s2a-go v0.1.9 h1:LGD7gtMgezd8a/Xak7mEWL0PjoTQFvpRudN895yqKW0= -github.com/google/s2a-go v0.1.9/go.mod h1:YA0Ei2ZQL3acow2O62kdp9UlnvMmU7kA6Eutn0dXayM= +github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM= +github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA= +github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= -github.com/googleapis/enterprise-certificate-proxy v0.3.6 h1:GW/XbdyBFQ8Qe+YAmFU9uHLo7OnF5tL52HFAgMmyrf4= -github.com/googleapis/enterprise-certificate-proxy v0.3.6/go.mod h1:MkHOF77EYAE7qfSuSS9PU6g4Nt4e11cnsDUowfwewLA= -github.com/googleapis/gax-go/v2 v2.15.0 h1:SyjDc1mGgZU5LncH8gimWo9lW1DtIfPibOG81vgd/bo= -github.com/googleapis/gax-go/v2 v2.15.0/go.mod h1:zVVkkxAQHa1RQpg9z2AUCMnKhi0Qld9rcmyfL1OZhoc= +github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw= +github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1 h1:EGx4pi6eqNxGaHF6qqu48+N2wcFQ5qg5FXgOdqsJ5d8= github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= @@ -153,8 +181,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0= -github.com/modelcontextprotocol/go-sdk v1.0.0 h1:Z4MSjLi38bTgLrd/LjSmofqRqyBiVKRyQSJgw8q8V74= -github.com/modelcontextprotocol/go-sdk v1.0.0/go.mod h1:nYtYQroQ2KQiM0/SbyEPUWQ6xs4B95gJjEalc9AQyOs= +github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA= +github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= @@ -163,10 +191,12 @@ github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8 github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM= github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= @@ -188,13 +218,19 @@ github.com/smartystreets/goconvey v1.6.4 h1:fv0U8FUIMPNf1L9lnHLvLhgicrIVChEkdzIK github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA= github.com/spf13/cobra v1.10.1 h1:lJeBwCfmrnXthfAupyUTzJ/J4Nc1RsHC/mSRU2dll/s= github.com/spf13/cobra v1.10.1/go.mod h1:7SmJGaTHFVBY0jW4NXGluQoLvhqFQM+6XSKD+P4XaB0= +github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4= github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/temoto/robotstxt v1.1.2 h1:W2pOjSJ6SWvldyEuiFXNxz3xZ8aiWX5LbfDiOFd7Fxg= @@ -225,14 +261,15 @@ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavM github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4= github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4= +github.com/yuin/goldmark v1.7.1/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= -github.com/yuin/goldmark-emoji v1.0.6 h1:QWfF2FYaXwL74tfGOW5izeiZepUDroDJfWubQI9HTHs= -github.com/yuin/goldmark-emoji v1.0.6/go.mod h1:ukxJDKFpdFb5x0a5HqbdlcKtebh086iJpI31LTKmWuA= +github.com/yuin/goldmark-emoji v1.0.5 h1:EMVWyCGPlXJfUXBXpuMu+ii3TIaxbVBnEX9uaDC4cIk= +github.com/yuin/goldmark-emoji v1.0.5/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U= +go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= +go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18= -go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg= go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.38.0 h1:GqRJVj7UmLjCVyVJ3ZFLdPRmhDUp2zFmQe3RHIOsw24= @@ -252,20 +289,37 @@ go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzK go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04= -golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b h1:DXr+pvt3nC887026GRP39Ej11UATqWDmWuS99x26cD0= -golang.org/x/exp v0.0.0-20250819193227-8b4c13bb791b/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= -golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U= -golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.41.0 h1:WKYxWedPGCTVVl5+WHSSrOBT0O8lx32+zxmHxijgXp4= +golang.org/x/crypto v0.41.0/go.mod h1:pO5AFd7FA68rFak7rOAGVuygIISepHftHnr8dr6+sUc= +golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o= +golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8= +golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= +golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU= +golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= +golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ= +golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc= +golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= -golang.org/x/net v0.46.0 h1:giFlY12I07fugqwPuWJi68oOnpfqFnJIJzaIIm2JVV4= -golang.org/x/net v0.46.0/go.mod h1:Q9BGdFy1y4nkUwiLvT5qtyhAnEHgnQ/zd8PfU6nc210= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE= +golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg= +golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.32.0 h1:jsCblLleRMDrxMN29H3z/k1KliIvpLgCkE6R8FXXNgY= golang.org/x/oauth2 v0.32.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= +golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ= @@ -273,28 +327,57 @@ golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q= golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k= -golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM= -golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= -golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= +golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= +golang.org/x/time v0.11.0 h1:/bpjEDfN9tkoN/ryeYHnv5hcMlc8ncjMcM4XBk5NWV0= +golang.org/x/time v0.11.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= +golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= -golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE= -golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w= +golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= +golang.org/x/tools v0.0.0-20190624222133-a101b041ded4/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc= +golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg= +golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genai v1.31.0 h1:R7xDt/Dosz11vcXbZ4IgisGnzUGGau2PZOIOAnXsYjw= -google.golang.org/genai v1.31.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= -google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1 h1:APHvLLYBhtZvsbnpkfknDZ7NyH4z5+ub/I0u8L3Oz6g= -google.golang.org/genproto/googleapis/api v0.0.0-20250826171959-ef028d996bc1/go.mod h1:xUjFWUnWDpZ/C0Gu0qloASKFb6f8/QXiiXhSPFsD668= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1 h1:pmJpJEvT846VzausCQ5d7KreSROcDqmO388w5YbnltA= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250826171959-ef028d996bc1/go.mod h1:GmFNa4BdJZ2a8G+wCe9Bg3wwThLrJun751XstdJt5Og= +google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= +google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +google.golang.org/genai v1.33.0 h1:DExzJZbSbxSRmwX2gCsZ+V9vb6rjdmsOAy47ASBgKvg= +google.golang.org/genai v1.33.0/go.mod h1:7pAilaICJlQBonjKKJNhftDFv3SREhZcTe9F6nRcjbg= +google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= +google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= +google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5 h1:BIRfGDEjiHRrk0QKZe3Xv2ieMhtgRGeLcZQ0mIVn4EY= +google.golang.org/genproto/googleapis/api v0.0.0-20250825161204-c5933d9347a5/go.mod h1:j3QtIyytwqGr1JUDtYXwtMXWPKsEa5LtzIFN1Wn5WvE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5 h1:eaY8u2EuxbRv7c3NiGK0/NedzVsCcV6hDuU5qPX5EGE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250825161204-c5933d9347a5/go.mod h1:M4/wBTSeyLxupu3W3tJtOgB14jILAS/XWPSSa3TAlJc= +google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= +google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= +google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= +google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= +google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc= google.golang.org/grpc v1.75.0 h1:+TW+dqTd2Biwe6KKfhE5JpiYIBWq865PhKGSXiivqt4= google.golang.org/grpc v1.75.0/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c= google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/dnaeon/go-vcr.v4 v4.0.5 h1:I0hpTIvD5rII+8LgYGrHMA2d4SQPoL6u7ZvJakWKsiA= +gopkg.in/dnaeon/go-vcr.v4 v4.0.5/go.mod h1:dRos81TkW9C1WJt6tTaE+uV2Lo8qJT3AG2b35+CB/nQ= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= @@ -302,6 +385,8 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools/v3 v3.0.3 h1:4AuOwCGf4lLR9u3YOe2awrHygurzhO/HeQ6laiA6Sx0= gotest.tools/v3 v3.0.3/go.mod h1:Z7Lb0S5l+klDB31fvDQX8ss/FlKDxtlFlw3Oa8Ymbl8= +honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= +honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4= modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A= diff --git a/golang_developer.yaml b/golang_developer.yaml index be71e7c2f..45bf0c859 100755 --- a/golang_developer.yaml +++ b/golang_developer.yaml @@ -1,5 +1,4 @@ #!/usr/bin/env cagent run -version: "2" agents: root: @@ -39,7 +38,7 @@ agents: * Be proactive in identifying potential issues * Only ask for clarification if necessary, try your best to use all the tools to get the info you need * Don't show the code that you generated - * Nerver write summary docuemnts, only code changes + * Never write summary documents, only code changes ## Core Responsibilities - Develop, maintain, and enhance Go applications following best practices @@ -103,3 +102,15 @@ agents: - type: todo - type: mcp ref: docker:context7 + + planner: + model: anthropic/claude-sonnet-4-5 + instruction: | + You are a planning agent responsible for gathering user requirements and creating a development plan. + Always ask clarifying questions to ensure you fully understand the user's needs before creating the plan. + Once you have a clear understanding, analyze the existing code and create a detailed development plan in a markdown file. Do not write any code yourself. + Once the plan is created, you will delegate tasks to the root agent. Make sure to provide the file name of the plan when delegating. Write the plan in the current directory. + toolsets: + - type: filesystem + sub_agents: + - root diff --git a/main.go b/main.go index cf4502cb9..edc711bbb 100644 --- a/main.go +++ b/main.go @@ -1,13 +1,22 @@ package main import ( + "context" "os" + "os/signal" + "syscall" "github.com/docker/cagent/cmd/root" ) func main() { - if err := root.Execute(); err != nil { + ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + + if err := root.Execute(ctx, os.Stdin, os.Stdout, os.Stderr, os.Args[1:]...); err != nil { + cancel() os.Exit(1) + } else { + cancel() + os.Exit(0) } } diff --git a/pkg/acp/agent.go b/pkg/acp/agent.go index e3e25a3c2..9a8400cf1 100644 --- a/pkg/acp/agent.go +++ b/pkg/acp/agent.go @@ -59,7 +59,7 @@ func (a *Agent) Stop(ctx context.Context) { } } -// SetConnection sets the ACP connection +// SetAgentConnection sets the ACP connection func (a *Agent) SetAgentConnection(conn *acp.AgentSideConnection) { a.conn = conn } @@ -299,7 +299,7 @@ func (a *Agent) handleToolCallConfirmation(ctx context.Context, acpSess *Session // Handle permission outcome if permResp.Outcome.Cancelled != nil { - acpSess.rt.Resume(ctx, string(runtime.ResumeTypeReject)) + acpSess.rt.Resume(ctx, runtime.ResumeTypeReject) return nil } @@ -309,11 +309,11 @@ func (a *Agent) handleToolCallConfirmation(ctx context.Context, acpSess *Session switch string(permResp.Outcome.Selected.OptionId) { case "allow": - acpSess.rt.Resume(ctx, string(runtime.ResumeTypeApprove)) + acpSess.rt.Resume(ctx, runtime.ResumeTypeApprove) case "allow-always": - acpSess.rt.Resume(ctx, string(runtime.ResumeTypeApproveSession)) + acpSess.rt.Resume(ctx, runtime.ResumeTypeApproveSession) case "reject": - acpSess.rt.Resume(ctx, string(runtime.ResumeTypeReject)) + acpSess.rt.Resume(ctx, runtime.ResumeTypeReject) default: return fmt.Errorf("unexpected permission option: %s", permResp.Outcome.Selected.OptionId) } @@ -325,7 +325,7 @@ func (a *Agent) handleToolCallConfirmation(ctx context.Context, acpSess *Session func (a *Agent) handleMaxIterationsReached(ctx context.Context, acpSess *Session, e *runtime.MaxIterationsReachedEvent) error { permResp, err := a.conn.RequestPermission(ctx, acp.RequestPermissionRequest{ SessionId: acp.SessionId(acpSess.id), - ToolCall: acp.ToolCallUpdate{ + ToolCall: acp.RequestPermissionToolCall{ ToolCallId: acp.ToolCallId("max_iterations"), Title: acp.Ptr(fmt.Sprintf("Maximum iterations (%d) reached", e.MaxIterations)), Kind: acp.Ptr(acp.ToolKindExecute), @@ -350,9 +350,9 @@ func (a *Agent) handleMaxIterationsReached(ctx context.Context, acpSess *Session if permResp.Outcome.Cancelled != nil || permResp.Outcome.Selected == nil || string(permResp.Outcome.Selected.OptionId) == "stop" { - acpSess.rt.Resume(ctx, string(runtime.ResumeTypeReject)) + acpSess.rt.Resume(ctx, runtime.ResumeTypeReject) } else { - acpSess.rt.Resume(ctx, string(runtime.ResumeTypeApprove)) + acpSess.rt.Resume(ctx, runtime.ResumeTypeApprove) } return nil @@ -391,7 +391,7 @@ func buildToolCallComplete(toolCall tools.ToolCall, output string) acp.SessionUp } // buildToolCallUpdate creates a tool call update for permission requests -func buildToolCallUpdate(toolCall tools.ToolCall, tool tools.Tool, status acp.ToolCallStatus) acp.ToolCallUpdate { +func buildToolCallUpdate(toolCall tools.ToolCall, tool tools.Tool, status acp.ToolCallStatus) acp.RequestPermissionToolCall { kind := acp.ToolKindExecute title := tool.Annotations.Title if title == "" { @@ -402,7 +402,7 @@ func buildToolCallUpdate(toolCall tools.ToolCall, tool tools.Tool, status acp.To kind = acp.ToolKindRead } - return acp.ToolCallUpdate{ + return acp.RequestPermissionToolCall{ ToolCallId: acp.ToolCallId(toolCall.ID), Title: acp.Ptr(title), Kind: acp.Ptr(kind), diff --git a/pkg/acp/filesystem.go b/pkg/acp/filesystem.go index 33edefbc3..99af426e8 100644 --- a/pkg/acp/filesystem.go +++ b/pkg/acp/filesystem.go @@ -56,11 +56,11 @@ func (t *FilesystemToolset) Tools(ctx context.Context) ([]tools.Tool, error) { for i := range baseTools { switch baseTools[i].Name { - case "read_file": + case builtin.ToolNameReadFile: baseTools[i].Handler = t.handleReadFile - case "write_file": + case builtin.ToolNameWriteFile: baseTools[i].Handler = t.handleWriteFile - case "edit_file": + case builtin.ToolNameEditFile: baseTools[i].Handler = t.handleEditFile } } diff --git a/pkg/agent/agent.go b/pkg/agent/agent.go index 7d5180170..e876a0d68 100644 --- a/pkg/agent/agent.go +++ b/pkg/agent/agent.go @@ -14,6 +14,7 @@ import ( type Agent struct { name string description string + welcomeMessage string instruction string toolsets []*StartableToolSet models []provider.Provider @@ -77,6 +78,11 @@ func (a *Agent) Description() string { return a.description } +// WelcomeMessage returns the agent's welcome message +func (a *Agent) WelcomeMessage() string { + return a.welcomeMessage +} + // SubAgents returns the list of sub-agent names func (a *Agent) SubAgents() []*Agent { return a.subAgents @@ -97,6 +103,11 @@ func (a *Agent) Model() provider.Provider { return a.models[rand.Intn(len(a.models))] } +// Commands returns the named commands configured for this agent. +func (a *Agent) Commands() map[string]string { + return a.commands +} + // Tools returns the tools available to this agent func (a *Agent) Tools(ctx context.Context) ([]tools.Tool, error) { a.ensureToolSetsAreStarted(ctx) @@ -131,11 +142,6 @@ func (a *Agent) ToolSets() []tools.ToolSet { return toolSets } -// Commands returns the named commands configured for this agent. -func (a *Agent) Commands() map[string]string { - return a.commands -} - func (a *Agent) ensureToolSetsAreStarted(ctx context.Context) { for _, toolSet := range a.toolsets { // Skip if toolset is already started diff --git a/pkg/agent/opts.go b/pkg/agent/opts.go index d8d764fe8..0cba91b32 100644 --- a/pkg/agent/opts.go +++ b/pkg/agent/opts.go @@ -40,6 +40,12 @@ func WithDescription(description string) Opt { } } +func WithWelcomeMessage(welcomeMessage string) Opt { + return func(a *Agent) { + a.welcomeMessage = welcomeMessage + } +} + func WithName(name string) Opt { return func(a *Agent) { a.name = name diff --git a/pkg/agentfile/resolver.go b/pkg/agentfile/resolver.go new file mode 100644 index 000000000..9559e5aca --- /dev/null +++ b/pkg/agentfile/resolver.go @@ -0,0 +1,154 @@ +package agentfile + +import ( + "bytes" + "context" + "fmt" + "io" + "log/slog" + "os" + "path/filepath" + "strings" + + "github.com/docker/cagent/pkg/aliases" + "github.com/docker/cagent/pkg/cli" + "github.com/docker/cagent/pkg/content" + "github.com/docker/cagent/pkg/remote" +) + +// IsLocalFile checks if the input is a local file +func IsLocalFile(input string) bool { + ext := strings.ToLower(filepath.Ext(input)) + // Check for YAML file extensions or file descriptors + if ext == ".yaml" || ext == ".yml" || strings.HasPrefix(input, "/dev/fd/") { + return true + } + // Check if it exists as a file on disk + return fileExists(input) +} + +// OciRefToFilename converts an OCI reference to a safe, consistent filename +// Examples: +// - "docker.io/myorg/agent:v1" -> "docker.io_myorg_agent_v1.yaml" +// - "localhost:5000/test" -> "localhost_5000_test.yaml" +func OciRefToFilename(ociRef string) string { + // Replace characters that are invalid in filenames with underscores + // Keep the structure recognizable but filesystem-safe + safe := strings.NewReplacer( + "/", "_", + ":", "_", + "@", "_", + "\\", "_", + "*", "_", + "?", "_", + "\"", "_", + "<", "_", + ">", "_", + "|", "_", + ).Replace(ociRef) + + // Ensure it has .yaml extension + if !strings.HasSuffix(safe, ".yaml") { + safe += ".yaml" + } + + return safe +} + +// Resolve resolves an agent file reference (local file or OCI image) to a local file path +func Resolve(ctx context.Context, out *cli.Printer, agentFilename string) (string, error) { + originalOCIRef := agentFilename // Store the original for OCI ref tracking + + // Try to resolve as an alias first + if aliasStore, err := aliases.Load(); err == nil { + if resolvedPath, ok := aliasStore.Get(agentFilename); ok { + slog.Debug("Resolved alias", "alias", agentFilename, "path", resolvedPath) + agentFilename = resolvedPath + } + } + + if IsLocalFile(agentFilename) { + // Treat as local YAML file: resolve to absolute path so later chdir doesn't break it + // TODO(rumpl): Why are we checking for newlines here? + if !strings.Contains(agentFilename, "\n") { + if abs, err := filepath.Abs(agentFilename); err == nil { + agentFilename = abs + } + } + if !fileExists(agentFilename) { + return "", fmt.Errorf("agent file not found: %s", agentFilename) + } + return agentFilename, nil + } + + // Treat as an OCI image reference. Try local store first, otherwise pull then load. + a, err := FromStore(agentFilename) + if err != nil { + out.Println("Pulling agent", agentFilename) + if _, pullErr := remote.Pull(agentFilename); pullErr != nil { + return "", fmt.Errorf("failed to pull OCI image %s: %w", agentFilename, pullErr) + } + // Retry after pull + a, err = FromStore(agentFilename) + if err != nil { + return "", fmt.Errorf("failed to load agent from store after pull: %w", err) + } + } + + filename := OciRefToFilename(originalOCIRef) + tmpFilename := filepath.Join(os.TempDir(), filename) + + if err := os.WriteFile(tmpFilename, []byte(a), 0o644); err != nil { + return "", fmt.Errorf("failed to write agent file: %w", err) + } + + slog.Debug("Resolved OCI reference to file", "oci_ref", originalOCIRef, "file", tmpFilename) + + go func() { + <-ctx.Done() + os.Remove(tmpFilename) + slog.Debug("Cleaned up OCI reference file", "file", tmpFilename) + }() + + return tmpFilename, nil +} + +// fileExists checks if a file exists at the given path +func fileExists(path string) bool { + _, err := os.Stat(path) + exists := err == nil + return exists +} + +// FromStore loads an agent configuration from the OCI content store +func FromStore(reference string) (string, error) { + store, err := content.NewStore() + if err != nil { + return "", err + } + + img, err := store.GetArtifactImage(reference) + if err != nil { + return "", err + } + + layers, err := img.Layers() + if err != nil { + return "", err + } + + var buf bytes.Buffer + layer := layers[0] + b, err := layer.Uncompressed() + if err != nil { + return "", err + } + + _, err = io.Copy(&buf, b) + if err != nil { + return "", err + } + b.Close() + + return buf.String(), nil +} diff --git a/pkg/aliases/aliases.go b/pkg/aliases/aliases.go index 8be33031f..e1d5ff318 100644 --- a/pkg/aliases/aliases.go +++ b/pkg/aliases/aliases.go @@ -32,12 +32,12 @@ func loadFrom(path string) (*Aliases, error) { return nil, fmt.Errorf("failed to read aliases file: %w", err) } - s := Aliases{} - if err := yaml.Unmarshal(data, &s); err != nil { + var aliases Aliases + if err := yaml.Unmarshal(data, &aliases); err != nil { return nil, fmt.Errorf("failed to parse aliases file: %w", err) } - return &s, nil + return &aliases, nil } // Save saves aliases to the configuration file diff --git a/pkg/api/pagination_test.go b/pkg/api/pagination_test.go index c52b9b206..57a2ff2b4 100644 --- a/pkg/api/pagination_test.go +++ b/pkg/api/pagination_test.go @@ -48,7 +48,6 @@ func TestPaginateMessages_FirstPage(t *testing.T) { // Should get most recent 10 messages (for chat infinite scroll) // For 100 messages, indices 90-99 should be returned - // Check that we got recent messages by verifying they're different from the old first messages assert.NotEqual(t, "Message 0", paginated[0].Message.Content) // Not the oldest message assert.NotEqual(t, "Message 9", paginated[9].Message.Content) // Not the 10th oldest message assert.Equal(t, "Message 90", paginated[0].Message.Content) // Index 90 @@ -66,12 +65,10 @@ func TestPaginateMessages_WithBeforeCursorPagination(t *testing.T) { endPage, endMeta, err := PaginateMessages(messages, endPageParams) require.NoError(t, err) - // Verify we got the end page assert.Len(t, endPage, 10) assert.Equal(t, "Message 10", endPage[0].Message.Content) // Index 10 assert.Equal(t, "Message 19", endPage[9].Message.Content) // Index 19 - // Get previous page using before cursor (should give us messages 0-9) prevPageParams := PaginationParams{ Limit: 10, Before: endMeta.PrevCursor, // Before the end page @@ -93,7 +90,6 @@ func TestPaginateMessages_WithBeforeCursorPagination(t *testing.T) { func TestPaginateMessages_WithBeforeCursor(t *testing.T) { messages := createTestMessages(100) - // Get a page in the middle (starting at index 50) middleCursor := strconv.Itoa(50) params := PaginationParams{ @@ -158,7 +154,6 @@ func TestPaginateMessages_EmptyMessages(t *testing.T) { func TestPaginateMessages_LastPage(t *testing.T) { messages := createTestMessages(25) - // Get the oldest 5 messages (using before cursor to limit to earliest messages) lastPageParams := PaginationParams{ Limit: 10, Before: "5", // Before the 6th message (index 5) @@ -178,7 +173,6 @@ func TestPaginateMessages_LastPage(t *testing.T) { func TestPaginateMessages_BeforeFirstMessage(t *testing.T) { messages := createTestMessages(10) - // Create cursor pointing to before first message firstCursor := strconv.Itoa(0) params := PaginationParams{ diff --git a/pkg/app/app.go b/pkg/app/app.go index 041af7f79..aa7526c64 100644 --- a/pkg/app/app.go +++ b/pkg/app/app.go @@ -5,18 +5,15 @@ import ( "os/exec" "time" - tea "github.com/charmbracelet/bubbletea/v2" + tea "charm.land/bubbletea/v2" "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/session" - "github.com/docker/cagent/pkg/team" ) type App struct { - title string agentFilename string runtime runtime.Runtime - team *team.Team session *session.Session firstMessage *string events chan tea.Msg @@ -24,12 +21,10 @@ type App struct { cancel context.CancelFunc } -func New(title, agentFilename string, rt runtime.Runtime, agents *team.Team, sess *session.Session, firstMessage *string) *App { +func New(agentFilename string, rt runtime.Runtime, sess *session.Session, firstMessage *string) *App { return &App{ - title: title, agentFilename: agentFilename, runtime: rt, - team: agents, session: sess, firstMessage: firstMessage, events: make(chan tea.Msg, 128), @@ -41,12 +36,9 @@ func (a *App) FirstMessage() *string { return a.firstMessage } -func (a *App) Team() *team.Team { - return a.team -} - -func (a *App) Title() string { - return a.title +// CurrentWelcomeMessage returns the welcome message for the active agent +func (a *App) CurrentWelcomeMessage(ctx context.Context) string { + return a.runtime.CurrentWelcomeMessage(ctx) } // CurrentAgentCommands returns the commands for the active agent @@ -94,9 +86,9 @@ func (a *App) Subscribe(ctx context.Context, program *tea.Program) { } // Resume resumes the runtime with the given confirmation type -func (a *App) Resume(confirmationType string) { +func (a *App) Resume(resumeType runtime.ResumeType) { if a.runtime != nil { - a.runtime.Resume(context.Background(), confirmationType) + a.runtime.Resume(context.Background(), resumeType) } } @@ -131,6 +123,10 @@ func (a *App) ResumeStartOAuth(bool) { } } +func (a *App) PlainTextTranscript() string { + return transcript(a.session) +} + // throttleEvents buffers and merges rapid events to prevent UI flooding func (a *App) throttleEvents(ctx context.Context, in <-chan tea.Msg) <-chan tea.Msg { out := make(chan tea.Msg, 128) diff --git a/pkg/app/testdata/assistant_message.golden b/pkg/app/testdata/assistant_message.golden new file mode 100644 index 000000000..e6db3d434 --- /dev/null +++ b/pkg/app/testdata/assistant_message.golden @@ -0,0 +1,7 @@ +## User + +Hello + +## Assistant (root) + +Hello to you too \ No newline at end of file diff --git a/pkg/app/testdata/assistant_message_with_reasoning.golden b/pkg/app/testdata/assistant_message_with_reasoning.golden new file mode 100644 index 000000000..bfd7763fc --- /dev/null +++ b/pkg/app/testdata/assistant_message_with_reasoning.golden @@ -0,0 +1,11 @@ +## User + +Hello + +## Assistant (root) + +### Reasoning + +Hm.... + +Hello to you too \ No newline at end of file diff --git a/pkg/app/testdata/simple.golden b/pkg/app/testdata/simple.golden new file mode 100644 index 000000000..9cd7996d9 --- /dev/null +++ b/pkg/app/testdata/simple.golden @@ -0,0 +1,3 @@ +## User + +Hello \ No newline at end of file diff --git a/pkg/app/testdata/tool_calls.golden b/pkg/app/testdata/tool_calls.golden new file mode 100644 index 000000000..f78349e8b --- /dev/null +++ b/pkg/app/testdata/tool_calls.golden @@ -0,0 +1,22 @@ +## User + +Hello + +## Assistant (root) + +Hello to you too + +### Tool Calls + +- **shell** +```json +{ + "cmd": "ls" +} +``` + + +### Tool Result + +. +.. \ No newline at end of file diff --git a/pkg/app/transcript.go b/pkg/app/transcript.go new file mode 100644 index 000000000..41848491b --- /dev/null +++ b/pkg/app/transcript.go @@ -0,0 +1,102 @@ +package app + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/docker/cagent/pkg/chat" + "github.com/docker/cagent/pkg/session" +) + +func transcript(sess *session.Session) string { + var builder strings.Builder + + messages := sess.GetAllMessages() + for i := range messages { + msg := messages[i] + + if msg.Implicit { + continue + } + + switch msg.Message.Role { + case chat.MessageRoleUser: + writeUserMessage(&builder, msg) + case chat.MessageRoleAssistant: + writeAssistantMessage(&builder, msg) + case chat.MessageRoleTool: + writeToolMessage(&builder, msg) + } + } + + return strings.TrimSpace(builder.String()) +} + +func writeUserMessage(builder *strings.Builder, msg session.Message) { + fmt.Fprintf(builder, "\n## User\n\n%s\n", msg.Message.Content) +} + +func writeAssistantMessage(builder *strings.Builder, msg session.Message) { + builder.WriteString("\n## Assistant") + if msg.AgentName != "" { + fmt.Fprintf(builder, " (%s)", msg.AgentName) + } + builder.WriteString("\n\n") + + if msg.Message.ReasoningContent != "" { + builder.WriteString("### Reasoning\n\n") + builder.WriteString(msg.Message.ReasoningContent) + builder.WriteString("\n\n") + } + + if msg.Message.Content != "" { + builder.WriteString(msg.Message.Content) + builder.WriteString("\n") + } + + if len(msg.Message.ToolCalls) > 0 { + builder.WriteString("\n### Tool Calls\n\n") + for _, toolCall := range msg.Message.ToolCalls { + fmt.Fprintf(builder, "- **%s**", toolCall.Function.Name) + if toolCall.ID != "" { + fmt.Fprintf(builder, " (ID: %s)", toolCall.ID) + } + + builder.WriteString("\n") + toJSONString(builder, toolCall.Function.Arguments) + builder.WriteString("\n") + } + builder.WriteString("\n") + } +} + +func writeToolMessage(builder *strings.Builder, msg session.Message) { + builder.WriteString("### Tool Result") + if msg.Message.ToolCallID != "" { + fmt.Fprintf(builder, " (ID: %s)", msg.Message.ToolCallID) + } + fmt.Fprintf(builder, "\n\n") + + toJSONString(builder, msg.Message.Content) + builder.WriteString("\n") +} + +func toJSONString(builder *strings.Builder, in string) { + var content any + if err := json.Unmarshal([]byte(in), &content); err == nil { + if formatted, err := json.MarshalIndent(content, "", " "); err == nil { + builder.WriteString("```json\n") + builder.WriteString(string(formatted)) + builder.WriteString("\n```\n") + } else { + builder.WriteString(in) + builder.WriteString("\n") + } + } else { + if in != "" { + builder.WriteString(in) + builder.WriteString("\n") + } + } +} diff --git a/pkg/app/transcript_test.go b/pkg/app/transcript_test.go new file mode 100644 index 000000000..3b5608f75 --- /dev/null +++ b/pkg/app/transcript_test.go @@ -0,0 +1,77 @@ +package app + +import ( + "testing" + + "gotest.tools/v3/golden" + + "github.com/docker/cagent/pkg/chat" + "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/tools" +) + +func TestSimple(t *testing.T) { + sess := session.New(session.WithUserMessage("", "Hello")) + content := transcript(sess) + golden.Assert(t, content, "simple.golden") +} + +func TestAssistantMessage(t *testing.T) { + sess := session.New( + session.WithUserMessage("", "Hello"), + ) + sess.AddMessage(&session.Message{ + AgentName: "root", + Message: chat.Message{ + Role: chat.MessageRoleAssistant, + Content: "Hello to you too", + }, + }) + content := transcript(sess) + golden.Assert(t, content, "assistant_message.golden") +} + +func TestAssistantMessageWithReasoning(t *testing.T) { + sess := session.New( + session.WithUserMessage("", "Hello"), + ) + sess.AddMessage(&session.Message{ + AgentName: "root", + Message: chat.Message{ + Role: chat.MessageRoleAssistant, + Content: "Hello to you too", + ReasoningContent: "Hm....", + }, + }) + content := transcript(sess) + golden.Assert(t, content, "assistant_message_with_reasoning.golden") +} + +func TestToolCalls(t *testing.T) { + sess := session.New( + session.WithUserMessage("", "Hello"), + ) + sess.AddMessage(&session.Message{ + AgentName: "root", + Message: chat.Message{ + Role: chat.MessageRoleAssistant, + Content: "Hello to you too", + ToolCalls: []tools.ToolCall{ + { + Function: tools.FunctionCall{Name: "shell", Arguments: `{"cmd":"ls"}`}, + }, + }, + }, + }) + + sess.AddMessage(&session.Message{ + AgentName: "", + Message: chat.Message{ + Role: chat.MessageRoleTool, + Content: ".\n..", + }, + }) + content := transcript(sess) + + golden.Assert(t, content, "tool_calls.golden") +} diff --git a/pkg/cli/runner.go b/pkg/cli/runner.go index 1b72c9055..68b485610 100644 --- a/pkg/cli/runner.go +++ b/pkg/cli/runner.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "fmt" "io" + "log/slog" "os" "path/filepath" "strings" @@ -37,7 +38,7 @@ type Config struct { } // Run executes an agent in non-TUI mode, handling user input and runtime events -func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runtime, sess *session.Session, args []string) error { +func Run(ctx context.Context, out *Printer, cfg Config, agentFilename string, rt runtime.Runtime, sess *session.Session, args []string) error { // Create a cancellable context for this agentic loop and wire Ctrl+C to cancel it ctx, cancel := context.WithCancel(ctx) defer cancel() @@ -61,7 +62,7 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti userInput = runtime.ResolveCommand(ctx, rt, userInput) - handled, err := runUserCommand(userInput, sess, rt, ctx) + handled, err := runUserCommand(out, userInput, sess, rt, ctx) if err != nil { return err } @@ -90,12 +91,12 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti if agentName != "" && (firstLoop || lastAgent != agentName) { if !firstLoop { if llmIsTyping { - fmt.Println() + out.Println() llmIsTyping = false } - fmt.Println() + out.Println() } - PrintAgentName(agentName) + out.PrintAgentName(agentName) firstLoop = false lastAgent = agentName reasoningStarted = false // Reset reasoning state on agent change @@ -106,16 +107,16 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti if !llmIsTyping { // Only add newline if we're not already typing if !agentChanged { - fmt.Println() + out.Println() } llmIsTyping = true } // Add newline when transitioning from reasoning to regular content if reasoningStarted { - fmt.Println() + out.Println() } reasoningStarted = false // Reset when regular content starts - fmt.Printf("%s", e.Content) + out.Print(e.Content) case *runtime.AgentChoiceReasoningEvent: if !reasoningStarted { // First reasoning chunk: print prefix @@ -123,17 +124,17 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti if e.AgentName != "" && e.AgentName != "root" { prefix = prefix + e.AgentName + ": " } - fmt.Printf("\n%s", White(prefix)) + out.Printf("\n%s", prefix) reasoningStarted = true } // Continue printing reasoning content - fmt.Printf("%s", White(e.Content)) + out.Print(e.Content) case *runtime.ToolCallConfirmationEvent: if llmIsTyping { - fmt.Println() + out.Println() llmIsTyping = false } - result := PrintToolCallWithConfirmation(ctx, e.ToolCall, rd) + result := out.PrintToolCallWithConfirmation(ctx, e.ToolCall, rd) // If interrupted, skip resuming; the runtime will notice context cancellation and stop if ctx.Err() != nil { continue @@ -141,12 +142,12 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti lastConfirmedToolCallID = e.ToolCall.ID // Store the ID to avoid duplicate printing switch result { case ConfirmationApprove: - rt.Resume(ctx, string(runtime.ResumeTypeApprove)) + rt.Resume(ctx, runtime.ResumeTypeApprove) case ConfirmationApproveSession: sess.ToolsApproved = true - rt.Resume(ctx, string(runtime.ResumeTypeApproveSession)) + rt.Resume(ctx, runtime.ResumeTypeApproveSession) case ConfirmationReject: - rt.Resume(ctx, string(runtime.ResumeTypeReject)) + rt.Resume(ctx, runtime.ResumeTypeReject) lastConfirmedToolCallID = "" // Clear on reject since tool won't execute case ConfirmationAbort: // Stop the agent loop immediately @@ -155,26 +156,26 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti } case *runtime.ToolCallEvent: if llmIsTyping { - fmt.Println() + out.Println() llmIsTyping = false } // Only print if this wasn't already shown during confirmation if e.ToolCall.ID != lastConfirmedToolCallID { - PrintToolCall(e.ToolCall) + out.PrintToolCall(e.ToolCall) } case *runtime.ToolCallResponseEvent: if llmIsTyping { - fmt.Println() + out.Println() llmIsTyping = false } - PrintToolCallResponse(e.ToolCall, e.Response) + out.PrintToolCallResponse(e.ToolCall, e.Response) // Clear the confirmed ID after the tool completes if e.ToolCall.ID == lastConfirmedToolCallID { lastConfirmedToolCallID = "" } case *runtime.ErrorEvent: if llmIsTyping { - fmt.Println() + out.Println() llmIsTyping = false } lowerErr := strings.ToLower(e.Error) @@ -182,33 +183,33 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti lastErr = nil } else { lastErr = fmt.Errorf("%s", e.Error) - PrintError(lastErr) + out.PrintError(lastErr) } case *runtime.MaxIterationsReachedEvent: if llmIsTyping { - fmt.Println() + out.Println() llmIsTyping = false } - result := PromptMaxIterationsContinue(ctx, e.MaxIterations) + result := out.PromptMaxIterationsContinue(ctx, e.MaxIterations) switch result { case ConfirmationApprove: - rt.Resume(ctx, string(runtime.ResumeTypeApprove)) + rt.Resume(ctx, runtime.ResumeTypeApprove) case ConfirmationReject: - rt.Resume(ctx, string(runtime.ResumeTypeReject)) + rt.Resume(ctx, runtime.ResumeTypeReject) return nil case ConfirmationAbort: - rt.Resume(ctx, string(runtime.ResumeTypeReject)) + rt.Resume(ctx, runtime.ResumeTypeReject) return nil } case *runtime.ElicitationRequestEvent: if llmIsTyping { - fmt.Println() + out.Println() llmIsTyping = false } serverURL := e.Meta["cagent/server_url"].(string) - result := PromptOAuthAuthorization(ctx, serverURL) + result := out.PromptOAuthAuthorization(ctx, serverURL) switch { case ctx.Err() != nil: return ctx.Err() @@ -223,7 +224,7 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti // If the loop ended due to Ctrl+C, inform the user succinctly if ctx.Err() != nil { - fmt.Println(Yellow("\n⚠️ agent stopped ⚠️")) + out.Println("\n⚠️ agent stopped ⚠️") } // Wrap runtime errors to prevent duplicate error messages and usage display @@ -249,13 +250,14 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti } } } else { - PrintWelcomeMessage(cfg.AppName) + out.PrintWelcomeMessage(cfg.AppName) firstQuestion := true for { if !firstQuestion { - fmt.Print("\n\n") + out.Println() + out.Println() } - fmt.Print(Blue("> ")) + out.Print("> ") firstQuestion = false line, err := input.ReadLine(ctx, os.Stdin) @@ -278,20 +280,20 @@ func Run(ctx context.Context, cfg Config, agentFilename string, rt runtime.Runti // runUserCommand handles built-in session commands // TODO: This is a duplication of builtInSessionCommands() in pkg/tui/tui.go -func runUserCommand(userInput string, sess *session.Session, rt runtime.Runtime, ctx context.Context) (bool, error) { +func runUserCommand(out *Printer, userInput string, sess *session.Session, rt runtime.Runtime, ctx context.Context) (bool, error) { switch userInput { case "/exit": os.Exit(0) case "/eval": evalFile, err := evaluation.Save(sess) if err == nil { - fmt.Printf("%s\n", Yellow("Evaluation saved to file %s", evalFile)) + out.Println("Evaluation saved to file:", evalFile) return true, err } return true, nil case "/usage": - fmt.Printf("%s\n", Yellow("Input tokens: %d", sess.InputTokens)) - fmt.Printf("%s\n", Yellow("Output tokens: %d", sess.OutputTokens)) + out.Println("Input tokens:", sess.InputTokens) + out.Println("Output tokens:", sess.OutputTokens) return true, nil case "/new": // Reset session items @@ -299,7 +301,7 @@ func runUserCommand(userInput string, sess *session.Session, rt runtime.Runtime, return true, nil case "/compact": // Generate a summary of the session and compact the history - fmt.Printf("%s\n", Yellow("Generating summary...")) + out.Println("Generating summary...") // Create a channel to capture summary events events := make(chan runtime.Event, 100) @@ -314,17 +316,17 @@ func runUserCommand(userInput string, sess *session.Session, rt runtime.Runtime, for event := range events { switch e := event.(type) { case *runtime.SessionSummaryEvent: - fmt.Printf("%s\n", Yellow("Summary generated and added to session")) - fmt.Printf("Summary: %s\n", e.Summary) + out.Println("Summary generated and added to session") + out.Println("Summary:", e.Summary) summaryGenerated = true case *runtime.WarningEvent: - fmt.Printf("%s\n", Yellow("Warning: "+e.Message)) + out.Println("Warning:", e.Message) hasWarning = true } } if !summaryGenerated && !hasWarning { - fmt.Printf("%s\n", Yellow("No summary generated")) + out.Println("No summary generated") } return true, nil @@ -396,7 +398,7 @@ func createUserMessageWithAttachment(agentFilename, userContent, attachmentPath // Convert file to data URL dataURL, err := fileToDataURL(attachmentPath) if err != nil { - fmt.Printf("Warning: Failed to attach file %s: %v\n", attachmentPath, err) + slog.Warn("Failed to attach file", "path", attachmentPath, "error", err) return session.UserMessage(agentFilename, userContent) } diff --git a/pkg/cli/text.go b/pkg/cli/text.go index 8d6b425ab..c566db45c 100644 --- a/pkg/cli/text.go +++ b/pkg/cli/text.go @@ -15,18 +15,6 @@ import ( "github.com/docker/cagent/pkg/tools" ) -var ( - // Let's disable the colors in non TUI mode. - // (dga): I kept those functions in case we find a proper way to use them in both dark and light modes. - blue = fmt.Sprintf - yellow = fmt.Sprintf - red = fmt.Sprintf - white = fmt.Sprintf - green = fmt.Sprintf - - bold = color.New(color.Bold).SprintfFunc() -) - // ConfirmationResult represents the result of a user confirmation prompt type ConfirmationResult string @@ -37,52 +25,62 @@ const ( ConfirmationAbort ConfirmationResult = "abort" ) -// Color formatting functions (exported for use by other packages) -var ( - Blue = blue - Yellow = yellow - Red = red - White = white - Green = green - Bold = bold -) +var bold = color.New(color.Bold).SprintfFunc() + +type Printer struct { + out io.Writer +} + +func NewPrinter(out io.Writer) *Printer { + return &Printer{ + out: out, + } +} + +func (p *Printer) Println(a ...any) { + fmt.Fprintln(p.out, a...) +} + +func (p *Printer) Print(a ...any) { + fmt.Fprint(p.out, a...) +} + +func (p *Printer) Printf(format string, a ...any) (n int, err error) { + return fmt.Fprintf(p.out, format, a...) +} // PrintWelcomeMessage prints the welcome message -func PrintWelcomeMessage(appName string) { - fmt.Printf("\n%s\n%s\n\n", blue("------- Welcome to %s! -------", bold(appName)), white("(Ctrl+C to stop the agent and exit)")) +func (p *Printer) PrintWelcomeMessage(appName string) { + p.Printf("\n------- Welcome to %s! -------\n(Ctrl+C to stop the agent and exit)\n\n", bold(appName)) } // PrintError prints an error message -func PrintError(err error) { - fmt.Println(red("❌ %s", err)) +func (p *Printer) PrintError(err error) { + p.Printf("❌ %s", err) } // PrintAgentName prints the agent name header -func PrintAgentName(agentName string) { - fmt.Printf("\n%s\n", blue("--- Agent: %s ---", bold(agentName))) +func (p *Printer) PrintAgentName(agentName string) { + p.Printf("\n--- Agent: %s ---\n", bold(agentName)) } // PrintToolCall prints a tool call -func PrintToolCall(toolCall tools.ToolCall, colorFunc ...func(format string, a ...any) string) { - c := white - if len(colorFunc) > 0 && colorFunc[0] != nil { - c = colorFunc[0] - } - fmt.Printf("\nCalling %s\n", c("%s%s", bold(toolCall.Function.Name), formatToolCallArguments(toolCall.Function.Arguments))) +func (p *Printer) PrintToolCall(toolCall tools.ToolCall) { + p.Printf("\nCalling %s%s\n", bold(toolCall.Function.Name), formatToolCallArguments(toolCall.Function.Arguments)) } // PrintToolCallWithConfirmation prints a tool call and prompts for confirmation -func PrintToolCallWithConfirmation(ctx context.Context, toolCall tools.ToolCall, rd io.Reader) ConfirmationResult { - fmt.Printf("\n%s\n", bold(yellow("🛠️ Tool call requires confirmation 🛠️"))) - PrintToolCall(toolCall, color.New(color.FgWhite).SprintfFunc()) - fmt.Printf("\n%s", bold(yellow("Can I run this tool? ([y]es/[a]ll/[n]o): "))) +func (p *Printer) PrintToolCallWithConfirmation(ctx context.Context, toolCall tools.ToolCall, rd io.Reader) ConfirmationResult { + p.Printf("\n%s\n", bold("🛠️ Tool call requires confirmation 🛠️")) + p.PrintToolCall(toolCall) + p.Printf("\n%s", bold("Can I run this tool? ([y]es/[a]ll/[n]o): ")) // Try single-character input from stdin in raw mode (no Enter required) fd := int(os.Stdin.Fd()) if oldState, err := term.MakeRaw(fd); err == nil { defer func() { if err := term.Restore(fd, oldState); err != nil { - fmt.Printf("\n%s\n", yellow("Failed to restore terminal state: %v", err)) + p.Printf("\nFailed to restore terminal state: %v\n", err) } }() buf := make([]byte, 1) @@ -92,13 +90,13 @@ func PrintToolCallWithConfirmation(ctx context.Context, toolCall tools.ToolCall, } switch buf[0] { case 'y', 'Y': - fmt.Print(bold("Yes 👍")) + p.Print(bold("Yes 👍")) return ConfirmationApprove case 'a', 'A': - fmt.Print(bold("Yes to all 👍")) + p.Print(bold("Yes to all 👍")) return ConfirmationApproveSession case 'n', 'N': - fmt.Print(bold("No 👎")) + p.Print(bold("No 👎")) return ConfirmationReject case 3: // Ctrl+C return ConfirmationAbort @@ -130,54 +128,54 @@ func PrintToolCallWithConfirmation(ctx context.Context, toolCall tools.ToolCall, } // PrintToolCallResponse prints a tool call response -func PrintToolCallResponse(toolCall tools.ToolCall, response string) { - fmt.Printf("\n%s\n", white("%s response%s", bold(toolCall.Function.Name), formatToolCallResponse(response))) +func (p *Printer) PrintToolCallResponse(toolCall tools.ToolCall, response string) { + p.Printf("\n%s response%s\n", bold(toolCall.Function.Name), formatToolCallResponse(response)) } // PromptMaxIterationsContinue prompts the user to continue after max iterations -func PromptMaxIterationsContinue(ctx context.Context, maxIterations int) ConfirmationResult { - fmt.Printf("\n%s\n", yellow("⚠️ Maximum iterations (%d) reached. The agent may be stuck in a loop.", maxIterations)) - fmt.Printf("%s\n", white("This can happen with smaller or less capable models.")) - fmt.Printf("\n%s (y/n): ", blue("Do you want to continue for 10 more iterations?")) +func (p *Printer) PromptMaxIterationsContinue(ctx context.Context, maxIterations int) ConfirmationResult { + p.Printf("\n⚠️ Maximum iterations (%d) reached. The agent may be stuck in a loop.\n", maxIterations) + p.Println("This can happen with smaller or less capable models.") + p.Println("\nDo you want to continue for 10 more iterations? (y/n):") response, err := input.ReadLine(ctx, os.Stdin) if err != nil { - fmt.Printf("\n%s\n", red("Failed to read input, exiting...")) + p.Println("\nFailed to read input, exiting...") return ConfirmationAbort } response = strings.TrimSpace(strings.ToLower(response)) if response == "y" || response == "yes" { - fmt.Printf("%s\n\n", green("✓ Continuing...")) + p.Print("✓ Continuing...\n\n") return ConfirmationApprove } else { - fmt.Printf("%s\n\n", white("Exiting...")) + p.Print("Exiting...\n\n") return ConfirmationReject } } // PromptOAuthAuthorization prompts the user for OAuth authorization -func PromptOAuthAuthorization(ctx context.Context, serverURL string) ConfirmationResult { - fmt.Printf("\n%s\n", yellow("🔐 OAuth Authorization Required")) - fmt.Printf("%s %s (remote)\n", white("Server:"), blue(serverURL)) - fmt.Printf("%s\n", white("This server requires OAuth authentication to access its tools.")) - fmt.Printf("%s\n", white("Your browser will open automatically to complete the authorization.")) - fmt.Printf("\n%s (y/n): ", blue("Do you want to authorize access?")) +func (p *Printer) PromptOAuthAuthorization(ctx context.Context, serverURL string) ConfirmationResult { + p.Println("\n🔐 OAuth Authorization Required") + p.Println("Server:", serverURL, "(remote)") + p.Println("This server requires OAuth authentication to access its tools.") + p.Println("Your browser will open automatically to complete the authorization.") + p.Printf("\n%s (y/n): ", "Do you want to authorize access?") response, err := input.ReadLine(ctx, os.Stdin) if err != nil { - fmt.Printf("\n%s\n", red("Failed to read input, aborting authorization...")) + p.Println("\nFailed to read input, aborting authorization...") return ConfirmationAbort } response = strings.TrimSpace(strings.ToLower(response)) if response == "y" || response == "yes" { - fmt.Printf("%s\n", green("✓ Starting OAuth authorization...")) - fmt.Printf("%s\n", white("Please complete the authorization in your browser.")) - fmt.Printf("%s\n\n", white("Once completed, the agent will continue automatically.")) + p.Println("✓ Starting OAuth authorization...") + p.Println("Please complete the authorization in your browser.") + p.Print("Once completed, the agent will continue automatically.\n\n") return ConfirmationApprove } else { - fmt.Printf("%s\n\n", white("Authorization declined. Exiting...")) + p.Print("Authorization declined. Exiting...\n\n") return ConfirmationReject } } diff --git a/pkg/cli/text_test.go b/pkg/cli/text_test.go new file mode 100644 index 000000000..60cf16f4a --- /dev/null +++ b/pkg/cli/text_test.go @@ -0,0 +1,75 @@ +package cli + +import ( + "testing" + + "gotest.tools/v3/assert" +) + +func TestFormatToolCallResponse_Empty(t *testing.T) { + formatted := formatToolCallResponse(``) + + assert.Equal(t, ` → ()`, formatted) +} + +func TestFormatToolCallResponse_Map(t *testing.T) { + formatted := formatToolCallResponse(`{"text": "hello"}`) + + assert.Equal(t, ` → (text: "hello")`, formatted) +} + +func TestFormatToolCallResponse_MapOfEmptyArray(t *testing.T) { + formatted := formatToolCallResponse(`{"array": []}`) + assert.Equal(t, ` → (array: [])`, formatted) +} + +func TestFormatToolCallResponse_MapOfArray(t *testing.T) { + formatted := formatToolCallResponse(`{"array": [1,2,3]}`) + assert.Equal(t, ` → ( + array: [ + 1, + 2, + 3 +] +)`, formatted) +} + +func TestFormatToolCallResponse_PlainText(t *testing.T) { + formatted := formatToolCallResponse(`Plain Text`) + + assert.Equal(t, ` → "Plain Text"`, formatted) +} + +func TestFormatToolCallArguments_Empty(t *testing.T) { + formatted := formatToolCallArguments(``) + + assert.Equal(t, `()`, formatted) +} + +func TestFormatToolCallArguments_Map(t *testing.T) { + formatted := formatToolCallArguments(`{"text": "hello"}`) + + assert.Equal(t, `(text: "hello")`, formatted) +} + +func TestFormatToolCallArguments_MapOfArray(t *testing.T) { + formatted := formatToolCallArguments(`{"array": [1,2,3]}`) + assert.Equal(t, `( + array: [ + 1, + 2, + 3 +] +)`, formatted) +} + +func TestFormatToolCallArguments_MapOfEmptyArray(t *testing.T) { + formatted := formatToolCallArguments(`{"array": []}`) + assert.Equal(t, `(array: [])`, formatted) +} + +func TestFormatToolCallArguments_PlainText(t *testing.T) { + formatted := formatToolCallArguments(`Plain Text`) + + assert.Equal(t, `(Plain Text)`, formatted) +} diff --git a/pkg/config/config.go b/pkg/config/config.go index bc49d7d38..e5c87414b 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -5,14 +5,14 @@ import ( "fmt" "log/slog" "os" - "path/filepath" "strings" "github.com/goccy/go-yaml" v0 "github.com/docker/cagent/pkg/config/v0" v1 "github.com/docker/cagent/pkg/config/v1" - latest "github.com/docker/cagent/pkg/config/v2" + latest "github.com/docker/cagent/pkg/config/v2" //nolint:staticcheck // This is used everywhere we reference the latest version + v2 "github.com/docker/cagent/pkg/config/v2" //nolint:staticcheck // This is used for migrations to v2 "github.com/docker/cagent/pkg/environment" "github.com/docker/cagent/pkg/filesystem" ) @@ -27,21 +27,22 @@ func LoadConfigSecureDeprecated(path, allowedDir string) (*latest.Config, error) } func LoadConfig(path string, fs filesystem.FS) (*latest.Config, error) { - dir := filepath.Dir(path) - data, err := fs.ReadFile(path) if err != nil { return nil, fmt.Errorf("reading config file: %w", err) } var raw struct { - Version any `yaml:"version"` + Version string `yaml:"version,omitempty"` } - if err := yaml.UnmarshalWithOptions(data, &raw, yaml.ReferenceDirs(dir)); err != nil { + if err := yaml.UnmarshalWithOptions(data, &raw); err != nil { return nil, fmt.Errorf("looking for version in config file %s\n%s", path, yaml.FormatError(err, true, true)) } + if raw.Version == "" { + raw.Version = latest.Version + } - oldConfig, err := parseCurrentVersion(dir, data, raw.Version) + oldConfig, err := parseCurrentVersion(data, raw.Version) if err != nil { return nil, fmt.Errorf("parsing config file %s\n%s", path, yaml.FormatError(err, true, true)) } @@ -51,6 +52,8 @@ func LoadConfig(path string, fs filesystem.FS) (*latest.Config, error) { return nil, fmt.Errorf("migrating config: %w", err) } + config.Version = raw.Version + if err := validateConfig(&config); err != nil { return nil, err } @@ -78,22 +81,24 @@ func CheckRequiredEnvVars(ctx context.Context, cfg *latest.Config, env environme return nil } -func parseCurrentVersion(dir string, data []byte, version any) (any, error) { - options := []yaml.DecodeOption{yaml.Strict(), yaml.ReferenceDirs(dir)} +func parseCurrentVersion(data []byte, version string) (any, error) { + options := []yaml.DecodeOption{yaml.Strict()} switch version { - case nil, "0", 0: + case v0.Version: var cfg v0.Config err := yaml.UnmarshalWithOptions(data, &cfg, options...) return cfg, err - case "1", 1: + case v1.Version: var cfg v1.Config err := yaml.UnmarshalWithOptions(data, &cfg, options...) return cfg, err - default: - var cfg latest.Config + case v2.Version: + var cfg v2.Config err := yaml.UnmarshalWithOptions(data, &cfg, options...) return cfg, err + default: + return nil, fmt.Errorf("unsupported config version: %v", version) } } diff --git a/pkg/config/config_test.go b/pkg/config/config_test.go index 39994e1fb..2ace80fae 100644 --- a/pkg/config/config_test.go +++ b/pkg/config/config_test.go @@ -50,7 +50,7 @@ func TestMigrate_v0_v1_provider(t *testing.T) { cfg, err := LoadConfig("provider_v0.yaml", root) require.NoError(t, err) - assert.Equal(t, "openai", cfg.Models["openai"].Provider) + assert.Equal(t, "openai", cfg.Models["gpt"].Provider) } func TestMigrate_v1_provider(t *testing.T) { @@ -61,7 +61,7 @@ func TestMigrate_v1_provider(t *testing.T) { cfg, err := LoadConfig("provider_v1.yaml", root) require.NoError(t, err) - assert.Equal(t, "openai", cfg.Models["openai"].Provider) + assert.Equal(t, "openai", cfg.Models["gpt"].Provider) } func TestMigrate_v0_v1_todo(t *testing.T) { @@ -214,6 +214,10 @@ func TestCheckRequiredEnvVars(t *testing.T) { yaml: "google_inline.yaml", expectedMissing: []string{"GOOGLE_API_KEY"}, }, + { + yaml: "mistral_inline.yaml", + expectedMissing: []string{"MISTRAL_API_KEY"}, + }, { yaml: "dmr_inline.yaml", expectedMissing: []string{}, @@ -230,13 +234,17 @@ func TestCheckRequiredEnvVars(t *testing.T) { yaml: "google_model.yaml", expectedMissing: []string{"GOOGLE_API_KEY"}, }, + { + yaml: "mistral_model.yaml", + expectedMissing: []string{"MISTRAL_API_KEY"}, + }, { yaml: "dmr_model.yaml", expectedMissing: []string{}, }, { yaml: "all.yaml", - expectedMissing: []string{"ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "OPENAI_API_KEY"}, + expectedMissing: []string{"ANTHROPIC_API_KEY", "GOOGLE_API_KEY", "MISTRAL_API_KEY", "OPENAI_API_KEY"}, }, } for _, test := range tests { diff --git a/pkg/config/examples_test.go b/pkg/config/examples_test.go index f4741eee9..a9bf19bfa 100644 --- a/pkg/config/examples_test.go +++ b/pkg/config/examples_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/xeipuuv/gojsonschema" + latest "github.com/docker/cagent/pkg/config/v2" "github.com/docker/cagent/pkg/filesystem" "github.com/docker/cagent/pkg/modelsdev" ) @@ -45,7 +46,7 @@ func TestParseExamples(t *testing.T) { cfg, err := LoadConfig(file, filesystem.AllowAll) require.NoError(t, err) - require.Equal(t, "2", cfg.Version, "Version should be 2 in %s", file) + require.Equal(t, latest.Version, cfg.Version, "Version should be %d in %s", latest.Version, file) require.NotEmpty(t, cfg.Agents["root"].Description, "Description should not be empty in %s", file) require.NotEmpty(t, cfg.Agents["root"].Instruction, "Instruction should not be empty in %s", file) diff --git a/pkg/config/gather.go b/pkg/config/gather.go index c743b4557..73d5e5e5b 100644 --- a/pkg/config/gather.go +++ b/pkg/config/gather.go @@ -65,6 +65,8 @@ func GatherEnvVarsForModels(cfg *latest.Config) []string { requiredEnv["ANTHROPIC_API_KEY"] = true case "google": requiredEnv["GOOGLE_API_KEY"] = true + case "mistral": + requiredEnv["MISTRAL_API_KEY"] = true } } } diff --git a/pkg/config/runtime.go b/pkg/config/runtime.go index 813ede53c..f9bad2c7e 100644 --- a/pkg/config/runtime.go +++ b/pkg/config/runtime.go @@ -1,9 +1,12 @@ package config +import "github.com/docker/cagent/pkg/environment" + type RuntimeConfig struct { - EnvFiles []string - ModelsGateway string - RedirectURI string - GlobalCodeMode bool - WorkingDir string + DefaultEnvProvider environment.Provider + EnvFiles []string + ModelsGateway string + RedirectURI string + GlobalCodeMode bool + WorkingDir string } diff --git a/pkg/config/testdata/commands_v0.yaml b/pkg/config/testdata/commands_v0.yaml index 54a08503e..b6e702efa 100644 --- a/pkg/config/testdata/commands_v0.yaml +++ b/pkg/config/testdata/commands_v0.yaml @@ -1,5 +1,6 @@ -agents: +version: "0" +agents: root: model: openai/gpt-4o instruction: you are a helpful computer assistant diff --git a/pkg/config/testdata/env/all.yaml b/pkg/config/testdata/env/all.yaml index 916bba25f..22104000a 100755 --- a/pkg/config/testdata/env/all.yaml +++ b/pkg/config/testdata/env/all.yaml @@ -7,9 +7,12 @@ agents: dmr: model: dmr/ai/gemma3-qat:12B instruction: Always answer by talking like a pirate. - openai: + gpt: model: openai/gpt-4o instruction: Always answer by talking like a pirate. gemini: model: google/gemini-2.0-flash instruction: Always answer by talking like a pirate. + mistral: + model: mistral/mistral-small-latest + instruction: Always answer by talking like a pirate. diff --git a/pkg/config/testdata/env/mistral_inline.yaml b/pkg/config/testdata/env/mistral_inline.yaml new file mode 100755 index 000000000..20c21be1c --- /dev/null +++ b/pkg/config/testdata/env/mistral_inline.yaml @@ -0,0 +1,6 @@ +version: "2" + +agents: + root: + model: mistral/mistral-small-latest + instruction: Always answer by talking like a pirate. diff --git a/pkg/config/testdata/env/mistral_model.yaml b/pkg/config/testdata/env/mistral_model.yaml new file mode 100755 index 000000000..3ce9b2c6a --- /dev/null +++ b/pkg/config/testdata/env/mistral_model.yaml @@ -0,0 +1,11 @@ +version: "2" + +agents: + root: + model: mistral + instruction: Always answer by talking like a pirate. + +models: + mistral: + provider: mistral + model: mistral-small-latest diff --git a/pkg/config/testdata/memory_v0.yaml b/pkg/config/testdata/memory_v0.yaml index 8e1b9a2f8..2ff067d80 100755 --- a/pkg/config/testdata/memory_v0.yaml +++ b/pkg/config/testdata/memory_v0.yaml @@ -1,6 +1,8 @@ +version: "0" + agents: root: - model: openai + model: gpt memory: path: dev_memory.db toolsets: @@ -9,6 +11,6 @@ agents: args: ["mcp", "gateway", "run", "--servers=fetch"] models: - openai: + gpt: type: openai model: gpt-4o diff --git a/pkg/config/testdata/memory_v1.yaml b/pkg/config/testdata/memory_v1.yaml index 2b9de55b1..8929dfab6 100755 --- a/pkg/config/testdata/memory_v1.yaml +++ b/pkg/config/testdata/memory_v1.yaml @@ -2,7 +2,7 @@ version: "1" agents: root: - model: openai + model: gpt toolsets: - type: memory path: dev_memory.db @@ -11,6 +11,6 @@ agents: args: ["mcp", "gateway", "run", "--servers=fetch"] models: - openai: + gpt: provider: openai model: gpt-4o diff --git a/pkg/config/testdata/provider_v0.yaml b/pkg/config/testdata/provider_v0.yaml index 8a2dfe3d9..f0c7a2289 100644 --- a/pkg/config/testdata/provider_v0.yaml +++ b/pkg/config/testdata/provider_v0.yaml @@ -1,8 +1,10 @@ +version: "0" + agents: root: - model: openai + model: gpt models: - openai: + gpt: type: openai model: gpt-4o diff --git a/pkg/config/testdata/provider_v1.yaml b/pkg/config/testdata/provider_v1.yaml index e38310de1..ab574701f 100644 --- a/pkg/config/testdata/provider_v1.yaml +++ b/pkg/config/testdata/provider_v1.yaml @@ -2,9 +2,9 @@ version: "1" agents: root: - model: openai + model: gpt models: - openai: + gpt: provider: openai model: gpt-4o diff --git a/pkg/config/testdata/shared_todo_v0.yaml b/pkg/config/testdata/shared_todo_v0.yaml index 8277ae80e..c9cef7742 100755 --- a/pkg/config/testdata/shared_todo_v0.yaml +++ b/pkg/config/testdata/shared_todo_v0.yaml @@ -1,6 +1,8 @@ +version: "0" + agents: root: - model: openai + model: gpt description: Create a biography of the user based on internet searches. instruction: Your goal is to present the user with a concise and informative biography based on their online presence. todo: @@ -11,6 +13,6 @@ agents: args: ["mcp", "gateway", "run", "--servers=fetch"] models: - openai: + gpt: type: openai model: gpt-4o diff --git a/pkg/config/testdata/shared_todo_v1.yaml b/pkg/config/testdata/shared_todo_v1.yaml index ea90f68c3..b62efc525 100755 --- a/pkg/config/testdata/shared_todo_v1.yaml +++ b/pkg/config/testdata/shared_todo_v1.yaml @@ -2,7 +2,7 @@ version: "1" agents: root: - model: openai + model: gpt description: Create a biography of the user based on internet searches. instruction: Your goal is to present the user with a concise and informative biography based on their online presence. toolsets: @@ -13,6 +13,6 @@ agents: args: ["mcp", "gateway", "run", "--servers=fetch"] models: - openai: + gpt: provider: openai model: gpt-4o diff --git a/pkg/config/testdata/think_v0.yaml b/pkg/config/testdata/think_v0.yaml index 2790c6723..0b09ac018 100755 --- a/pkg/config/testdata/think_v0.yaml +++ b/pkg/config/testdata/think_v0.yaml @@ -1,6 +1,8 @@ +version: "0" + agents: root: - model: openai + model: gpt think: true toolsets: - type: mcp @@ -8,6 +10,6 @@ agents: args: ["mcp", "gateway", "run", "--servers=fetch"] models: - openai: + gpt: type: openai model: gpt-4o diff --git a/pkg/config/testdata/think_v1.yaml b/pkg/config/testdata/think_v1.yaml index e201d3e07..34f6fb2a9 100755 --- a/pkg/config/testdata/think_v1.yaml +++ b/pkg/config/testdata/think_v1.yaml @@ -2,7 +2,7 @@ version: "1" agents: root: - model: openai + model: gpt toolsets: - type: think - type: mcp @@ -10,6 +10,6 @@ agents: args: ["mcp", "gateway", "run", "--servers=fetch"] models: - openai: + gpt: provider: openai model: gpt-4o diff --git a/pkg/config/testdata/todo_v0.yaml b/pkg/config/testdata/todo_v0.yaml index 0c418313f..b9a9e2328 100755 --- a/pkg/config/testdata/todo_v0.yaml +++ b/pkg/config/testdata/todo_v0.yaml @@ -1,6 +1,8 @@ +version: "0" + agents: root: - model: openai + model: gpt todo: true toolsets: - type: mcp @@ -8,6 +10,6 @@ agents: args: ["mcp", "gateway", "run", "--servers=fetch"] models: - openai: + gpt: type: openai model: gpt-4o diff --git a/pkg/config/testdata/todo_v1.yaml b/pkg/config/testdata/todo_v1.yaml index 5b262df58..4b1142a80 100755 --- a/pkg/config/testdata/todo_v1.yaml +++ b/pkg/config/testdata/todo_v1.yaml @@ -2,7 +2,7 @@ version: "1" agents: root: - model: openai + model: gpt toolsets: - type: todo - type: mcp @@ -10,6 +10,6 @@ agents: args: ["mcp", "gateway", "run", "--servers=fetch"] models: - openai: + gpt: provider: openai model: gpt-4o diff --git a/pkg/config/v0/types.go b/pkg/config/v0/types.go index d967af261..630ce7989 100644 --- a/pkg/config/v0/types.go +++ b/pkg/config/v0/types.go @@ -6,6 +6,8 @@ import ( "github.com/docker/cagent/pkg/config/types" ) +const Version = "0" + // Toolset represents a tool configuration type Toolset struct { Type string `json:"type,omitempty" yaml:"type,omitempty"` @@ -111,7 +113,8 @@ type ModelConfig struct { // Config represents the entire configuration file type Config struct { - Agents map[string]AgentConfig `json:"agents,omitempty" yaml:"agents,omitempty"` - Models map[string]ModelConfig `json:"models,omitempty" yaml:"models,omitempty"` - Env map[string]string `json:"env,omitempty" yaml:"env,omitempty"` + Version string `json:"version,omitempty"` + Agents map[string]AgentConfig `json:"agents,omitempty" yaml:"agents,omitempty"` + Models map[string]ModelConfig `json:"models,omitempty" yaml:"models,omitempty"` + Env map[string]string `json:"env,omitempty" yaml:"env,omitempty"` } diff --git a/pkg/config/v1/types.go b/pkg/config/v1/types.go index 939a449ee..dd5d52069 100644 --- a/pkg/config/v1/types.go +++ b/pkg/config/v1/types.go @@ -7,6 +7,8 @@ import ( "github.com/docker/cagent/pkg/config/types" ) +const Version = "1" + // ScriptShellToolConfig represents a custom shell tool configuration type ScriptShellToolConfig struct { Cmd string `json:"cmd" yaml:"cmd"` diff --git a/pkg/config/v2/types.go b/pkg/config/v2/types.go index bd377e0dd..7f9c12493 100644 --- a/pkg/config/v2/types.go +++ b/pkg/config/v2/types.go @@ -1,8 +1,8 @@ package v2 -import ( - "github.com/docker/cagent/pkg/config/types" -) +import "github.com/docker/cagent/pkg/config/types" + +const Version = "2" // Config represents the entire configuration file type Config struct { @@ -16,6 +16,7 @@ type Config struct { type AgentConfig struct { Model string `json:"model,omitempty"` Description string `json:"description,omitempty"` + WelcomeMessage string `json:"welcome_message,omitempty"` Toolsets []Toolset `json:"toolsets,omitempty"` Instruction string `json:"instruction,omitempty"` SubAgents []string `json:"sub_agents,omitempty"` @@ -87,6 +88,16 @@ type ScriptShellToolConfig struct { WorkingDir string `json:"working_dir,omitempty"` } +type APIToolConfig struct { + Instruction string `json:"instruction,omitempty"` + Name string `json:"name,omitempty"` + Required []string `json:"required,omitempty"` + Args map[string]any `json:"args,omitempty"` + Endpoint string `json:"endpoint,omitempty"` + Method string `json:"method,omitempty"` + Headers map[string]string `json:"headers,omitempty"` +} + // PostEditConfig represents a post-edit command configuration type PostEditConfig struct { Path string `json:"path"` @@ -122,6 +133,8 @@ type Toolset struct { // For the `filesystem` tool - post-edit commands PostEdit []PostEditConfig `json:"post_edit,omitempty"` + APIConfig APIToolConfig `json:"api_config"` + // For the `fetch` tool Timeout int `json:"timeout,omitempty"` } diff --git a/pkg/creator/agent.go b/pkg/creator/agent.go index 133affd3b..8c58236ef 100644 --- a/pkg/creator/agent.go +++ b/pkg/creator/agent.go @@ -5,6 +5,7 @@ import ( _ "embed" "encoding/json" "fmt" + "log/slog" "os" "path/filepath" "strings" @@ -53,7 +54,7 @@ func (f *fsToolset) Tools(ctx context.Context) ([]tools.Tool, error) { } for i, tool := range innerTools { - if tool.Name == "write_file" { + if tool.Name == builtin.ToolNameWriteFile { f.originalWriteFileHandler = tool.Handler innerTools[i].Handler = f.customWriteFileHandler } @@ -91,7 +92,7 @@ func CreateAgent(ctx context.Context, baseDir, prompt string, runConfig config.R return "", "", fmt.Errorf("failed to create LLM client: %w", err) } - fmt.Println("Generating agent configuration....") + slog.Info("Generating agent configuration....") fsToolset := fsToolset{inner: builtin.NewFilesystemTool([]string{baseDir})} fileName := filepath.Base(fsToolset.path) @@ -112,8 +113,10 @@ func CreateAgent(ctx context.Context, baseDir, prompt string, runConfig config.R return "", "", fmt.Errorf("failed to create runtime: %w", err) } - sess := session.New(session.WithUserMessage("", prompt)) - sess.ToolsApproved = true + sess := session.New( + session.WithUserMessage("", prompt), + session.WithToolsApproved(true), + ) messages, err := rt.Run(ctx, sess) if err != nil { @@ -123,21 +126,13 @@ func CreateAgent(ctx context.Context, baseDir, prompt string, runConfig config.R return messages[len(messages)-1].Message.Content, fsToolset.path, nil } -func StreamCreateAgent(ctx context.Context, baseDir, prompt string, runConfig config.RuntimeConfig, providerName, modelNameOverride string, maxTokensOverride, maxIterations int) (<-chan runtime.Event, runtime.Runtime, error) { - // Apply default max iterations if not specified (0 means use defaults) - if maxIterations == 0 { - // Only when using DMR we set a default limit. Local models are more prone to loops - if providerName == "dmr" { - maxIterations = 20 - } - } +func Agent(ctx context.Context, baseDir string, runConfig config.RuntimeConfig, providerName string, maxTokensOverride int, modelNameOverride string) (*team.Team, error) { defaultModels := map[string]string{ "openai": "gpt-5-mini", "anthropic": "claude-sonnet-4-0", "google": "gemini-2.5-flash", "dmr": "ai/qwen3:latest", } - var modelName string if _, ok := defaultModels[providerName]; ok { modelName = defaultModels[providerName] @@ -148,13 +143,7 @@ func StreamCreateAgent(ctx context.Context, baseDir, prompt string, runConfig co if modelNameOverride != "" { modelName = modelNameOverride } else { - fmt.Printf("Using default model: %s\n", modelName) - } - - // if the user provided a model override, let's use that by default for DMR - // in the generated agentfile - if providerName == "dmr" && modelName == "" { - defaultModels["dmr"] = modelName + slog.Info("Using default model: " + modelName) } // If not using a model gateway, avoid selecting a provider the user can't run @@ -169,10 +158,31 @@ func StreamCreateAgent(ctx context.Context, baseDir, prompt string, runConfig co if os.Getenv("GOOGLE_API_KEY") != "" { usableProviders = append(usableProviders, "google") } + if os.Getenv("MISTRAL_API_KEY") != "" { + usableProviders = append(usableProviders, "mistral") + } // DMR runs locally by default; include it when not using a gateway usableProviders = append(usableProviders, "dmr") } + fsToolset := fsToolset{inner: builtin.NewFilesystemTool([]string{baseDir})} + fileName := filepath.Base(fsToolset.path) + + // Provide soft guidance to prefer the selected providers + instructions := agentBuilderInstructions + "\n\nPreferred model providers to use: " + strings.Join(usableProviders, ", ") + ". You must always use one or more of the following model configurations: \n" + for _, provider := range usableProviders { + suggestedMaxTokens := 64000 + if provider == "dmr" { + suggestedMaxTokens = 16000 + } + instructions += fmt.Sprintf(` + models: + %s: + provider: %s + model: %s + max_tokens: %d\n`, provider, provider, defaultModels[provider], suggestedMaxTokens) + } + // Use 16k for DMR to limit memory costs maxTokens := 64000 if providerName == "dmr" { @@ -193,28 +203,7 @@ func StreamCreateAgent(ctx context.Context, baseDir, prompt string, runConfig co options.WithGateway(runConfig.ModelsGateway), ) if err != nil { - return nil, nil, fmt.Errorf("failed to create LLM client: %w", err) - } - - fmt.Println("Generating agent configuration....") - - fsToolset := fsToolset{inner: builtin.NewFilesystemTool([]string{baseDir})} - fileName := filepath.Base(fsToolset.path) - - // Provide soft guidance to prefer the selected providers - instructions := agentBuilderInstructions + "\n\nPreferred model providers to use: " + strings.Join(usableProviders, ", ") + ". You must always use one or more of the following model configurations: \n" - for _, provider := range usableProviders { - suggestedMaxTokens := 64000 - if provider == "dmr" { - suggestedMaxTokens = 16000 - } - instructions += fmt.Sprintf(` - version: "2" - models: - %s: - provider: %s - model: %s - max_tokens: %d\n`, provider, provider, defaultModels[provider], suggestedMaxTokens) + return nil, fmt.Errorf("failed to create LLM client: %w", err) } newTeam := team.New( @@ -229,17 +218,6 @@ func StreamCreateAgent(ctx context.Context, baseDir, prompt string, runConfig co &fsToolset, ), ))) - rt, err := runtime.New(newTeam) - if err != nil { - return nil, nil, fmt.Errorf("failed to create runtime: %w", err) - } - - sess := session.New( - session.WithUserMessage("", prompt), - session.WithMaxIterations(maxIterations), - ) - sess.ToolsApproved = true - events := rt.RunStream(ctx, sess) - return events, rt, nil + return newTeam, nil } diff --git a/pkg/creator/instructions.txt b/pkg/creator/instructions.txt index 1196aae12..b2562ffd3 100644 --- a/pkg/creator/instructions.txt +++ b/pkg/creator/instructions.txt @@ -14,11 +14,7 @@ A yaml file contains everyting needed to run a team of agents: If you are making a team of agents you should make one `root` agent whose job is to delegate tasks to its subagents -Important: always include top level `version: "2"` - ```yaml -version: "2" - agents: agent_name: model: string # Model reference @@ -46,8 +42,6 @@ The todo, memory, and script tools can be configured: Todos can be shared between different agents in a team ``` -version: "2" - agents: root: ... @@ -59,8 +53,6 @@ agents: Memory needs a path to the sqlite database file ``` -version: "2" - agents: root: ... @@ -72,8 +64,6 @@ agents: Script tools allow you to define custom shell commands with typed parameters: ``` -version: "2" - agents: root: ... @@ -126,8 +116,6 @@ Note: Arguments are substituted as environment variables in the command using $V Example of using the `youtube_transcript` MCP server, from the docker MCP Catalog, using the docker MCP Gateway: ```yaml -version: "2" - agents: root: ... @@ -156,27 +144,23 @@ docker mcp server inspect Multiple MCP Servers can be configured when multiple tools are useful. ```yaml -version: "2" - agents: - root: - ... - toolsets: - - type: mcp - ref: docker:duckduckgo - - type: mcp - ref: docker:youtube_transcript - - type: mcp - ref: docker:other + root: + ... + toolsets: + - type: mcp + ref: docker:duckduckgo + - type: mcp + ref: docker:youtube_transcript + - type: mcp + ref: docker:other ``` ### Model Configuration ```yaml -version: "2" - models: -model_name: + model_name: provider: string # Provider: openai, anthropic, dmr model: string # Model name: gpt-4o, claude-3-7-sonnet-latest max_tokens: integer # Response length limit diff --git a/pkg/desktop/connection_other.go b/pkg/desktop/connection_other.go index 9b82f821e..982799056 100644 --- a/pkg/desktop/connection_other.go +++ b/pkg/desktop/connection_other.go @@ -1,5 +1,4 @@ //go:build !windows -// +build !windows package desktop diff --git a/pkg/history/history_test.go b/pkg/history/history_test.go index 75c283247..f5106bd28 100644 --- a/pkg/history/history_test.go +++ b/pkg/history/history_test.go @@ -23,18 +23,15 @@ func TestHistory_AddAndSave(t *testing.T) { h, err := New() require.NoError(t, err) - // Test adding messages messages := []string{"first", "second", "third"} for _, msg := range messages { err := h.Add(msg) require.NoError(t, err) } - // Verify messages were added assert.Equal(t, messages, h.Messages) assert.Len(t, messages, h.current) - // Test persistence by creating a new instance h2, err := New() require.NoError(t, err) assert.Equal(t, messages, h2.Messages) @@ -46,27 +43,21 @@ func TestHistory_Navigation(t *testing.T) { h, err := New() require.NoError(t, err) - // Test empty history assert.Empty(t, h.Previous()) assert.Empty(t, h.Next()) - // Add test messages messages := []string{"first", "second", "third"} for _, msg := range messages { require.NoError(t, h.Add(msg)) } - // Test Previous() navigation assert.Equal(t, "third", h.Previous()) assert.Equal(t, "second", h.Previous()) assert.Equal(t, "first", h.Previous()) - // Test staying at beginning assert.Equal(t, "first", h.Previous()) - // Test Next() navigation assert.Equal(t, "second", h.Next()) assert.Equal(t, "third", h.Next()) - // Test going past the end assert.Empty(t, h.Next()) } @@ -76,14 +67,11 @@ func TestHistory_EdgeCases(t *testing.T) { h, err := New() require.NoError(t, err) - // Test empty history navigation assert.Empty(t, h.Previous()) assert.Empty(t, h.Next()) - // Add single message require.NoError(t, h.Add("only")) - // Test navigation with single message assert.Equal(t, "only", h.Previous()) assert.Equal(t, "only", h.Previous()) // Should stay at the beginning assert.Empty(t, h.Next()) // Should return empty when going past the end diff --git a/pkg/httpclient/client.go b/pkg/httpclient/client.go index e6dca046a..2badb14b5 100644 --- a/pkg/httpclient/client.go +++ b/pkg/httpclient/client.go @@ -25,7 +25,7 @@ func NewHTTPClient(opts ...Opt) *http.Client { } // Enforce a consistent User-Agent header - httpOptions.Header.Set("User-Agent", fmt.Sprintf("Cagent/%s (%s; %s)", version.Version, getNormalizedOS(), getNormalizedArchitecture())) + httpOptions.Header.Set("User-Agent", fmt.Sprintf("Cagent/%s (%s; %s)", version.Version, runtime.GOOS, runtime.GOARCH)) return &http.Client{ Transport: &userAgentTransport{ @@ -47,8 +47,8 @@ func WithProxiedBaseURL(value string) Opt { // Enforce consistent headers (Anthropic client sets similar header already) o.Header.Set("X-Cagent-Lang", "go") - o.Header.Set("X-Cagent-OS", getNormalizedOS()) - o.Header.Set("X-Cagent-Arch", getNormalizedArchitecture()) + o.Header.Set("X-Cagent-OS", runtime.GOOS) + o.Header.Set("X-Cagent-Arch", runtime.GOARCH) o.Header.Set("X-Cagent-Runtime", "cagent") o.Header.Set("X-Cagent-Runtime-Version", version.Version) } @@ -76,39 +76,3 @@ func (u *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error maps.Copy(r2.Header, u.httpOptions.Header) return u.rt.RoundTrip(r2) } - -func getNormalizedOS() string { - switch runtime.GOOS { - case "ios": - return "iOS" - case "android": - return "Android" - case "darwin": - return "MacOS" - case "window": - return "Windows" - case "freebsd": - return "FreeBSD" - case "openbsd": - return "OpenBSD" - case "linux": - return "Linux" - default: - return fmt.Sprintf("Other:%s", runtime.GOOS) - } -} - -func getNormalizedArchitecture() string { - switch runtime.GOARCH { - case "386": - return "x32" - case "amd64": - return "x64" - case "arm": - return "arm" - case "arm64": - return "arm64" - default: - return fmt.Sprintf("other:%s", runtime.GOARCH) - } -} diff --git a/pkg/js/expand.go b/pkg/js/expand.go index e1e17b183..ede67a620 100644 --- a/pkg/js/expand.go +++ b/pkg/js/expand.go @@ -37,3 +37,18 @@ func Expand(ctx context.Context, kv map[string]string, env environment.Provider) return expanded } + +func ExpandString(ctx context.Context, str string, values map[string]string) (string, error) { + vm := goja.New() + + for k, v := range values { + _ = vm.Set(k, v) + } + + expanded, err := vm.RunString("`" + str + "`") + if err != nil { + return "", err + } + + return fmt.Sprintf("%v", expanded.Export()), nil +} diff --git a/pkg/mcp/server.go b/pkg/mcp/server.go new file mode 100644 index 000000000..cedd1188f --- /dev/null +++ b/pkg/mcp/server.go @@ -0,0 +1,151 @@ +package mcp + +import ( + "context" + "fmt" + "log/slog" + + "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/docker/cagent/pkg/agent" + "github.com/docker/cagent/pkg/agentfile" + "github.com/docker/cagent/pkg/cli" + "github.com/docker/cagent/pkg/config" + "github.com/docker/cagent/pkg/runtime" + "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/team" + "github.com/docker/cagent/pkg/teamloader" + "github.com/docker/cagent/pkg/tools" + "github.com/docker/cagent/pkg/version" +) + +type ToolInput struct { + Message string `json:"message" jsonschema:"the message to send to the agent"` +} + +type ToolOutput struct { + Response string `json:"response" jsonschema:"the response from the agent"` +} + +func StartMCPServer(ctx context.Context, out *cli.Printer, agentFilename string, runConfig config.RuntimeConfig) error { + slog.Debug("Starting MCP server", "agent", agentFilename) + + agentFilename, err := agentfile.Resolve(ctx, out, agentFilename) + if err != nil { + return err + } + + t, err := teamloader.Load(ctx, agentFilename, runConfig) + if err != nil { + return fmt.Errorf("failed to load agents: %w", err) + } + + defer func() { + if err := t.StopToolSets(ctx); err != nil { + slog.Error("Failed to stop tool sets", "error", err) + } + }() + + server := mcp.NewServer(&mcp.Implementation{ + Name: "cagent", + Version: version.Version, + }, nil) + + agentNames := t.AgentNames() + slog.Debug("Adding MCP tools for agents", "count", len(agentNames)) + + for _, agentName := range agentNames { + ag, err := t.Agent(agentName) + if err != nil { + return fmt.Errorf("failed to get agent %s: %w", agentName, err) + } + + description := ag.Description() + if description == "" { + description = fmt.Sprintf("Run the %s agent", agentName) + } + + slog.Debug("Adding MCP tool", "agent", agentName, "description", description) + + readOnly, err := isReadOnlyAgent(ctx, ag) + if err != nil { + return fmt.Errorf("failed to determine if agent %s is read-only: %w", agentName, err) + } + + toolDef := &mcp.Tool{ + Name: agentName, + Description: description, + Annotations: &mcp.ToolAnnotations{ + ReadOnlyHint: readOnly, + }, + InputSchema: tools.MustSchemaFor[ToolInput](), + OutputSchema: tools.MustSchemaFor[ToolOutput](), + } + + mcp.AddTool(server, toolDef, CreateToolHandler(t, agentName, agentFilename)) + } + + slog.Debug("MCP server starting with stdio transport") + + if err := server.Run(ctx, &mcp.StdioTransport{}); err != nil { + return fmt.Errorf("MCP server error: %w", err) + } + + return nil +} + +func CreateToolHandler(t *team.Team, agentName, agentFilename string) func(context.Context, *mcp.CallToolRequest, ToolInput) (*mcp.CallToolResult, ToolOutput, error) { + return func(ctx context.Context, req *mcp.CallToolRequest, input ToolInput) (*mcp.CallToolResult, ToolOutput, error) { + slog.Debug("MCP tool called", "agent", agentName, "message", input.Message) + + ag, err := t.Agent(agentName) + if err != nil { + return nil, ToolOutput{}, fmt.Errorf("failed to get agent: %w", err) + } + + sess := session.New( + session.WithTitle("MCP tool call"), + session.WithMaxIterations(ag.MaxIterations()), + session.WithUserMessage(agentFilename, input.Message), + session.WithToolsApproved(true), + ) + + rt, err := runtime.New(t, + runtime.WithCurrentAgent(agentName), + runtime.WithRootSessionID(sess.ID), + ) + if err != nil { + return nil, ToolOutput{}, fmt.Errorf("failed to create runtime: %w", err) + } + + _, err = rt.Run(ctx, sess) + if err != nil { + slog.Error("Agent execution failed", "agent", agentName, "error", err) + return nil, ToolOutput{}, fmt.Errorf("agent execution failed: %w", err) + } + + result := sess.GetLastAssistantMessageContent() + if result == "" { + result = "No response from agent" + } + + slog.Debug("Agent execution completed", "agent", agentName, "response_length", len(result)) + + return nil, ToolOutput{Response: result}, nil + } +} + +func isReadOnlyAgent(ctx context.Context, ag *agent.Agent) (bool, error) { + allTools, err := ag.Tools(ctx) + if err != nil { + return false, err + } + + for _, tool := range allTools { + if !tool.Annotations.ReadOnlyHint { + return false, nil + } + } + + return true, nil +} diff --git a/pkg/memory/database/sqlite/sqlite_test.go b/pkg/memory/database/sqlite/sqlite_test.go index 8f88d8ccf..89aed5c78 100644 --- a/pkg/memory/database/sqlite/sqlite_test.go +++ b/pkg/memory/database/sqlite/sqlite_test.go @@ -15,7 +15,6 @@ import ( func setupTestDB(t *testing.T) database.Database { t.Helper() - // Create temporary database file tmpFile := t.TempDir() + "/test.db" db, err := NewMemoryDatabase(tmpFile) @@ -33,12 +32,10 @@ func setupTestDB(t *testing.T) database.Database { } func TestNewMemoryDatabase(t *testing.T) { - // Test successful database creation db := setupTestDB(t) assert.NotNil(t, db, "Database should be created successfully") - // Test with invalid path _, err := NewMemoryDatabase("/:invalid:path") require.Error(t, err, "Should fail with invalid database path") } @@ -48,7 +45,6 @@ func TestAddMemory(t *testing.T) { ctx := t.Context() - // Test adding a valid memory memory := database.UserMemory{ ID: "test-id-1", CreatedAt: time.Now().Format(time.RFC3339), @@ -58,11 +54,9 @@ func TestAddMemory(t *testing.T) { err := db.AddMemory(ctx, memory) require.NoError(t, err, "Adding memory should succeed") - // Test adding a duplicate memory (same ID) err = db.AddMemory(ctx, memory) require.Error(t, err, "Adding memory with duplicate ID should fail") - // Test adding with empty ID emptyIDMemory := database.UserMemory{ ID: "", CreatedAt: time.Now().Format(time.RFC3339), @@ -76,12 +70,10 @@ func TestAddMemory(t *testing.T) { func TestGetMemories(t *testing.T) { db := setupTestDB(t) - // Test with empty database memories, err := db.GetMemories(t.Context()) require.NoError(t, err) assert.Empty(t, memories, "Empty database should return empty memories slice") - // Add test memories testMemories := []database.UserMemory{ { ID: "test-id-1", @@ -100,12 +92,10 @@ func TestGetMemories(t *testing.T) { require.NoError(t, err) } - // Get and verify memories memories, err = db.GetMemories(t.Context()) require.NoError(t, err) assert.Len(t, memories, 2, "Should retrieve both added memories") - // Verify contents (order might not be guaranteed) memoryMap := make(map[string]database.UserMemory) for _, memory := range memories { memoryMap[memory.ID] = memory @@ -122,7 +112,6 @@ func TestGetMemories(t *testing.T) { func TestDeleteMemory(t *testing.T) { db := setupTestDB(t) - // Add a test memory memory := database.UserMemory{ ID: "test-id-1", CreatedAt: time.Now().Format(time.RFC3339), @@ -132,7 +121,6 @@ func TestDeleteMemory(t *testing.T) { err := db.AddMemory(t.Context(), memory) require.NoError(t, err) - // Verify it exists memories, err := db.GetMemories(t.Context()) require.NoError(t, err) require.Len(t, memories, 1) @@ -141,7 +129,6 @@ func TestDeleteMemory(t *testing.T) { err = db.DeleteMemory(t.Context(), memory) require.NoError(t, err, "Deleting existing memory should succeed") - // Verify it's gone memories, err = db.GetMemories(t.Context()) require.NoError(t, err) assert.Empty(t, memories, "Memory should be deleted") @@ -157,11 +144,9 @@ func TestDeleteMemory(t *testing.T) { func TestDatabaseOperationsWithCanceledContext(t *testing.T) { db := setupTestDB(t) - // Create a canceled context ctx, cancel := context.WithCancel(t.Context()) cancel() - // Test operations with canceled context memory := database.UserMemory{ ID: "test-id", CreatedAt: time.Now().Format(time.RFC3339), @@ -179,7 +164,6 @@ func TestDatabaseOperationsWithCanceledContext(t *testing.T) { } func TestDatabaseWithMultipleInstances(t *testing.T) { - // Create first database instance tmpFile := t.TempDir() + "/shared.db" db1, err := NewMemoryDatabase(tmpFile) require.NoError(t, err) @@ -189,7 +173,6 @@ func TestDatabaseWithMultipleInstances(t *testing.T) { os.Remove(tmpFile) }() - // Add a memory to the first instance memory := database.UserMemory{ ID: "shared-id", CreatedAt: time.Now().Format(time.RFC3339), @@ -199,7 +182,6 @@ func TestDatabaseWithMultipleInstances(t *testing.T) { err = db1.AddMemory(t.Context(), memory) require.NoError(t, err) - // Create second database instance pointing to the same file db2, err := NewMemoryDatabase(tmpFile) require.NoError(t, err) defer func() { @@ -207,7 +189,6 @@ func TestDatabaseWithMultipleInstances(t *testing.T) { memDB.db.Close() }() - // Verify second instance can read the memory added by first instance memories, err := db2.GetMemories(t.Context()) require.NoError(t, err) assert.Len(t, memories, 1, "Second instance should see memory added by first instance") diff --git a/pkg/model/provider/anthropic/beta_client_test.go b/pkg/model/provider/anthropic/beta_client_test.go index 586cac185..66622a188 100644 --- a/pkg/model/provider/anthropic/beta_client_test.go +++ b/pkg/model/provider/anthropic/beta_client_test.go @@ -16,13 +16,11 @@ import ( // TestCountAnthropicTokensBeta_Success tests successful token counting for beta API func TestCountAnthropicTokensBeta_Success(t *testing.T) { - // Setup mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/v1/messages/count_tokens", r.URL.Path) assert.Equal(t, "application/json", r.Header.Get("content-type")) assert.NotEmpty(t, r.Header.Get("x-api-key")) - // Verify request body contains expected fields var payload map[string]any err := json.NewDecoder(r.Body).Decode(&payload) assert.NoError(t, err) @@ -36,7 +34,6 @@ func TestCountAnthropicTokensBeta_Success(t *testing.T) { })) defer server.Close() - // Create test data messages := []anthropic.BetaMessageParam{ { Role: anthropic.BetaMessageParamRoleUser, @@ -49,16 +46,13 @@ func TestCountAnthropicTokensBeta_Success(t *testing.T) { {Text: "You are helpful"}, } - // Create client with test server URL client := anthropic.NewClient( option.WithAPIKey("test-key"), option.WithBaseURL(server.URL), ) - // Call function tokens, err := countAnthropicTokensBeta(t.Context(), client, "claude-3-5-sonnet-20241022", messages, system, nil) - // Verify require.NoError(t, err) assert.Equal(t, int64(150), tokens) } @@ -68,7 +62,6 @@ func TestCountAnthropicTokensBeta_NoAPIKey(t *testing.T) { messages := []anthropic.BetaMessageParam{} system := []anthropic.BetaTextBlockParam{} - // Create client without base URL to trigger error client := anthropic.NewClient( option.WithAPIKey("test-key"), // No base URL set @@ -90,7 +83,6 @@ func TestCountAnthropicTokensBeta_ServerError(t *testing.T) { messages := []anthropic.BetaMessageParam{} system := []anthropic.BetaTextBlockParam{} - // Create client with test server URL client := anthropic.NewClient( option.WithAPIKey("test-key"), option.WithBaseURL(server.URL), @@ -108,7 +100,6 @@ func TestCountAnthropicTokensBeta_WithTools(t *testing.T) { err := json.NewDecoder(r.Body).Decode(&payload) assert.NoError(t, err) - // Verify tools are included in payload assert.NotNil(t, payload["tools"]) tools, ok := payload["tools"].([]any) assert.True(t, ok) @@ -129,7 +120,6 @@ func TestCountAnthropicTokensBeta_WithTools(t *testing.T) { }}, } - // Create client with test server URL client := anthropic.NewClient( option.WithAPIKey("test-key"), option.WithBaseURL(server.URL), diff --git a/pkg/model/provider/anthropic/beta_converter_test.go b/pkg/model/provider/anthropic/beta_converter_test.go index e8b50be4c..687b37eea 100644 --- a/pkg/model/provider/anthropic/beta_converter_test.go +++ b/pkg/model/provider/anthropic/beta_converter_test.go @@ -62,10 +62,8 @@ func TestConvertBetaMessages_MergesConsecutiveToolMessages(t *testing.T) { // Convert to Beta format betaMessages := convertBetaMessages(messages) - // Verify structure: User -> Assistant (with 2 tool_use) -> User (with 2 tool_result) -> Assistant require.Len(t, betaMessages, 4, "Should have 4 messages after conversion") - // Verify roles msg0Map, _ := marshalToMapBeta(betaMessages[0]) msg1Map, _ := marshalToMapBeta(betaMessages[1]) msg2Map, _ := marshalToMapBeta(betaMessages[2]) @@ -75,13 +73,11 @@ func TestConvertBetaMessages_MergesConsecutiveToolMessages(t *testing.T) { assert.Equal(t, "user", msg2Map["role"]) assert.Equal(t, "assistant", msg3Map["role"]) - // Verify the second user message (tool results) has both tool_result blocks userMsg2Map, ok := marshalToMapBeta(betaMessages[2]) require.True(t, ok) content := contentArrayBeta(userMsg2Map) require.Len(t, content, 2, "User message should have 2 tool_result blocks") - // Verify both tool_result IDs are present toolResultIDs := collectToolResultIDs(content) assert.Contains(t, toolResultIDs, "tool_call_1") assert.Contains(t, toolResultIDs, "tool_call_2") diff --git a/pkg/model/provider/anthropic/client.go b/pkg/model/provider/anthropic/client.go index 54bcb7d1f..00ce0506f 100644 --- a/pkg/model/provider/anthropic/client.go +++ b/pkg/model/provider/anthropic/client.go @@ -31,7 +31,7 @@ type Client struct { // models:provider_opts:interleaved_thinking: true func (c *Client) interleavedThinkingEnabled() bool { // Default to false if not provided - if c == nil || c.ModelConfig == nil || len(c.ModelConfig.ProviderOpts) == 0 { + if c == nil || len(c.ModelConfig.ProviderOpts) == 0 { return false } v, ok := c.ModelConfig.ProviderOpts["interleaved_thinking"] @@ -121,14 +121,15 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro slog.Debug("Anthropic client created successfully", "model", cfg.Model) - if globalOptions.StructuredOutput != nil { - return &Client{}, errors.New("anthropic does not support native structured_output") + if globalOptions.StructuredOutput() != nil { + return nil, errors.New("anthropic does not support native structured_output") } return &Client{ Config: base.Config{ - ModelConfig: cfg, + ModelConfig: *cfg, ModelOptions: globalOptions, + Env: env, }, clientFn: clientFn, }, nil diff --git a/pkg/model/provider/anthropic/client_test.go b/pkg/model/provider/anthropic/client_test.go index 49def35ce..cef764475 100644 --- a/pkg/model/provider/anthropic/client_test.go +++ b/pkg/model/provider/anthropic/client_test.go @@ -150,7 +150,6 @@ func TestSystemMessages_InterspersedExtractedAndExcluded(t *testing.T) { // Converted messages must exclude system roles and preserve order of others out := convertMessages(msgs) require.Len(t, out, 3) - // Check roles: user, assistant, user expectedRoles := []string{"user", "assistant", "user"} for i, expected := range expectedRoles { b, err := json.Marshal(out[i]) @@ -254,7 +253,6 @@ func TestConvertMessages_GroupToolResults_AfterAssistantToolUse(t *testing.T) { // Validate sequencing is acceptable to Anthropic require.NoError(t, validateAnthropicSequencing(converted)) - // Check the third message (index 2) is a user with tool_result blocks for both tools b, err := json.Marshal(converted[2]) require.NoError(t, err) var m map[string]any @@ -280,13 +278,11 @@ func TestConvertMessages_GroupToolResults_AfterAssistantToolUse(t *testing.T) { // TestCountAnthropicTokens_Success tests successful token counting for standard API func TestCountAnthropicTokens_Success(t *testing.T) { - // Setup mock server server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.Equal(t, "/v1/messages/count_tokens", r.URL.Path) assert.Equal(t, "application/json", r.Header.Get("content-type")) assert.NotEmpty(t, r.Header.Get("x-api-key")) - // Verify request body contains expected fields var payload map[string]any err := json.NewDecoder(r.Body).Decode(&payload) assert.NoError(t, err) @@ -300,7 +296,6 @@ func TestCountAnthropicTokens_Success(t *testing.T) { })) defer server.Close() - // Create test data messages := []anthropic.MessageParam{ { Role: anthropic.MessageParamRoleUser, @@ -313,16 +308,13 @@ func TestCountAnthropicTokens_Success(t *testing.T) { {Text: "You are helpful"}, } - // Create client with test server URL client := anthropic.NewClient( option.WithAPIKey("test-key"), option.WithBaseURL(server.URL), ) - // Call function tokens, err := countAnthropicTokens(t.Context(), client, "claude-3-5-sonnet-20241022", messages, system, nil) - // Verify require.NoError(t, err) assert.Equal(t, int64(150), tokens) } @@ -332,7 +324,6 @@ func TestCountAnthropicTokens_NoAPIKey(t *testing.T) { messages := []anthropic.MessageParam{} system := []anthropic.TextBlockParam{} - // Create client without base URL to trigger error client := anthropic.NewClient( option.WithAPIKey("test-key"), // No base URL set @@ -354,7 +345,6 @@ func TestCountAnthropicTokens_ServerError(t *testing.T) { messages := []anthropic.MessageParam{} system := []anthropic.TextBlockParam{} - // Create client with test server URL client := anthropic.NewClient( option.WithAPIKey("test-key"), option.WithBaseURL(server.URL), @@ -373,7 +363,6 @@ func TestCountAnthropicTokens_WithTools(t *testing.T) { err := json.NewDecoder(r.Body).Decode(&payload) assert.NoError(t, err) - // Verify tools are included in payload assert.NotNil(t, payload["tools"]) tools, ok := payload["tools"].([]any) assert.True(t, ok) @@ -394,7 +383,6 @@ func TestCountAnthropicTokens_WithTools(t *testing.T) { }}, } - // Create client with test server URL client := anthropic.NewClient( option.WithAPIKey("test-key"), option.WithBaseURL(server.URL), diff --git a/pkg/model/provider/base/base.go b/pkg/model/provider/base/base.go index 3259fddc0..6221f90e4 100644 --- a/pkg/model/provider/base/base.go +++ b/pkg/model/provider/base/base.go @@ -2,14 +2,16 @@ package base import ( latest "github.com/docker/cagent/pkg/config/v2" + "github.com/docker/cagent/pkg/environment" "github.com/docker/cagent/pkg/model/provider/options" ) // Config is a common base configuration shared by all provider clients. // It can be embedded in provider-specific Client structs to avoid code duplication. type Config struct { - ModelConfig *latest.ModelConfig + ModelConfig latest.ModelConfig ModelOptions options.ModelOptions + Env environment.Provider } // ID returns the provider and model ID in the format "provider/model" @@ -17,15 +19,6 @@ func (c *Config) ID() string { return c.ModelConfig.Provider + "/" + c.ModelConfig.Model } -// MaxTokens returns the maximum tokens configured for this provider's model -func (c *Config) MaxTokens() int { - if c.ModelConfig == nil { - return 0 - } - return c.ModelConfig.MaxTokens -} - -// Options returns the effective model options used by this provider's model -func (c *Config) Options() options.ModelOptions { - return c.ModelOptions +func (c *Config) BaseConfig() Config { + return *c } diff --git a/pkg/model/provider/clone.go b/pkg/model/provider/clone.go index 6ec5dcd89..a56accc8e 100644 --- a/pkg/model/provider/clone.go +++ b/pkg/model/provider/clone.go @@ -3,40 +3,25 @@ package provider import ( "context" "log/slog" - "strings" - latest "github.com/docker/cagent/pkg/config/v2" - "github.com/docker/cagent/pkg/environment" "github.com/docker/cagent/pkg/model/provider/options" ) // CloneWithOptions returns a new Provider instance using the same provider/model // as the base provider, applying the provided options. If cloning fails, the // original base provider is returned. -func CloneWithOptions(ctx context.Context, base Provider, env environment.Provider, opts ...options.Opt) Provider { - if base == nil { - return nil - } - - id := strings.TrimSpace(base.ID()) - parts := strings.SplitN(id, "/", 2) - if len(parts) != 2 { - return base - } - - cfg := &latest.ModelConfig{Provider: parts[0], Model: parts[1]} - if env == nil { - env = environment.NewDefaultProvider() - } +func CloneWithOptions(ctx context.Context, base Provider, opts ...options.Opt) Provider { + config := base.BaseConfig() // Preserve existing options, then apply overrides. Later opts take precedence. - baseOpts := options.FromModelOptions(base.Options()) + baseOpts := options.FromModelOptions(config.ModelOptions) mergedOpts := append(baseOpts, opts...) - cloned, err := New(ctx, cfg, env, mergedOpts...) + clone, err := New(ctx, &config.ModelConfig, config.Env, mergedOpts...) if err != nil { - slog.Debug("Failed to clone provider; using base provider", "error", err, "id", id) + slog.Debug("Failed to clone provider; using base provider", "error", err, "id", base.ID()) return base } - return cloned + + return clone } diff --git a/pkg/model/provider/dmr/client.go b/pkg/model/provider/dmr/client.go index 5cb02269b..14e8aee46 100644 --- a/pkg/model/provider/dmr/client.go +++ b/pkg/model/provider/dmr/client.go @@ -107,7 +107,7 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, opts ...options.Opt return &Client{ Config: base.Config{ - ModelConfig: cfg, + ModelConfig: *cfg, ModelOptions: globalOptions, }, client: openai.NewClientWithConfig(clientConfig), @@ -384,15 +384,16 @@ func (c *Client) CreateChatCompletionStream(ctx context.Context, messages []chat } else { slog.Error("Failed to marshal DMR request to JSON", "error", err) } - if c.ModelOptions.StructuredOutput != nil { - slog.Debug("Adding structured output to DMR request", "structured_output", c.ModelOptions.StructuredOutput) + if structuredOutput := c.ModelOptions.StructuredOutput(); structuredOutput != nil { + slog.Debug("Adding structured output to DMR request", "structured_output", structuredOutput) + request.ResponseFormat = &openai.ChatCompletionResponseFormat{ Type: openai.ChatCompletionResponseFormatTypeJSONSchema, JSONSchema: &openai.ChatCompletionResponseFormatJSONSchema{ - Name: c.ModelOptions.StructuredOutput.Name, - Description: c.ModelOptions.StructuredOutput.Description, - Schema: jsonSchema(c.ModelOptions.StructuredOutput.Schema), - Strict: c.ModelOptions.StructuredOutput.Strict, + Name: structuredOutput.Name, + Description: structuredOutput.Description, + Schema: jsonSchema(structuredOutput.Schema), + Strict: structuredOutput.Strict, }, } } diff --git a/pkg/model/provider/gemini/adapter_test.go b/pkg/model/provider/gemini/adapter_test.go index b3615d2d8..768c0b712 100644 --- a/pkg/model/provider/gemini/adapter_test.go +++ b/pkg/model/provider/gemini/adapter_test.go @@ -10,9 +10,7 @@ import ( ) func TestStreamAdapter_FunctionCalls(t *testing.T) { - // Test that function calls are properly handled in the final message t.Run("function calls in final message", func(t *testing.T) { - // Create a mock response with function calls mockResp := &genai.GenerateContentResponse{ Candidates: []*genai.Candidate{ { diff --git a/pkg/model/provider/gemini/client.go b/pkg/model/provider/gemini/client.go index 6221db872..2760d7c2d 100644 --- a/pkg/model/provider/gemini/client.go +++ b/pkg/model/provider/gemini/client.go @@ -37,13 +37,13 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro return nil, errors.New("model type must be 'google'") } - var modelOptions options.ModelOptions + var globalOptions options.ModelOptions for _, opt := range opts { - opt(&modelOptions) + opt(&globalOptions) } var clientFn func(context.Context) (*genai.Client, error) - if gateway := modelOptions.Gateway(); gateway == "" { + if gateway := globalOptions.Gateway(); gateway == "" { apiKey := env.Get(ctx, "GOOGLE_API_KEY") if apiKey == "" { return nil, errors.New("GOOGLE_API_KEY environment variable is required") @@ -101,8 +101,9 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro return &Client{ Config: base.Config{ - ModelConfig: cfg, - ModelOptions: modelOptions, + ModelConfig: *cfg, + ModelOptions: globalOptions, + Env: env, }, clientFn: clientFn, }, nil @@ -213,10 +214,6 @@ func convertMessagesToGemini(messages []chat.Message) []*genai.Content { // buildConfig creates GenerateContentConfig from model config func (c *Client) buildConfig() *genai.GenerateContentConfig { - if c.ModelConfig == nil { - return nil - } - config := &genai.GenerateContentConfig{ Temperature: genai.Ptr(float32(c.ModelConfig.Temperature)), TopP: genai.Ptr(float32(c.ModelConfig.TopP)), @@ -251,9 +248,9 @@ func (c *Client) buildConfig() *genai.GenerateContentConfig { } } - if c.ModelOptions.StructuredOutput != nil { + if structuredOutput := c.ModelOptions.StructuredOutput(); structuredOutput != nil { config.ResponseMIMEType = "application/json" - config.ResponseJsonSchema = c.ModelOptions.StructuredOutput.Schema + config.ResponseJsonSchema = structuredOutput.Schema } return config diff --git a/pkg/model/provider/oaistream/adapter.go b/pkg/model/provider/oaistream/adapter.go index 174aab7ef..ba98f5ddd 100644 --- a/pkg/model/provider/oaistream/adapter.go +++ b/pkg/model/provider/oaistream/adapter.go @@ -59,12 +59,22 @@ func (a *StreamAdapter) Recv() (chat.MessageStreamResponse, error) { } // Use the tracked finish reason instead of hardcoding stop finishReason := a.lastFinishReason - if finishReason == "" { + if finishReason == chat.FinishReasonNull || finishReason == "" { finishReason = chat.FinishReasonStop } - response.Choices = append(response.Choices, chat.MessageStreamChoice{ - FinishReason: finishReason, - }) + // OPENAI returns the usage without a finish reason or a choice, so we fake it here + // and create a new choice for the last event in the stream + if len(openaiResponse.Choices) == 0 { + response.Choices = append(response.Choices, chat.MessageStreamChoice{ + FinishReason: finishReason, + }) + } else { + // Other openai-compatible providers DO resturn a choice with finish reason... + response.Choices[0].FinishReason = finishReason + } + if finishReason == chat.FinishReasonStop { + return response, nil + } } // Convert the choices diff --git a/pkg/model/provider/openai/client.go b/pkg/model/provider/openai/client.go index 4c1e7f44e..41343b105 100644 --- a/pkg/model/provider/openai/client.go +++ b/pkg/model/provider/openai/client.go @@ -123,8 +123,9 @@ func NewClient(ctx context.Context, cfg *latest.ModelConfig, env environment.Pro return &Client{ Config: base.Config{ - ModelConfig: cfg, + ModelConfig: *cfg, ModelOptions: globalOptions, + Env: env, }, clientFn: clientFn, }, nil @@ -235,12 +236,12 @@ func (c *Client) CreateChatCompletionStream( }, } - if c.MaxTokens() > 0 { + if maxToken := c.ModelConfig.MaxTokens; maxToken > 0 { if !isResponsesOnlyModel(c.ModelConfig.Model) { - request.MaxTokens = c.MaxTokens() - slog.Debug("OpenAI request configured with max tokens", "max_tokens", c.MaxTokens()) + request.MaxTokens = maxToken + slog.Debug("OpenAI request configured with max tokens", "max_tokens", maxToken, "model", c.ModelConfig.Model) } else { - request.MaxCompletionTokens = c.MaxTokens() + request.MaxCompletionTokens = maxToken slog.Debug("using max_completion_tokens instead of max_tokens for Responses-API models", "model", c.ModelConfig.Model) } } @@ -273,7 +274,7 @@ func (c *Client) CreateChatCompletionStream( // Apply thinking budget: set reasoning_effort parameter if c.ModelConfig.ThinkingBudget != nil { - effort, err := getOpenAIReasoningEffort(c.ModelConfig) + effort, err := getOpenAIReasoningEffort(&c.ModelConfig) if err != nil { slog.Error("OpenAI request using thinking_budget failed", "error", err) return nil, err @@ -283,17 +284,18 @@ func (c *Client) CreateChatCompletionStream( } // Apply structured output configuration - if c.ModelOptions.StructuredOutput != nil { + if structuredOutput := c.ModelOptions.StructuredOutput(); structuredOutput != nil { + slog.Debug("OpenAI request using structured output", "name", structuredOutput.Name, "strict", structuredOutput.Strict) + request.ResponseFormat = &openai.ChatCompletionResponseFormat{ Type: openai.ChatCompletionResponseFormatTypeJSONSchema, JSONSchema: &openai.ChatCompletionResponseFormatJSONSchema{ - Name: c.ModelOptions.StructuredOutput.Name, - Description: c.ModelOptions.StructuredOutput.Description, - Schema: jsonSchema(c.ModelOptions.StructuredOutput.Schema), - Strict: c.ModelOptions.StructuredOutput.Strict, + Name: structuredOutput.Name, + Description: structuredOutput.Description, + Schema: jsonSchema(structuredOutput.Schema), + Strict: structuredOutput.Strict, }, } - slog.Debug("OpenAI request using structured output", "name", c.ModelOptions.StructuredOutput.Name, "strict", c.ModelOptions.StructuredOutput.Strict) } // Log the request in JSON format for debugging diff --git a/pkg/model/provider/options/options.go b/pkg/model/provider/options/options.go index 5cb17e77e..80c13a954 100644 --- a/pkg/model/provider/options/options.go +++ b/pkg/model/provider/options/options.go @@ -6,13 +6,17 @@ import ( type ModelOptions struct { gateway string - StructuredOutput *latest.StructuredOutput + structuredOutput *latest.StructuredOutput } func (c *ModelOptions) Gateway() string { return c.gateway } +func (c *ModelOptions) StructuredOutput() *latest.StructuredOutput { + return c.structuredOutput +} + type Opt func(*ModelOptions) func WithGateway(gateway string) Opt { @@ -21,9 +25,9 @@ func WithGateway(gateway string) Opt { } } -func WithStructuredOutput(output *latest.StructuredOutput) Opt { +func WithStructuredOutput(structuredOutput *latest.StructuredOutput) Opt { return func(cfg *ModelOptions) { - cfg.StructuredOutput = output + cfg.structuredOutput = structuredOutput } } @@ -34,8 +38,8 @@ func FromModelOptions(m ModelOptions) []Opt { if g := m.Gateway(); g != "" { out = append(out, WithGateway(g)) } - if m.StructuredOutput != nil { - out = append(out, WithStructuredOutput(m.StructuredOutput)) + if m.structuredOutput != nil { + out = append(out, WithStructuredOutput(m.structuredOutput)) } return out } diff --git a/pkg/model/provider/provider.go b/pkg/model/provider/provider.go index a8d4bc35b..782dfc60a 100644 --- a/pkg/model/provider/provider.go +++ b/pkg/model/provider/provider.go @@ -9,6 +9,7 @@ import ( latest "github.com/docker/cagent/pkg/config/v2" "github.com/docker/cagent/pkg/environment" "github.com/docker/cagent/pkg/model/provider/anthropic" + "github.com/docker/cagent/pkg/model/provider/base" "github.com/docker/cagent/pkg/model/provider/dmr" "github.com/docker/cagent/pkg/model/provider/gemini" "github.com/docker/cagent/pkg/model/provider/openai" @@ -39,6 +40,16 @@ var ProviderAliases = map[string]Alias{ BaseURL: "https://api.x.ai/v1", TokenEnvVar: "XAI_API_KEY", }, + "nebius": { + APIType: "openai", + BaseURL: "https://api.studio.nebius.com/v1", + TokenEnvVar: "NEBIUS_API_KEY", + }, + "mistral": { + APIType: "openai", + BaseURL: "https://api.mistral.ai/v1", + TokenEnvVar: "MISTRAL_API_KEY", + }, } // Provider defines the interface for model providers @@ -52,10 +63,8 @@ type Provider interface { messages []chat.Message, tools []tools.Tool, ) (chat.MessageStream, error) - // Options returns the effective model options used by this provider - Options() options.ModelOptions - // MaxTokens returns the maximum tokens configured for this provider - MaxTokens() int + // BaseConfig returns the base configuration of this provider + BaseConfig() base.Config } func New(ctx context.Context, cfg *latest.ModelConfig, env environment.Provider, opts ...options.Opt) (Provider, error) { diff --git a/pkg/modelsdev/store.go b/pkg/modelsdev/store.go index eb8a08f2a..468ef24a5 100644 --- a/pkg/modelsdev/store.go +++ b/pkg/modelsdev/store.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net/http" "os" "path/filepath" @@ -91,7 +92,7 @@ func (s *Store) GetDatabase(ctx context.Context) (*Database, error) { // Save to cache if err := s.saveToCache(cacheFile, database); err != nil { // Log the error but don't fail the request - fmt.Printf("Warning: failed to save to cache: %v\n", err) + slog.Warn("Warning: failed to save to cache", "error", err) } return database, nil diff --git a/pkg/oci/Dockerfile.template b/pkg/oci/Dockerfile.template index d5340e337..4756626c6 100644 --- a/pkg/oci/Dockerfile.template +++ b/pkg/oci/Dockerfile.template @@ -22,7 +22,6 @@ LABEL org.opencontainers.image.version="" VOLUME /data ENV TELEMETRY_ENABLED=true -ENV CAGENT_HIDE_FEEDBACK_LINK=0 ENV CAGENT_HIDE_TELEMETRY_BANNER=0 USER root RUN cat </agent.yaml && chmod 777 /agent.yaml diff --git a/pkg/oci/build.go b/pkg/oci/build.go index 9d628fc97..6ecd69d32 100644 --- a/pkg/oci/build.go +++ b/pkg/oci/build.go @@ -4,7 +4,6 @@ import ( "bytes" "context" _ "embed" - "fmt" "log/slog" "os" "os/exec" @@ -14,6 +13,7 @@ import ( "github.com/goccy/go-yaml" + "github.com/docker/cagent/pkg/cli" "github.com/docker/cagent/pkg/config" "github.com/docker/cagent/pkg/filesystem" ) @@ -28,7 +28,7 @@ type Options struct { Pull bool } -func BuildDockerImage(ctx context.Context, agentFilePath string, fs filesystem.FS, dockerImageName string, opts Options) error { +func BuildDockerImage(ctx context.Context, out *cli.Printer, agentFilePath string, fs filesystem.FS, dockerImageName string, opts Options) error { cfg, err := config.LoadConfig(agentFilePath, fs) if err != nil { return err @@ -71,7 +71,7 @@ func BuildDockerImage(ctx context.Context, agentFilePath string, fs filesystem.F dockerfile := dockerfileBuf.String() if opts.DryRun { - fmt.Println(dockerfile) + out.Println(dockerfile) return nil } diff --git a/pkg/oci/package_test.go b/pkg/oci/package_test.go index 76918cffa..1ced40d4e 100644 --- a/pkg/oci/package_test.go +++ b/pkg/oci/package_test.go @@ -96,7 +96,6 @@ func TestPackageFileAsOCIToStoreDifferentFileTypes(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - // Create test file testFile := filepath.Join(t.TempDir(), tc.filename) require.NoError(t, os.WriteFile(testFile, []byte(tc.content), 0o644)) @@ -106,7 +105,6 @@ func TestPackageFileAsOCIToStoreDifferentFileTypes(t *testing.T) { digests = append(digests, digest) - // Verify the artifact was stored img, err := store.GetArtifactImage(tc.tag) require.NoError(t, err) assert.NotNil(t, img) diff --git a/pkg/remote/pull.go b/pkg/remote/pull.go index e87a53a11..a6de03c9b 100644 --- a/pkg/remote/pull.go +++ b/pkg/remote/pull.go @@ -30,7 +30,6 @@ func Pull(registryRef string, opts ...crane.Option) (string, error) { if meta, metaErr := store.GetArtifactMetadata(localRef); metaErr == nil { if meta.Digest == remoteDigest { - fmt.Printf("Artifact %s already exists in the store (digest %s). Using cache.\n", localRef, remoteDigest) return meta.Digest, nil } } diff --git a/pkg/remote/push_test.go b/pkg/remote/push_test.go index ed11622af..7d8aa5ba4 100644 --- a/pkg/remote/push_test.go +++ b/pkg/remote/push_test.go @@ -74,7 +74,6 @@ func TestPushWithOptions(t *testing.T) { } func TestContentStore(t *testing.T) { - // Create a content store store, err := content.NewStore(content.WithBaseDir(t.TempDir())) require.NoError(t, err) diff --git a/pkg/runtime/event.go b/pkg/runtime/event.go index 726e8e0cc..9816cab66 100644 --- a/pkg/runtime/event.go +++ b/pkg/runtime/event.go @@ -183,8 +183,16 @@ func Warning(message, agentName string) Event { } type TokenUsageEvent struct { - Type string `json:"type"` - Usage *Usage `json:"usage"` + // Type stays "token_usage" for backward compatibility with existing clients. + Type string `json:"type"` + // SessionID lets consumers correlate usage snapshots with a specific session/sub-session. + SessionID string `json:"session_id"` + // Usage retains the legacy aggregate payload so older UIs do not break immediately. + Usage *Usage `json:"usage,omitempty"` + // SelfUsage captures the tokens/cost generated directly by the emitting session. + SelfUsage *Usage `json:"self_usage,omitempty"` + // InclusiveUsage represents the session plus any merged child usage for team totals. + InclusiveUsage *Usage `json:"inclusive_usage,omitempty"` AgentContext } @@ -196,16 +204,27 @@ type Usage struct { Cost float64 `json:"cost"` } -func TokenUsage(inputTokens, outputTokens, contextLength, contextLimit int, cost float64) Event { +func TokenUsage(sessionID, agentName string, selfUsage, inclusiveUsage *Usage) Event { + if selfUsage == nil && inclusiveUsage == nil { + return &TokenUsageEvent{Type: "token_usage"} + } + + // Default to inclusive usage when only one snapshot is provided. + if selfUsage == nil { + selfUsage = inclusiveUsage + } + if inclusiveUsage == nil { + inclusiveUsage = selfUsage + } + + // Emit both snapshots so the UI can show per-session and team totals simultaneously. return &TokenUsageEvent{ - Type: "token_usage", - Usage: &Usage{ - ContextLength: contextLength, - ContextLimit: contextLimit, - InputTokens: inputTokens, - OutputTokens: outputTokens, - Cost: cost, - }, + Type: "token_usage", + SessionID: sessionID, + Usage: inclusiveUsage, + SelfUsage: selfUsage, + InclusiveUsage: inclusiveUsage, + AgentContext: AgentContext{AgentName: agentName}, } } diff --git a/pkg/runtime/remote_runtime.go b/pkg/runtime/remote_runtime.go index 155db423c..cc7c832b8 100644 --- a/pkg/runtime/remote_runtime.go +++ b/pkg/runtime/remote_runtime.go @@ -7,6 +7,7 @@ import ( "github.com/docker/cagent/pkg/api" "github.com/docker/cagent/pkg/chat" + latest "github.com/docker/cagent/pkg/config/v2" "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/team" ) @@ -63,18 +64,26 @@ func (r *RemoteRuntime) CurrentAgentName() string { } func (r *RemoteRuntime) CurrentAgentCommands(ctx context.Context) map[string]string { + return r.readCurrentAgentConfig(ctx).Commands +} + +func (r *RemoteRuntime) CurrentWelcomeMessage(ctx context.Context) string { + return r.readCurrentAgentConfig(ctx).WelcomeMessage +} + +func (r *RemoteRuntime) readCurrentAgentConfig(ctx context.Context) latest.AgentConfig { cfg, err := r.client.GetAgent(ctx, r.agentFilename) if err != nil { - return map[string]string{} + return latest.AgentConfig{} } for agentName, agent := range cfg.Agents { if agentName == r.currentAgent { - return agent.Commands + return agent } } - return map[string]string{} + return latest.AgentConfig{} } // RunStream starts the agent's interaction loop and returns a channel of events @@ -127,7 +136,7 @@ func (r *RemoteRuntime) Run(ctx context.Context, sess *session.Session) ([]sessi } // Resume allows resuming execution after user confirmation -func (r *RemoteRuntime) Resume(ctx context.Context, confirmationType string) { +func (r *RemoteRuntime) Resume(ctx context.Context, confirmationType ResumeType) { slog.Debug("Resuming remote runtime", "agent", r.currentAgent, "confirmation_type", confirmationType, "session_id", r.sessionID) if r.sessionID == "" { @@ -135,7 +144,7 @@ func (r *RemoteRuntime) Resume(ctx context.Context, confirmationType string) { return } - if err := r.client.ResumeSession(ctx, r.sessionID, confirmationType); err != nil { + if err := r.client.ResumeSession(ctx, r.sessionID, string(confirmationType)); err != nil { slog.Error("Failed to resume remote session", "error", err, "session_id", r.sessionID) } } diff --git a/pkg/runtime/runtime.go b/pkg/runtime/runtime.go index 69bee967f..add197267 100644 --- a/pkg/runtime/runtime.go +++ b/pkg/runtime/runtime.go @@ -7,6 +7,7 @@ import ( "fmt" "io" "log/slog" + "os" "strings" "sync" "time" @@ -25,8 +26,11 @@ import ( "github.com/docker/cagent/pkg/team" "github.com/docker/cagent/pkg/telemetry" "github.com/docker/cagent/pkg/tools" + "github.com/docker/cagent/pkg/tools/builtin" ) +const tokenUsageLogFile = "token_usage_chunks.log" + type ResumeType string type modelStore interface { @@ -67,12 +71,14 @@ type Runtime interface { CurrentAgentName() string // CurrentAgentCommands returns the commands for the active agent CurrentAgentCommands(ctx context.Context) map[string]string + // CurrentWelcomeMessage returns the welcome message for the active agent + CurrentWelcomeMessage(ctx context.Context) string // RunStream starts the agent's interaction loop and returns a channel of events RunStream(ctx context.Context, sess *session.Session) <-chan Event // Run starts the agent's interaction loop and returns the final messages Run(ctx context.Context, sess *session.Session) ([]session.Message, error) // Resume allows resuming execution after user confirmation - Resume(ctx context.Context, confirmationType string) + Resume(ctx context.Context, confirmationType ResumeType) // Summarize generates a summary for the session Summarize(ctx context.Context, sess *session.Session, events chan Event) // ResumeElicitation sends an elicitation response back to a waiting elicitation request @@ -182,6 +188,10 @@ func (r *LocalRuntime) CurrentAgentCommands(context.Context) map[string]string { return r.CurrentAgent().Commands() } +func (r *LocalRuntime) CurrentWelcomeMessage(ctx context.Context) string { + return r.CurrentAgent().WelcomeMessage() +} + // CurrentAgent returns the current agent func (r *LocalRuntime) CurrentAgent() *agent.Agent { // We validated already that the agent exists @@ -192,7 +202,7 @@ func (r *LocalRuntime) CurrentAgent() *agent.Agent { // registerDefaultTools registers the default tool handlers func (r *LocalRuntime) registerDefaultTools() { slog.Debug("Registering default tools") - r.toolMap["transfer_task"] = r.handleTaskTransfer + r.toolMap[builtin.ToolNameTransferTask] = r.handleTaskTransfer slog.Debug("Registered default tools", "count", len(r.toolMap)) } @@ -374,7 +384,10 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c if m != nil { contextLimit = m.Limit.Context } - events <- TokenUsage(sess.InputTokens, sess.OutputTokens, sess.InputTokens+sess.OutputTokens, contextLimit, sess.Cost) + // Emit a snapshot that downstream components can use for both self and inclusive totals. + inclusiveUsage := buildInclusiveUsageSnapshot(sess, contextLimit) + selfUsage := buildSelfUsageSnapshot(sess, contextLimit) + events <- TokenUsage(sess.ID, a.Name(), selfUsage, inclusiveUsage) if m != nil && r.sessionCompaction { if sess.InputTokens+sess.OutputTokens > int(float64(contextLimit)*0.9) { @@ -383,7 +396,10 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c if len(res.Calls) == 0 { events <- SessionCompaction(sess.ID, "start", r.currentAgent) r.Summarize(ctx, sess, events) - events <- TokenUsage(sess.InputTokens, sess.OutputTokens, sess.InputTokens+sess.OutputTokens, contextLimit, sess.Cost) + // Refresh usage after compaction since token counts may have changed. + inclusiveUsage := buildInclusiveUsageSnapshot(sess, contextLimit) + selfUsage := buildSelfUsageSnapshot(sess, contextLimit) + events <- TokenUsage(sess.ID, a.Name(), selfUsage, inclusiveUsage) events <- SessionCompaction(sess.ID, "completed", r.currentAgent) } } @@ -397,7 +413,10 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c if sess.InputTokens+sess.OutputTokens > int(float64(contextLimit)*0.9) { events <- SessionCompaction(sess.ID, "start", r.currentAgent) r.Summarize(ctx, sess, events) - events <- TokenUsage(sess.InputTokens, sess.OutputTokens, sess.InputTokens+sess.OutputTokens, contextLimit, sess.Cost) + // Emit the post-compaction snapshot as well for consistency. + inclusiveUsage := buildInclusiveUsageSnapshot(sess, contextLimit) + selfUsage := buildSelfUsageSnapshot(sess, contextLimit) + events <- TokenUsage(sess.ID, a.Name(), selfUsage, inclusiveUsage) events <- SessionCompaction(sess.ID, "completed", r.currentAgent) } } @@ -414,7 +433,7 @@ func (r *LocalRuntime) RunStream(ctx context.Context, sess *session.Session) <-c // getTools executes tool retrieval with automatic OAuth handling func (r *LocalRuntime) getTools(ctx context.Context, a *agent.Agent, sessionSpan trace.Span, events chan Event) ([]tools.Tool, error) { - shouldEmitMCPInit := events != nil && len(a.ToolSets()) > 0 + shouldEmitMCPInit := len(a.ToolSets()) > 0 if shouldEmitMCPInit { events <- MCPInitStarted(a.Name()) } @@ -463,14 +482,14 @@ func formatToolWarning(a *agent.Agent, warnings []string) string { return strings.TrimSuffix(builder.String(), "\n") } -func (r *LocalRuntime) Resume(_ context.Context, confirmationType string) { +func (r *LocalRuntime) Resume(_ context.Context, confirmationType ResumeType) { slog.Debug("Resuming runtime", "agent", r.currentAgent, "confirmation_type", confirmationType) cType := ResumeTypeApproveSession switch confirmationType { - case "approve": + case ResumeTypeApprove: cType = ResumeTypeApprove - case "reject": + case ResumeTypeReject: cType = ResumeTypeReject } @@ -537,15 +556,25 @@ func (r *LocalRuntime) handleStream(ctx context.Context, stream chat.MessageStre } if response.Usage != nil { + selfInput := response.Usage.InputTokens + response.Usage.CachedInputTokens + selfOutput := response.Usage.OutputTokens + response.Usage.CachedOutputTokens + response.Usage.ReasoningTokens + + logTokenUsageChunk(sess.ID, a.Name(), response.Usage) + + var callCost float64 if m != nil { - sess.Cost += (float64(response.Usage.InputTokens)*m.Cost.Input + + callCost = (float64(response.Usage.InputTokens)*m.Cost.Input + float64(response.Usage.OutputTokens+response.Usage.ReasoningTokens)*m.Cost.Output + float64(response.Usage.CachedInputTokens)*m.Cost.CacheRead + float64(response.Usage.CachedOutputTokens)*m.Cost.CacheWrite) / 1e6 } - sess.InputTokens = response.Usage.InputTokens + response.Usage.CachedInputTokens - sess.OutputTokens = response.Usage.OutputTokens + response.Usage.CachedOutputTokens + response.Usage.ReasoningTokens + sess.SelfCost += callCost + sess.SelfInputTokens += selfInput + sess.SelfOutputTokens += selfOutput + sess.Cost = sess.SelfCost + sess.ChildCost + sess.InputTokens = sess.ChildInputTokens + sess.SelfInputTokens + sess.OutputTokens = sess.ChildOutputTokens + sess.SelfOutputTokens modelName := "unknown" if m != nil { @@ -657,6 +686,54 @@ func (r *LocalRuntime) handleStream(ctx context.Context, stream chat.MessageStre }, nil } +// buildInclusiveUsageSnapshot captures the session's current inclusive usage in the shared event format. +func buildInclusiveUsageSnapshot(sess *session.Session, contextLimit int) *Usage { + return &Usage{ + ContextLength: sess.InputTokens + sess.OutputTokens, + ContextLimit: contextLimit, + InputTokens: sess.InputTokens, + OutputTokens: sess.OutputTokens, + Cost: sess.Cost, + } +} + +func buildSelfUsageSnapshot(sess *session.Session, contextLimit int) *Usage { + return &Usage{ + ContextLength: sess.SelfInputTokens + sess.SelfOutputTokens, + ContextLimit: contextLimit, + InputTokens: sess.SelfInputTokens, + OutputTokens: sess.SelfOutputTokens, + Cost: sess.SelfCost, + } +} + +func logTokenUsageChunk(sessionID, agentName string, usage *chat.Usage) { + if usage == nil { + return + } + entry := fmt.Sprintf("%s session=%s agent=%s input=%d output=%d cached_input=%d cached_output=%d reasoning=%d\n", + time.Now().Format(time.RFC3339Nano), + sessionID, + agentName, + usage.InputTokens, + usage.OutputTokens, + usage.CachedInputTokens, + usage.CachedOutputTokens, + usage.ReasoningTokens, + ) + + file, err := os.OpenFile(tokenUsageLogFile, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err != nil { + slog.Warn("Failed to open token usage log file", "error", err) + return + } + defer file.Close() + + if _, err := file.WriteString(entry); err != nil { + slog.Warn("Failed to write token usage log entry", "error", err) + } +} + // processToolCalls handles the execution of tool calls for an agent func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Session, calls []tools.ToolCall, agentTools []tools.Tool, events chan Event) { a := r.CurrentAgent() @@ -682,7 +759,7 @@ func (r *LocalRuntime) processToolCalls(ctx context.Context, sess *session.Sessi }, } slog.Debug("Using runtime tool handler", "tool", toolCall.Function.Name, "session_id", sess.ID) - if sess.ToolsApproved || toolCall.Function.Name == "transfer_task" { + if sess.ToolsApproved || toolCall.Function.Name == builtin.ToolNameTransferTask { r.runAgentTool(callCtx, handler, sess, toolCall, tool, events, a) } else { slog.Debug("Tools not approved, waiting for resume", "tool", toolCall.Function.Name, "session_id", sess.ID) @@ -963,10 +1040,10 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses session.WithSystemMessage(memberAgentTask), session.WithImplicitUserMessage("", "Follow the default instructions"), session.WithMaxIterations(child.MaxIterations()), + session.WithTitle("Transferred task"), + session.WithToolsApproved(sess.ToolsApproved), + session.WithSendUserMessage(false), ) - s.SendUserMessage = false - s.Title = "Transferred task" - s.ToolsApproved = sess.ToolsApproved for event := range r.RunStream(ctx, s) { evts <- event @@ -978,10 +1055,40 @@ func (r *LocalRuntime) handleTaskTransfer(ctx context.Context, sess *session.Ses } sess.ToolsApproved = s.ToolsApproved - sess.Cost += s.Cost + parentCostBefore := sess.Cost // capture parent values for debug logging + parentInputBefore := sess.InputTokens + parentOutputBefore := sess.OutputTokens + + // Merge the child session's usage into the parent's child buckets, then recompute totals. + sess.ChildCost += s.Cost + sess.ChildInputTokens += s.InputTokens + sess.ChildOutputTokens += s.OutputTokens + sess.Cost = sess.SelfCost + sess.ChildCost + sess.InputTokens = sess.ChildInputTokens + sess.SelfInputTokens + sess.OutputTokens = sess.ChildOutputTokens + sess.SelfOutputTokens + + slog.Debug("Merged sub-session usage into parent", + "parent_session_id", sess.ID, + "child_session_id", s.ID, + "parent_cost_before", parentCostBefore, + "child_cost", s.Cost, + "parent_cost_after", sess.Cost, + "parent_input_before", parentInputBefore, + "child_input_merged", s.InputTokens, + "parent_input_after", sess.InputTokens, + "parent_output_before", parentOutputBefore, + "child_output_merged", s.OutputTokens, + "parent_output_after", sess.OutputTokens, + ) sess.AddSubSession(s) + // Emit an updated token usage snapshot so the UI sees the merged totals immediately. + inclusiveUsage := buildInclusiveUsageSnapshot(sess, 0) + selfUsage := buildSelfUsageSnapshot(sess, 0) + parentAgentName := ca + evts <- TokenUsage(sess.ID, parentAgentName, selfUsage, inclusiveUsage) + slog.Debug("Task transfer completed", "agent", params.Agent, "task", params.Task) span.SetStatus(codes.Ok, "task transfer completed") @@ -1013,14 +1120,15 @@ func (r *LocalRuntime) generateSessionTitle(ctx context.Context, sess *session.S systemPrompt := "You are a helpful AI assistant that generates concise, descriptive titles for conversations. You will be given a conversation history and asked to create a title that captures the main topic." userPrompt := fmt.Sprintf("Based on the following message a user sent to an AI assistant, generate a short, descriptive title (maximum 50 characters) that captures the main topic or purpose of the conversation. Return ONLY the title text, nothing else.\n\nUser message:%s\n\n", conversationHistory.String()) - titleModel := provider.CloneWithOptions(ctx, r.CurrentAgent().Model(), nil, options.WithStructuredOutput(nil)) + titleModel := provider.CloneWithOptions(ctx, r.CurrentAgent().Model(), options.WithStructuredOutput(nil)) newTeam := team.New( team.WithID("title-generator"), team.WithAgents(agent.New("root", systemPrompt, agent.WithModel(titleModel))), ) - titleSession := session.New(session.WithSystemMessage(systemPrompt)) - titleSession.AddMessage(session.UserMessage("", userPrompt)) - titleSession.Title = "Generating title..." + titleSession := session.New( + session.WithUserMessage("", userPrompt), + session.WithTitle("Generating title..."), + ) titleRuntime, err := New(newTeam, WithSessionCompaction(false)) if err != nil { @@ -1079,7 +1187,7 @@ func (r *LocalRuntime) Summarize(ctx context.Context, sess *session.Session, eve // Create a new session for summary generation systemPrompt := "You are a helpful AI assistant that creates comprehensive summaries of conversations. You will be given a conversation history and asked to create a concise yet thorough summary that captures the key points, decisions made, and outcomes." userPrompt := fmt.Sprintf("Based on the following conversation between a user and an AI assistant, create a comprehensive summary that captures:\n- The main topics discussed\n- Key information exchanged\n- Decisions made or conclusions reached\n- Important outcomes or results\n\nProvide a well-structured summary (2-4 paragraphs) that someone could read to understand what happened in this conversation. Return ONLY the summary text, nothing else.\n\nConversation history:%s\n\nGenerate a summary for this conversation:", conversationHistory.String()) - newModel := provider.CloneWithOptions(ctx, r.CurrentAgent().Model(), nil, options.WithStructuredOutput(nil)) + newModel := provider.CloneWithOptions(ctx, r.CurrentAgent().Model(), options.WithStructuredOutput(nil)) newTeam := team.New( team.WithID("summary-generator"), team.WithAgents(agent.New("root", systemPrompt, agent.WithModel(newModel))), diff --git a/pkg/runtime/runtime_test.go b/pkg/runtime/runtime_test.go index 664978a57..e2708f1df 100644 --- a/pkg/runtime/runtime_test.go +++ b/pkg/runtime/runtime_test.go @@ -13,7 +13,7 @@ import ( "github.com/docker/cagent/pkg/agent" "github.com/docker/cagent/pkg/chat" - "github.com/docker/cagent/pkg/model/provider/options" + "github.com/docker/cagent/pkg/model/provider/base" "github.com/docker/cagent/pkg/modelsdev" "github.com/docker/cagent/pkg/session" "github.com/docker/cagent/pkg/team" @@ -143,7 +143,7 @@ func (m *mockProvider) CreateChatCompletionStream(context.Context, []chat.Messag return m.stream, nil } -func (m *mockProvider) Options() options.ModelOptions { return options.ModelOptions{} } +func (m *mockProvider) BaseConfig() base.Config { return base.Config{} } func (m *mockProvider) MaxTokens() int { return 0 } @@ -157,7 +157,7 @@ func (m *mockProviderWithError) CreateChatCompletionStream(context.Context, []ch return nil, fmt.Errorf("simulated error creating chat completion stream") } -func (m *mockProviderWithError) Options() options.ModelOptions { return options.ModelOptions{} } +func (m *mockProviderWithError) BaseConfig() base.Config { return base.Config{} } func (m *mockProviderWithError) MaxTokens() int { return 0 } @@ -200,6 +200,16 @@ func hasEventType(t *testing.T, events []Event, target Event) bool { return false } +func makeUsageSnapshot(input, output, contextLimit int) *Usage { + return &Usage{ + InputTokens: input, + OutputTokens: output, + ContextLength: input + output, + ContextLimit: contextLimit, + Cost: 0, + } +} + func TestSimple(t *testing.T) { stream := newStreamBuilder(). AddContent("Hello"). @@ -210,11 +220,12 @@ func TestSimple(t *testing.T) { events := runSession(t, sess, stream) + snapshot := makeUsageSnapshot(3, 2, 0) expectedEvents := []Event{ UserMessage("Hi"), StreamStarted(sess.ID, "root"), AgentChoice("root", "Hello"), - TokenUsage(3, 2, 5, 0, 0), + TokenUsage(sess.ID, "root", snapshot, snapshot), StreamStopped(sess.ID, "root"), } @@ -235,6 +246,7 @@ func TestMultipleContentChunks(t *testing.T) { events := runSession(t, sess, stream) + snapshot := makeUsageSnapshot(8, 12, 0) expectedEvents := []Event{ UserMessage("Please greet me"), StreamStarted(sess.ID, "root"), @@ -243,7 +255,7 @@ func TestMultipleContentChunks(t *testing.T) { AgentChoice("root", "how "), AgentChoice("root", "are "), AgentChoice("root", "you?"), - TokenUsage(8, 12, 20, 0, 0), + TokenUsage(sess.ID, "root", snapshot, snapshot), StreamStopped(sess.ID, "root"), } @@ -262,13 +274,14 @@ func TestWithReasoning(t *testing.T) { events := runSession(t, sess, stream) + snapshot := makeUsageSnapshot(10, 15, 0) expectedEvents := []Event{ UserMessage("Hi"), StreamStarted(sess.ID, "root"), AgentChoiceReasoning("root", "Let me think about this..."), AgentChoiceReasoning("root", " I should respond politely."), AgentChoice("root", "Hello, how can I help you?"), - TokenUsage(10, 15, 25, 0, 0), + TokenUsage(sess.ID, "root", snapshot, snapshot), StreamStopped(sess.ID, "root"), } @@ -288,6 +301,7 @@ func TestMixedContentAndReasoning(t *testing.T) { events := runSession(t, sess, stream) + snapshot := makeUsageSnapshot(15, 20, 0) expectedEvents := []Event{ UserMessage("Hi there"), StreamStarted(sess.ID, "root"), @@ -295,7 +309,7 @@ func TestMixedContentAndReasoning(t *testing.T) { AgentChoice("root", "Hello!"), AgentChoiceReasoning("root", " I should be friendly"), AgentChoice("root", " How can I help you today?"), - TokenUsage(15, 20, 35, 0, 0), + TokenUsage(sess.ID, "root", snapshot, snapshot), StreamStopped(sess.ID, "root"), } @@ -344,7 +358,6 @@ func TestErrorEvent(t *testing.T) { require.IsType(t, &ErrorEvent{}, events[2]) require.IsType(t, &StreamStoppedEvent{}, events[3]) - // Check the error message contains our test error errorEvent := events[2].(*ErrorEvent) require.Contains(t, errorEvent.Error, "simulated error") } @@ -453,7 +466,7 @@ func (p *queueProvider) CreateChatCompletionStream(context.Context, []chat.Messa return s, nil } -func (p *queueProvider) Options() options.ModelOptions { return options.ModelOptions{} } +func (p *queueProvider) BaseConfig() base.Config { return base.Config{} } func (p *queueProvider) MaxTokens() int { return 0 } @@ -529,15 +542,15 @@ func TestCompactionOccursAfterToolResultsWhenToolUsePresent(t *testing.T) { require.NotEqual(t, -1, toolRespIdx, "expected a ToolCallResponseEvent") require.NotEqual(t, -1, compactionStartIdx, "expected a SessionCompaction start event") - // Assert compaction is triggered only after tool results have been appended require.Greater(t, compactionStartIdx, toolRespIdx, "compaction should occur after tool results when tool_use is present") } func TestSessionWithoutUserMessage(t *testing.T) { stream := newStreamBuilder().AddContent("OK").AddStopWithUsage(1, 1).Build() - sess := session.New() - sess.SendUserMessage = false + sess := session.New( + session.WithSendUserMessage(false), + ) events := runSession(t, sess, stream) @@ -630,7 +643,6 @@ func TestNewRuntime_NoAgentsError(t *testing.T) { } func TestNewRuntime_InvalidCurrentAgentError(t *testing.T) { - // Create a team with a single agent named "root" root := agent.New("root", "You are a test agent") tm := team.New(team.WithAgents(root)) @@ -640,7 +652,6 @@ func TestNewRuntime_InvalidCurrentAgentError(t *testing.T) { } func TestSummarize_EmptySession(t *testing.T) { - // Create a runtime with a simple agent prov := &mockProvider{id: "test/mock-model", stream: &mockStream{}} root := agent.New("root", "You are a test agent", agent.WithModel(prov)) tm := team.New(team.WithAgents(root)) @@ -648,7 +659,6 @@ func TestSummarize_EmptySession(t *testing.T) { rt, err := New(tm, WithSessionCompaction(false), WithModelStore(mockModelStore{})) require.NoError(t, err) - // Create an empty session (no messages) sess := session.New() sess.Title = "Empty Session Test" @@ -702,7 +712,6 @@ func TestProcessToolCalls_UnknownTool_NoToolResultMessage(t *testing.T) { for range events { } - // Verify no tool result message was added for the unknown tool var sawToolMsg bool for _, it := range sess.Messages { if it.IsMessage() && it.Message.Message.Role == chat.MessageRoleTool && it.Message.Message.ToolCallID == "tool-unknown-1" { diff --git a/pkg/server/image_test.go b/pkg/server/image_test.go index 27d4bbac7..5fdb75ad2 100644 --- a/pkg/server/image_test.go +++ b/pkg/server/image_test.go @@ -14,7 +14,6 @@ import ( // TestAPIMessageWithImageStructure tests that the API message type can hold image attachments func TestAPIMessageWithImageStructure(t *testing.T) { - // Create a message with image attachment using multi_content imageData := createTestImageDataURL() messages := []api.Message{ { @@ -44,17 +43,14 @@ func TestAPIMessageWithImageStructure(t *testing.T) { err = json.Unmarshal(jsonData, &decoded) require.NoError(t, err) - // Verify the structure assert.Len(t, decoded, 1) assert.Equal(t, chat.MessageRoleUser, decoded[0].Role) assert.Empty(t, decoded[0].Content) // Content should be empty when using MultiContent assert.Len(t, decoded[0].MultiContent, 2) - // Verify text part assert.Equal(t, chat.MessagePartTypeText, decoded[0].MultiContent[0].Type) assert.Equal(t, "What's in this image?", decoded[0].MultiContent[0].Text) - // Verify image part assert.Equal(t, chat.MessagePartTypeImageURL, decoded[0].MultiContent[1].Type) require.NotNil(t, decoded[0].MultiContent[1].ImageURL) assert.Equal(t, imageData, decoded[0].MultiContent[1].ImageURL.URL) @@ -79,7 +75,6 @@ func TestAPIMessageWithTextOnly(t *testing.T) { err = json.Unmarshal(jsonData, &decoded) require.NoError(t, err) - // Verify the structure assert.Len(t, decoded, 1) assert.Equal(t, chat.MessageRoleUser, decoded[0].Role) assert.Equal(t, "Tell me a joke", decoded[0].Content) diff --git a/pkg/server/server.go b/pkg/server/server.go index 3b2ed04b1..032b76c56 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -46,6 +46,7 @@ type Server struct { sessionStore session.Store runConfig config.RuntimeConfig teams map[string]*team.Team + teamsMu sync.RWMutex agentsDir string rootFS *os.Root } @@ -154,6 +155,65 @@ func (s *Server) Serve(ctx context.Context, ln net.Listener) error { return nil } +// getTeam retrieves a team by key with read lock +func (s *Server) getTeam(key string) (*team.Team, bool) { + s.teamsMu.RLock() + defer s.teamsMu.RUnlock() + t, exists := s.teams[key] + return t, exists +} + +// setTeam sets a team by key with write lock, returns the old team if it existed +func (s *Server) setTeam(key string, t *team.Team) (*team.Team, bool) { + s.teamsMu.Lock() + defer s.teamsMu.Unlock() + oldTeam, exists := s.teams[key] + s.teams[key] = t + return oldTeam, exists +} + +// deleteTeam removes a team by key with write lock +func (s *Server) deleteTeam(key string) { + s.teamsMu.Lock() + defer s.teamsMu.Unlock() + delete(s.teams, key) +} + +// getTeams returns a snapshot of all teams with read lock +func (s *Server) getTeams() map[string]*team.Team { + s.teamsMu.RLock() + defer s.teamsMu.RUnlock() + teams := make(map[string]*team.Team, len(s.teams)) + for k, v := range s.teams { + teams[k] = v + } + return teams +} + +// replaceAllTeams replaces the entire teams map with write lock, returns old teams +func (s *Server) replaceAllTeams(teams map[string]*team.Team) map[string]*team.Team { + s.teamsMu.Lock() + defer s.teamsMu.Unlock() + oldTeams := s.teams + s.teams = teams + return oldTeams +} + +// countTeams returns the number of teams with read lock +func (s *Server) countTeams() int { + s.teamsMu.RLock() + defer s.teamsMu.RUnlock() + return len(s.teams) +} + +// hasTeam checks if a team exists with read lock +func (s *Server) hasTeam(key string) bool { + s.teamsMu.RLock() + defer s.teamsMu.RUnlock() + _, exists := s.teams[key] + return exists +} + func (s *Server) ping(c echo.Context) error { return c.JSON(http.StatusOK, map[string]string{"status": "ok"}) } @@ -216,10 +276,8 @@ func (s *Server) editAgentConfigYAML(c echo.Context) error { yamlContent := string(body) // Validate the YAML content by attempting to parse it - // Use ReferenceDirs to allow YAML files to reference other files relative to the agent directory - agentDir := filepath.Dir(p) var tmpConfig latest.Config - if err := yaml.UnmarshalWithOptions([]byte(yamlContent), &tmpConfig, yaml.Strict(), yaml.ReferenceDirs(agentDir)); err != nil { + if err := yaml.UnmarshalWithOptions([]byte(yamlContent), &tmpConfig, yaml.Strict()); err != nil { slog.Error("Invalid YAML content", "error", err) return echo.NewHTTPError(http.StatusBadRequest, "invalid YAML content: "+err.Error()) } @@ -237,15 +295,15 @@ func (s *Server) editAgentConfigYAML(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "failed to write agent file") } - // Update the teams map with the reloaded agent - agentKey := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)) - if oldTeam, exists := s.teams[agentKey]; exists { - // Stop old team's toolsets before replacing + agentKey := filepath.Base(p) + + // Update the teams map with the reloaded agent and + // stop old team's toolsets + if oldTeam, exists := s.setTeam(agentKey, t); exists { if err := oldTeam.StopToolSets(c.Request().Context()); err != nil { slog.Error("Failed to stop old team toolsets", "agentKey", agentKey, "error", err) } } - s.teams[agentKey] = t slog.Info("Agent YAML configuration updated successfully", "path", p) return c.String(http.StatusOK, yamlContent) @@ -323,15 +381,15 @@ func (s *Server) editAgentConfig(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "failed to reload agent configuration") } - // Update the teams map with the reloaded agent - agentKey := strings.TrimSuffix(filepath.Base(p), filepath.Ext(p)) - if oldTeam, exists := s.teams[agentKey]; exists { - // Stop old team's toolsets before replacing + agentKey := filepath.Base(p) + + // Update the teams map with the reloaded agent and + // stop old team's toolsets + if oldTeam, exists := s.setTeam(agentKey, t); exists { if err := oldTeam.StopToolSets(c.Request().Context()); err != nil { slog.Error("Failed to stop old team toolsets", "agentKey", agentKey, "error", err) } } - s.teams[agentKey] = t slog.Info("Agent configuration updated successfully", "path", p) return c.JSON(http.StatusOK, map[string]any{"message": "agent configuration updated successfully", "path": p, "config": mergedConfig}) @@ -361,7 +419,7 @@ func (s *Server) createAgent(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "failed to load agent") } - s.teams[filepath.Base(p)] = t + s.setTeam(filepath.Base(p), t) slog.Info("Agent loaded", "path", p, "out", out) return c.JSON(http.StatusOK, map[string]string{"path": p, "out": out}) @@ -450,8 +508,8 @@ func (s *Server) createAgentConfig(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "failed to load agent from target path: "+err.Error()) } - agentKey := strings.TrimSuffix(filepath.Base(targetPath), filepath.Ext(targetPath)) - s.teams[agentKey] = t + agentFilename := filepath.Base(targetPath) + s.setTeam(agentFilename, t) slog.Info("Manual agent created successfully", "filepath", targetPath, "filename", filename) return c.JSON(http.StatusOK, map[string]string{ @@ -537,7 +595,7 @@ func (s *Server) importAgent(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "failed to load agent from target path: "+err.Error()) } - s.teams[agentKey] = t + s.setTeam(agentKey, t) rootAgent, err := t.Agent("root") if err != nil { @@ -663,7 +721,7 @@ func (s *Server) pullAgent(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "failed to load agent") } - s.teams[agentName] = t + s.setTeam(filepath.Base(fileName), t) rootAgent, err := t.Agent("root") if err != nil { @@ -760,18 +818,22 @@ func (s *Server) deleteAgent(c echo.Context) error { return echo.NewHTTPError(http.StatusBadRequest, "file must be a YAML file (.yaml or .yml)") } - // Determine the agent key from the file path - agentKey := strings.TrimSuffix(filepath.Base(req.FilePath), filepath.Ext(req.FilePath)) + // Determine the agent filename from the file path + agentFilename := filepath.Base(req.FilePath) // Remove from teams map and stop toolsets if active - if t, exists := s.teams[agentKey]; exists { - slog.Info("Stopping toolsets for agent", "agentKey", agentKey) + t, exists := s.getTeam(agentFilename) + + if exists { + slog.Info("Stopping toolsets for agent", "agentKey", agentFilename) if err := t.StopToolSets(c.Request().Context()); err != nil { - slog.Error("Failed to stop tool sets for agent", "agentKey", agentKey, "error", err) + slog.Error("Failed to stop tool sets for agent", "agentKey", agentFilename, "error", err) // Continue with deletion even if stopping toolsets fails } - delete(s.teams, agentKey) - slog.Info("Removed agent from teams", "agentKey", agentKey) + + s.deleteTeam(agentFilename) + + slog.Info("Removed agent from teams", "agentKey", agentFilename) } // Delete the file @@ -780,7 +842,7 @@ func (s *Server) deleteAgent(c echo.Context) error { return echo.NewHTTPError(http.StatusInternalServerError, "failed to delete agent file: "+err.Error()) } - slog.Info("Agent deleted successfully", "filePath", req.FilePath, "agentKey", agentKey) + slog.Info("Agent deleted successfully", "filePath", req.FilePath, "agentKey", agentFilename) return c.JSON(http.StatusOK, map[string]string{ "filePath": req.FilePath, }) @@ -796,6 +858,8 @@ func (s *Server) getAgents(c echo.Context) error { // we want to return an empty slice if there are no agents, not nil. agents := []api.Agent{} + // Manually lock the mutex to safely iterate over teams + s.teamsMu.RLock() for id, t := range s.teams { a, err := t.Agent("root") if err != nil { @@ -808,7 +872,8 @@ func (s *Server) getAgents(c echo.Context) error { case t.Size() == 1: a, err = t.Agent(t.AgentNames()[0]) if err != nil { - return echo.NewHTTPError(http.StatusInternalServerError, "failed to get agent") + s.teamsMu.RUnlock() + return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("failed to get agent for team %s: %v", id, err)) } agents = append(agents, api.Agent{ Name: id, @@ -827,6 +892,7 @@ func (s *Server) getAgents(c echo.Context) error { }) } } + s.teamsMu.RUnlock() // Sort agents by name sort.Slice(agents, func(i, j int) bool { @@ -841,21 +907,47 @@ func (s *Server) refreshAgentsFromDisk(ctx context.Context) error { return nil } + slog.Debug("Refreshing agents from disk", "agentsDir", s.agentsDir) + newTeams, err := teamloader.LoadTeams(ctx, s.agentsDir, s.runConfig) if err != nil { return fmt.Errorf("failed to load teams: %w", err) } - for id, oldTeam := range s.teams { + oldTeams := s.getTeams() + + for id, oldTeam := range oldTeams { if _, exists := newTeams[id]; !exists { // Team no longer exists on disk, stop its tool sets + slog.Info("Stopping tool sets for removed team", "team", id) if err := oldTeam.StopToolSets(ctx); err != nil { slog.Error("Failed to stop tool sets for removed team", "team", id, "error", err) } } } - s.teams = newTeams + s.replaceAllTeams(newTeams) + + return nil +} + +// ReloadTeams loads teams from the specified path and replaces the current teams. +// This method is thread-safe and ensures ongoing requests are not interrupted. +func (s *Server) ReloadTeams(ctx context.Context, agentPath string) error { + newTeams, err := teamloader.LoadTeams(ctx, agentPath, s.runConfig) + if err != nil { + return fmt.Errorf("failed to load teams: %w", err) + } + + oldTeams := s.replaceAllTeams(newTeams) + + // Stop old teams' toolsets after releasing the lock to avoid blocking requests + for id, oldTeam := range oldTeams { + if err := oldTeam.StopToolSets(ctx); err != nil { + slog.Error("Failed to stop tool sets for old team", "team", id, "error", err) + } + } + return nil } @@ -915,7 +1007,10 @@ func (s *Server) createSession(c echo.Context) error { } var opts []session.Opt - opts = append(opts, session.WithMaxIterations(sessionTemplate.MaxIterations)) + opts = append(opts, + session.WithMaxIterations(sessionTemplate.MaxIterations), + session.WithToolsApproved(sessionTemplate.ToolsApproved), + ) if wd := strings.TrimSpace(sessionTemplate.WorkingDir); wd != "" { absWd, err := filepath.Abs(wd) @@ -936,7 +1031,6 @@ func (s *Server) createSession(c echo.Context) error { } sess := session.New(opts...) - sess.ToolsApproved = sessionTemplate.ToolsApproved if err := s.sessionStore.AddSession(c.Request().Context(), sess); err != nil { slog.Error("Failed to persist session", "session_id", sess.ID, "error", err) @@ -998,7 +1092,7 @@ func (s *Server) resumeSession(c echo.Context) error { return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("runtime not found: %s", sessionID)) } - rt.Resume(c.Request().Context(), req.Confirmation) + rt.Resume(c.Request().Context(), runtime.ResumeType(req.Confirmation)) return c.JSON(http.StatusOK, map[string]string{"message": "session resumed"}) } diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index 1f4d830d3..7ff1495a4 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -19,6 +19,7 @@ import ( "github.com/docker/cagent/pkg/config" latest "github.com/docker/cagent/pkg/config/v2" "github.com/docker/cagent/pkg/session" + "github.com/docker/cagent/pkg/teamloader" ) func TestServer_ListAgents(t *testing.T) { @@ -217,6 +218,380 @@ func TestServer_ListSessions(t *testing.T) { assert.Empty(t, sessions) } +func TestServer_ReloadTeams(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + t.Setenv("ANTHROPIC_API_KEY", "dummy") + + ctx := t.Context() + + agentsDir1 := prepareAgentsDir(t, "pirate.yaml") + + var store mockStore + var runConfig config.RuntimeConfig + + // Load initial teams + teams, err := teamloader.LoadTeams(ctx, agentsDir1, runConfig) + require.NoError(t, err) + + srv, err := New(store, runConfig, teams, WithAgentsDir(agentsDir1)) + require.NoError(t, err) + + initialTeamsCount := srv.countTeams() + hasPirate := srv.hasTeam("pirate.yaml") + + assert.Equal(t, 1, initialTeamsCount) + assert.True(t, hasPirate, "should have pirate agent initially") + + agentsDir2 := prepareAgentsDir(t, "contradict.yaml", "multi_agents.yaml") + + // Reload teams from the new directory + err = srv.ReloadTeams(ctx, agentsDir2) + require.NoError(t, err) + + newTeamsCount := srv.countTeams() + hasPirateAfter := srv.hasTeam("pirate.yaml") + hasContradict := srv.hasTeam("contradict.yaml") + hasMulti := srv.hasTeam("multi_agents.yaml") + + assert.Equal(t, 2, newTeamsCount, "should have 2 agents after reload") + assert.False(t, hasPirateAfter, "pirate agent should be removed") + assert.True(t, hasContradict, "should have contradict agent") + assert.True(t, hasMulti, "should have multi_agents agent") +} + +func TestServer_ReloadTeams_Concurrent(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + t.Setenv("ANTHROPIC_API_KEY", "dummy") + + ctx := t.Context() + + agentsDir := prepareAgentsDir(t, "pirate.yaml") + + var store mockStore + var runConfig config.RuntimeConfig + + // Load initial teams + teams, err := teamloader.LoadTeams(ctx, agentsDir, runConfig) + require.NoError(t, err) + + srv, err := New(store, runConfig, teams, WithAgentsDir(agentsDir)) + require.NoError(t, err) + + agentsDir2 := prepareAgentsDir(t, "contradict.yaml") + + done := make(chan bool) + go func() { + for range 100 { + _ = srv.countTeams() + } + done <- true + }() + + err = srv.ReloadTeams(ctx, agentsDir2) + require.NoError(t, err) + + err = srv.ReloadTeams(ctx, agentsDir) + require.NoError(t, err) + + <-done + + hasPirate := srv.hasTeam("pirate.yaml") + + assert.True(t, hasPirate, "should have pirate agent after final reload") +} + +func TestServer_ReloadTeams_InvalidPath(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + ctx := t.Context() + + agentsDir := prepareAgentsDir(t, "pirate.yaml") + + var store mockStore + var runConfig config.RuntimeConfig + + // Load initial teams + teams, err := teamloader.LoadTeams(ctx, agentsDir, runConfig) + require.NoError(t, err) + + srv, err := New(store, runConfig, teams, WithAgentsDir(agentsDir)) + require.NoError(t, err) + + // Try to reload from non-existent path + err = srv.ReloadTeams(ctx, "/nonexistent/path") + require.Error(t, err) + + teamsCount := srv.countTeams() + hasPirate := srv.hasTeam("pirate.yaml") + + assert.Equal(t, 1, teamsCount, "should still have original team") + assert.True(t, hasPirate, "should still have pirate agent") +} + +func TestServer_RefreshAgentsFromDisk_AddNewAgent(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + t.Setenv("ANTHROPIC_API_KEY", "dummy") + + ctx := t.Context() + + // Start with only pirate agent + agentsDir := prepareAgentsDir(t, "pirate.yaml") + + var store mockStore + var runConfig config.RuntimeConfig + + teams, err := teamloader.LoadTeams(ctx, agentsDir, runConfig) + require.NoError(t, err) + + srv, err := New(store, runConfig, teams, WithAgentsDir(agentsDir)) + require.NoError(t, err) + + initialCount := srv.countTeams() + hasPirate := srv.hasTeam("pirate.yaml") + hasContradict := srv.hasTeam("contradict.yaml") + + assert.Equal(t, 1, initialCount) + assert.True(t, hasPirate) + assert.False(t, hasContradict) + + buf, err := os.ReadFile(filepath.Join("testdata", "contradict.yaml")) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(agentsDir, "contradict.yaml"), buf, 0o600) + require.NoError(t, err) + + // Refresh agents from disk + err = srv.refreshAgentsFromDisk(ctx) + require.NoError(t, err) + + newCount := srv.countTeams() + hasPirateAfter := srv.hasTeam("pirate.yaml") + hasContradictAfter := srv.hasTeam("contradict.yaml") + + assert.Equal(t, 2, newCount, "should have 2 agents after refresh") + assert.True(t, hasPirateAfter, "pirate agent should still exist") + assert.True(t, hasContradictAfter, "contradict agent should be added") +} + +func TestServer_RefreshAgentsFromDisk_RemoveAgent(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + t.Setenv("ANTHROPIC_API_KEY", "dummy") + + ctx := t.Context() + + // Start with two agents + agentsDir := prepareAgentsDir(t, "pirate.yaml", "contradict.yaml") + + var store mockStore + var runConfig config.RuntimeConfig + + teams, err := teamloader.LoadTeams(ctx, agentsDir, runConfig) + require.NoError(t, err) + + srv, err := New(store, runConfig, teams, WithAgentsDir(agentsDir)) + require.NoError(t, err) + + initialCount := srv.countTeams() + hasPirate := srv.hasTeam("pirate.yaml") + hasContradict := srv.hasTeam("contradict.yaml") + + assert.Equal(t, 2, initialCount) + assert.True(t, hasPirate) + assert.True(t, hasContradict) + + // Remove contradict agent from disk + err = os.Remove(filepath.Join(agentsDir, "contradict.yaml")) + require.NoError(t, err) + + // Refresh agents from disk + err = srv.refreshAgentsFromDisk(ctx) + require.NoError(t, err) + + newCount := srv.countTeams() + hasPirateAfter := srv.hasTeam("pirate.yaml") + hasContradictAfter := srv.hasTeam("contradict.yaml") + + assert.Equal(t, 1, newCount, "should have 1 agent after refresh") + assert.True(t, hasPirateAfter, "pirate agent should still exist") + assert.False(t, hasContradictAfter, "contradict agent should be removed") +} + +func TestServer_RefreshAgentsFromDisk_UpdateAgent(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + ctx := t.Context() + + // Start with pirate agent + agentsDir := prepareAgentsDir(t, "pirate.yaml") + + var store mockStore + var runConfig config.RuntimeConfig + + teams, err := teamloader.LoadTeams(ctx, agentsDir, runConfig) + require.NoError(t, err) + + srv, err := New(store, runConfig, teams, WithAgentsDir(agentsDir)) + require.NoError(t, err) + + initialTeam, exists := srv.getTeam("pirate.yaml") + require.True(t, exists) + require.NotNil(t, initialTeam) + + initialAgent, err := initialTeam.Agent("root") + require.NoError(t, err) + initialInstruction := initialAgent.Instruction() + + // Modify the agent file on disk + modifiedConfig := `version: "2" +agents: + root: + model: openai/gpt-4o + description: "Updated pirate" + instruction: "You are an UPDATED pirate. Talk like a pirate in all your responses." +` + err = os.WriteFile(filepath.Join(agentsDir, "pirate.yaml"), []byte(modifiedConfig), 0o600) + require.NoError(t, err) + + // Refresh agents from disk + err = srv.refreshAgentsFromDisk(ctx) + require.NoError(t, err) + + updatedTeam, exists := srv.getTeam("pirate.yaml") + require.True(t, exists) + require.NotNil(t, updatedTeam) + + updatedAgent, err := updatedTeam.Agent("root") + require.NoError(t, err) + updatedInstruction := updatedAgent.Instruction() + + assert.NotEqual(t, initialInstruction, updatedInstruction, "instruction should be updated") + assert.Contains(t, updatedInstruction, "UPDATED", "should have updated instruction") +} + +func TestServer_RefreshAgentsFromDisk_MultipleChanges(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + t.Setenv("ANTHROPIC_API_KEY", "dummy") + + ctx := t.Context() + + // Start with pirate and contradict agents + agentsDir := prepareAgentsDir(t, "pirate.yaml", "contradict.yaml") + + var store mockStore + var runConfig config.RuntimeConfig + + teams, err := teamloader.LoadTeams(ctx, agentsDir, runConfig) + require.NoError(t, err) + + srv, err := New(store, runConfig, teams, WithAgentsDir(agentsDir)) + require.NoError(t, err) + + initialCount := srv.countTeams() + assert.Equal(t, 2, initialCount) + + // Remove contradict, add multi_agents + err = os.Remove(filepath.Join(agentsDir, "contradict.yaml")) + require.NoError(t, err) + + buf, err := os.ReadFile(filepath.Join("testdata", "multi_agents.yaml")) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(agentsDir, "multi_agents.yaml"), buf, 0o600) + require.NoError(t, err) + + // Refresh agents from disk + err = srv.refreshAgentsFromDisk(ctx) + require.NoError(t, err) + + newCount := srv.countTeams() + hasPirate := srv.hasTeam("pirate.yaml") + hasContradict := srv.hasTeam("contradict.yaml") + hasMulti := srv.hasTeam("multi_agents.yaml") + + assert.Equal(t, 2, newCount, "should have 2 agents") + assert.True(t, hasPirate, "pirate agent should still exist") + assert.False(t, hasContradict, "contradict agent should be removed") + assert.True(t, hasMulti, "multi_agents should be added") +} + +func TestServer_RefreshAgentsFromDisk_NoChanges(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + ctx := t.Context() + + agentsDir := prepareAgentsDir(t, "pirate.yaml") + + var store mockStore + var runConfig config.RuntimeConfig + + teams, err := teamloader.LoadTeams(ctx, agentsDir, runConfig) + require.NoError(t, err) + + srv, err := New(store, runConfig, teams, WithAgentsDir(agentsDir)) + require.NoError(t, err) + + initialCount := srv.countTeams() + + // Refresh without any changes + err = srv.refreshAgentsFromDisk(ctx) + require.NoError(t, err) + + newCount := srv.countTeams() + exists := srv.hasTeam("pirate.yaml") + + assert.Equal(t, initialCount, newCount, "count should be unchanged") + assert.True(t, exists, "team should still exist") + // Note: team will be a different instance due to reload, but that's expected +} + +func TestServer_RefreshAgentsFromDisk_EmptyDir(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + ctx := t.Context() + + // Start with one agent + agentsDir := prepareAgentsDir(t, "pirate.yaml") + + var store mockStore + var runConfig config.RuntimeConfig + + teams, err := teamloader.LoadTeams(ctx, agentsDir, runConfig) + require.NoError(t, err) + + srv, err := New(store, runConfig, teams, WithAgentsDir(agentsDir)) + require.NoError(t, err) + + // Remove all agents + err = os.Remove(filepath.Join(agentsDir, "pirate.yaml")) + require.NoError(t, err) + + // Refresh with empty directory + err = srv.refreshAgentsFromDisk(ctx) + require.NoError(t, err) + + count := srv.countTeams() + + assert.Equal(t, 0, count, "should have no agents") +} + +func TestServer_RefreshAgentsFromDisk_NoAgentsDir(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + + ctx := t.Context() + + var store mockStore + var runConfig config.RuntimeConfig + + srv, err := New(store, runConfig, nil) + require.NoError(t, err) + + // Refresh should be no-op + err = srv.refreshAgentsFromDisk(ctx) + require.NoError(t, err) + + count := srv.countTeams() + + assert.Equal(t, 0, count) +} + func prepareAgentsDir(t *testing.T, testFiles ...string) string { t.Helper() diff --git a/pkg/session/session.go b/pkg/session/session.go index a16a8e5f2..0bdd87005 100644 --- a/pkg/session/session.go +++ b/pkg/session/session.go @@ -68,6 +68,14 @@ type Session struct { InputTokens int `json:"input_tokens"` OutputTokens int `json:"output_tokens"` Cost float64 `json:"cost"` + // Self* fields track the most recent provider-reported usage for this session only (no children). + SelfInputTokens int `json:"self_input_tokens"` + SelfOutputTokens int `json:"self_output_tokens"` + SelfCost float64 `json:"self_cost"` + // Child* fields accumulate merged usage from completed sub-sessions for quick lookup. + ChildInputTokens int `json:"child_input_tokens"` + ChildOutputTokens int `json:"child_output_tokens"` + ChildCost float64 `json:"child_cost"` } // Message is a message from an agent @@ -235,6 +243,18 @@ func WithTitle(title string) Opt { } } +func WithToolsApproved(toolsApproved bool) Opt { + return func(s *Session) { + s.ToolsApproved = toolsApproved + } +} + +func WithSendUserMessage(sendUserMessage bool) Opt { + return func(s *Session) { + s.SendUserMessage = sendUserMessage + } +} + // New creates a new agent session func New(opts ...Opt) *Session { sessionID := uuid.New().String() diff --git a/pkg/session/session_history_test.go b/pkg/session/session_history_test.go index 2ee755816..b6f76bb02 100644 --- a/pkg/session/session_history_test.go +++ b/pkg/session/session_history_test.go @@ -46,11 +46,9 @@ func TestSessionNumHistoryItems(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // Create agent with specified numHistoryItems testAgent := agent.New("test-agent", "test instruction", agent.WithNumHistoryItems(tt.numHistoryItems)) - // Create session with many messages s := New() for i := range tt.messageCount { s.AddMessage(UserMessage("", fmt.Sprintf("Message %d", i))) @@ -63,7 +61,6 @@ func TestSessionNumHistoryItems(t *testing.T) { }) } - // Get messages for the agent messages := s.GetMessages(testAgent) // Count conversation messages (non-system) @@ -88,7 +85,6 @@ func TestSessionNumHistoryItems(t *testing.T) { } func TestTrimMessagesPreservesSystemMessages(t *testing.T) { - // Create messages with multiple system messages messages := []chat.Message{ {Role: chat.MessageRoleSystem, Content: "System instruction 1"}, {Role: chat.MessageRoleSystem, Content: "System instruction 2"}, @@ -101,7 +97,6 @@ func TestTrimMessagesPreservesSystemMessages(t *testing.T) { {Role: chat.MessageRoleAssistant, Content: "Assistant response 3"}, } - // Test with very small limit (1 conversation message) trimmed := trimMessages(messages, 1) // Count message types @@ -125,7 +120,6 @@ func TestTrimMessagesPreservesSystemMessages(t *testing.T) { } func TestTrimMessagesConversationLimit(t *testing.T) { - // Create a mix of system and conversation messages messages := []chat.Message{ {Role: chat.MessageRoleSystem, Content: "System prompt"}, {Role: chat.MessageRoleUser, Content: "Message 1"}, @@ -172,7 +166,6 @@ func TestTrimMessagesConversationLimit(t *testing.T) { } func TestTrimMessagesWithToolCallsPreservation(t *testing.T) { - // Test that tool calls are properly handled when trimming, preserving system messages messages := []chat.Message{ {Role: chat.MessageRoleSystem, Content: "System prompt"}, {Role: chat.MessageRoleUser, Content: "Old message"}, @@ -206,7 +199,6 @@ func TestTrimMessagesWithToolCallsPreservation(t *testing.T) { // Limit to 3 conversation messages (should keep the recent tool interaction) trimmed := trimMessages(messages, 3) - // Check that we don't have orphaned tool results toolCallIDs := make(map[string]bool) for _, msg := range trimmed { if msg.Role == chat.MessageRoleAssistant { @@ -216,7 +208,6 @@ func TestTrimMessagesWithToolCallsPreservation(t *testing.T) { } } - // Verify tool message consistency for _, msg := range trimmed { if msg.Role == chat.MessageRoleTool { assert.True(t, toolCallIDs[msg.ToolCallID], @@ -235,7 +226,6 @@ func TestTrimMessagesWithToolCallsPreservation(t *testing.T) { } func TestNumHistoryItemsConfiguration(t *testing.T) { - // Test that the configuration properly flows through the system testCases := []struct { configValue int expected int @@ -249,11 +239,9 @@ func TestNumHistoryItemsConfiguration(t *testing.T) { for _, tc := range testCases { t.Run(tc.description, func(t *testing.T) { - // Create agent with specific configuration a := agent.New("test", "instruction", agent.WithNumHistoryItems(tc.configValue)) - // Create a session and get messages s := New() s.AddMessage(UserMessage("", "test")) diff --git a/pkg/session/session_test.go b/pkg/session/session_test.go index 1d84c2b2a..3d5eeda92 100644 --- a/pkg/session/session_test.go +++ b/pkg/session/session_test.go @@ -11,7 +11,6 @@ import ( ) func TestTrimMessages(t *testing.T) { - // Create a slice of messages that exceeds maxMessages messages := make([]chat.Message, maxMessages+10) // Fill with some basic messages @@ -22,7 +21,6 @@ func TestTrimMessages(t *testing.T) { } } - // Test basic trimming result := trimMessages(messages, maxMessages) assert.Len(t, result, maxMessages, "should trim to maxMessages") } @@ -75,7 +73,6 @@ func TestTrimMessagesWithToolCalls(t *testing.T) { // Should keep last 3 messages, but ensure tool call consistency assert.Len(t, result, maxItems) - // Verify we don't have any orphaned tool results toolCalls := make(map[string]bool) for _, msg := range result { if msg.Role == chat.MessageRoleAssistant { @@ -90,13 +87,10 @@ func TestTrimMessagesWithToolCalls(t *testing.T) { } func TestGetMessages(t *testing.T) { - // Create a test agent testAgent := &agent.Agent{} - // Create a session with many messages s := New() - // Add more than maxMessages to the session for range maxMessages + 10 { s.AddMessage(NewAgentMessage(testAgent, &chat.Message{ Role: chat.MessageRoleUser, @@ -104,7 +98,6 @@ func TestGetMessages(t *testing.T) { })) } - // Get messages for the agent messages := s.GetMessages(testAgent) // Count non-system messages (since system messages are not limited) @@ -115,18 +108,14 @@ func TestGetMessages(t *testing.T) { } } - // Verify we get at most maxMessages for non-system messages assert.LessOrEqual(t, nonSystemCount, maxMessages, "non-system messages should not exceed maxMessages") } func TestGetMessagesWithToolCalls(t *testing.T) { - // Create a test agent testAgent := &agent.Agent{} - // Create a session s := New() - // Add a sequence of messages with tool calls s.AddMessage(NewAgentMessage(testAgent, &chat.Message{ Role: chat.MessageRoleUser, Content: "test message", @@ -148,14 +137,12 @@ func TestGetMessagesWithToolCalls(t *testing.T) { Content: "tool result", })) - // Set maxMessages to 2 to force trimming oldMax := maxMessages maxMessages = 2 defer func() { maxMessages = oldMax }() messages := s.GetMessages(testAgent) - // Verify tool call consistency toolCalls := make(map[string]bool) for _, msg := range messages { if msg.Role == chat.MessageRoleAssistant { @@ -170,13 +157,10 @@ func TestGetMessagesWithToolCalls(t *testing.T) { } func TestGetMessagesWithSummary(t *testing.T) { - // Create a test agent testAgent := &agent.Agent{} - // Create a session s := New() - // Add some initial messages s.AddMessage(NewAgentMessage(testAgent, &chat.Message{ Role: chat.MessageRoleUser, Content: "first message", @@ -186,10 +170,8 @@ func TestGetMessagesWithSummary(t *testing.T) { Content: "first response", })) - // Add a summary s.Messages = append(s.Messages, Item{Summary: "This is a summary of the conversation so far"}) - // Add messages after the summary s.AddMessage(NewAgentMessage(testAgent, &chat.Message{ Role: chat.MessageRoleUser, Content: "message after summary", @@ -199,7 +181,6 @@ func TestGetMessagesWithSummary(t *testing.T) { Content: "response after summary", })) - // Get messages messages := s.GetMessages(testAgent) // Count non-system messages (user and assistant only) diff --git a/pkg/session/store_test.go b/pkg/session/store_test.go index 6b9d19826..40478d526 100644 --- a/pkg/session/store_test.go +++ b/pkg/session/store_test.go @@ -15,16 +15,13 @@ import ( func TestStoreAgentName(t *testing.T) { tempDB := filepath.Join(t.TempDir(), "test_store.db") - // Create the store store, err := NewSQLiteSessionStore(tempDB) require.NoError(t, err) defer store.(*SQLiteSessionStore).Close() - // Create test agents testAgent1 := agent.New("test-agent-1", "test prompt 1") testAgent2 := agent.New("test-agent-2", "test prompt 2") - // Create a session with messages from different agents session := &Session{ ID: "test-session", Messages: []Item{ @@ -52,7 +49,6 @@ func TestStoreAgentName(t *testing.T) { require.NoError(t, err) require.NotNil(t, retrievedSession) - // Verify the agent names are correctly stored and retrieved assert.Len(t, retrievedSession.GetAllMessages(), 3) // First message should be user message with empty agent name @@ -72,19 +68,15 @@ func TestStoreAgentName(t *testing.T) { } func TestStoreMultipleAgents(t *testing.T) { - // Create a temporary database file tempDB := filepath.Join(t.TempDir(), "test_store_multi.db") - // Create the store store, err := NewSQLiteSessionStore(tempDB) require.NoError(t, err) defer store.(*SQLiteSessionStore).Close() - // Create multiple test agents agent1 := agent.New("agent-1", "agent 1 prompt") agent2 := agent.New("agent-2", "agent 2 prompt") - // Create a session with messages from different agents session := &Session{ ID: "multi-agent-session", CreatedAt: time.Now(), @@ -110,7 +102,6 @@ func TestStoreMultipleAgents(t *testing.T) { require.NoError(t, err) require.NotNil(t, retrievedSession) - // Verify the agent names are correctly stored and retrieved assert.Len(t, retrievedSession.Messages, 3) // First message should be user message with empty agent name @@ -129,18 +120,14 @@ func TestStoreMultipleAgents(t *testing.T) { } func TestGetSessions(t *testing.T) { - // Create a temporary database file tempDB := filepath.Join(t.TempDir(), "test_get_sessions.db") - // Create the store store, err := NewSQLiteSessionStore(tempDB) require.NoError(t, err) defer store.(*SQLiteSessionStore).Close() - // Create a test agent testAgent := agent.New("test-agent", "test prompt") - // Create multiple sessions session1 := &Session{ ID: "session-1", Messages: []Item{ @@ -174,7 +161,6 @@ func TestGetSessions(t *testing.T) { require.NoError(t, err) assert.Len(t, sessions, 2) - // Verify agent names are preserved in all sessions for _, session := range sessions { assert.Len(t, session.Messages, 1) assert.Equal(t, "test-agent", session.Messages[0].Message.AgentName) @@ -182,19 +168,15 @@ func TestGetSessions(t *testing.T) { } func TestStoreAgentNameJSON(t *testing.T) { - // Create a temporary database file tempDB := filepath.Join(t.TempDir(), "test_store_json.db") - // Create the store store, err := NewSQLiteSessionStore(tempDB) require.NoError(t, err) defer store.(*SQLiteSessionStore).Close() - // Create test agents agent1 := agent.New("my-agent", "test prompt") agent2 := agent.New("another-agent", "another prompt") - // Create a session with messages from different agents session := &Session{ ID: "json-test-session", Messages: []Item{ @@ -220,12 +202,10 @@ func TestStoreAgentNameJSON(t *testing.T) { require.NoError(t, err) require.NotNil(t, retrievedSession) - // Verify specific agent filenames are correctly stored and retrieved assert.Equal(t, "demo-agent", retrievedSession.Messages[0].Message.AgentFilename) // User message assert.Empty(t, retrievedSession.Messages[1].Message.AgentFilename) // First agent assert.Empty(t, retrievedSession.Messages[2].Message.AgentFilename) // Second agent - // Verify specific agent names are correctly stored and retrieved assert.Empty(t, retrievedSession.Messages[0].Message.AgentName) // User message assert.Equal(t, "my-agent", retrievedSession.Messages[1].Message.AgentName) // First agent assert.Equal(t, "another-agent", retrievedSession.Messages[2].Message.AgentName) // Second agent diff --git a/pkg/teamloader/teamloader.go b/pkg/teamloader/teamloader.go index 022d95e0c..e39e9740d 100644 --- a/pkg/teamloader/teamloader.go +++ b/pkg/teamloader/teamloader.go @@ -72,6 +72,7 @@ func NewDefaultToolsetRegistry() *ToolsetRegistry { r.Register("filesystem", createFilesystemTool) r.Register("fetch", createFetchTool) r.Register("mcp", createMCPTool) + r.Register("api", createAPITool) return r } @@ -159,6 +160,16 @@ func createFilesystemTool(ctx context.Context, toolset latest.Toolset, parentDir return builtin.NewFilesystemTool([]string{wd}, opts...), nil } +func createAPITool(ctx context.Context, toolset latest.Toolset, parentDir string, envProvider environment.Provider, runtimeConfig config.RuntimeConfig) (tools.ToolSet, error) { + if toolset.APIConfig.Endpoint == "" { + return nil, fmt.Errorf("api tool requires an endpoint in api_config") + } + + toolset.APIConfig.Headers = js.Expand(ctx, toolset.APIConfig.Headers, envProvider) + + return builtin.NewAPITool(toolset.APIConfig), nil +} + func createFetchTool(ctx context.Context, toolset latest.Toolset, parentDir string, envProvider environment.Provider, runtimeConfig config.RuntimeConfig) (tools.ToolSet, error) { var opts []builtin.FetchToolOption if toolset.Timeout > 0 { @@ -310,10 +321,11 @@ func Load(ctx context.Context, p string, runtimeConfig config.RuntimeConfig, opt return nil, fmt.Errorf("failed to read env files: %w", err) } - env := environment.NewMultiProvider( - envFilesProviders, - environment.NewDefaultProvider(), - ) + defaultEnvProvider := runtimeConfig.DefaultEnvProvider + if defaultEnvProvider == nil { + defaultEnvProvider = environment.NewDefaultProvider() + } + env := environment.NewMultiProvider(envFilesProviders, defaultEnvProvider) // Load the agent's configuration cfg, err := config.LoadConfigSecureDeprecated(fileName, parentDir) @@ -339,6 +351,7 @@ func Load(ctx context.Context, p string, runtimeConfig config.RuntimeConfig, opt opts := []agent.Opt{ agent.WithName(name), agent.WithDescription(agentConfig.Description), + agent.WithWelcomeMessage(agentConfig.WelcomeMessage), agent.WithAddDate(agentConfig.AddDate), agent.WithAddEnvironmentInfo(agentConfig.AddEnvironmentInfo), agent.WithAddPromptFiles(agentConfig.AddPromptFiles), diff --git a/pkg/telemetry/telemetry_test.go b/pkg/telemetry/telemetry_test.go index 16ad45ffb..50400c521 100644 --- a/pkg/telemetry/telemetry_test.go +++ b/pkg/telemetry/telemetry_test.go @@ -91,7 +91,6 @@ func (m *MockHTTPClient) GetRequestCount() int { func TestNewClient(t *testing.T) { logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) - // Test enabled client with mock HTTP client to capture HTTP calls // Note: debug mode does NOT disable HTTP calls - it only adds extra logging client := newClient(logger, false, false, "test-version") @@ -111,14 +110,12 @@ func TestSessionTracking(t *testing.T) { mockHTTP := NewMockHTTPClient() client := newClient(logger, true, true, "test-version", mockHTTP.Client) - // Set endpoint, apiKey, and header to verify HTTP calls are made correctly client.endpoint = "https://test-session-tracking.com/api" client.apiKey = "test-session-key" client.header = "test-header" ctx := t.Context() - // Test session lifecycle sessionID := client.RecordSessionStart(ctx, "test-agent", "test-session-id") assert.NotEmpty(t, sessionID) @@ -135,13 +132,11 @@ func TestSessionTracking(t *testing.T) { // Wait for events to be processed time.Sleep(100 * time.Millisecond) - // Verify HTTP requests were made (should have session start, tool call, token usage, session end) requestCount := mockHTTP.GetRequestCount() assert.Positive(t, requestCount, "Expected HTTP requests to be made for session tracking events") t.Logf("Session tracking HTTP requests captured: %d", requestCount) - // Verify request structure requests := mockHTTP.GetRequests() for i, req := range requests { assert.Equal(t, http.MethodPost, req.Method, "Request %d: Expected POST method", i) @@ -154,7 +149,6 @@ func TestCommandTracking(t *testing.T) { mockHTTP := NewMockHTTPClient() client := newClient(logger, true, true, "test-version", mockHTTP.Client) - // Set endpoint, apiKey, and header to verify HTTP calls are made correctly client.endpoint = "https://test-command-tracking.com/api" client.apiKey = "test-command-key" client.header = "test-header" @@ -176,13 +170,11 @@ func TestCommandTracking(t *testing.T) { // Wait for events to be processed time.Sleep(100 * time.Millisecond) - // Verify HTTP requests were made for command tracking requestCount := mockHTTP.GetRequestCount() assert.Positive(t, requestCount, "Expected HTTP requests to be made for command tracking") t.Logf("Command tracking HTTP requests captured: %d", requestCount) - // Verify request structure requests := mockHTTP.GetRequests() for i, req := range requests { assert.Equal(t, "test-command-key", req.Header.Get("test-header"), "Request %d: Expected test-header test-command-key", i) @@ -194,7 +186,6 @@ func TestCommandTrackingWithError(t *testing.T) { mockHTTP := NewMockHTTPClient() client := newClient(logger, true, true, "test-version", mockHTTP.Client) - // Set endpoint, apiKey, and header to verify HTTP calls are made correctly client.endpoint = "https://test-command-error.com/api" client.apiKey = "test-command-error-key" client.header = "test-header" @@ -214,7 +205,6 @@ func TestCommandTrackingWithError(t *testing.T) { // Wait for events to be processed time.Sleep(100 * time.Millisecond) - // Verify HTTP requests were made for command tracking with error requestCount := mockHTTP.GetRequestCount() assert.Positive(t, requestCount, "Expected HTTP requests to be made for command error tracking") @@ -236,19 +226,15 @@ func TestStructuredEvent(t *testing.T) { } func TestGetTelemetryEnabled(t *testing.T) { - // Test default (enabled) t.Setenv("TELEMETRY_ENABLED", "") assert.True(t, GetTelemetryEnabled()) - // Test explicitly disabled t.Setenv("TELEMETRY_ENABLED", "false") assert.False(t, GetTelemetryEnabled()) - // Test explicitly enabled t.Setenv("TELEMETRY_ENABLED", "true") assert.True(t, GetTelemetryEnabled()) - // Test other values default to enabled (only "false" disables) testCases := []string{"1", "yes", "on", "enabled", "anything", ""} for _, value := range testCases { t.Setenv("TELEMETRY_ENABLED", value) @@ -271,7 +257,6 @@ func (tc *Client) TrackCommand(ctx context.Context, commandInfo CommandInfo, fn return fn(ctx) } - // Add telemetry client to context so the wrapped function can access it ctx = WithClient(ctx, tc) // Send telemetry event immediately (optimistic approach) @@ -295,7 +280,6 @@ func (tc *Client) TrackServerStart(ctx context.Context, commandInfo CommandInfo, return fn(ctx) } - // Add telemetry client to context so the wrapped function can access it ctx = WithClient(ctx, tc) // Send startup event immediately @@ -319,7 +303,6 @@ func TestAllEventTypes(t *testing.T) { mockHTTP := NewMockHTTPClient() client := newClient(logger, true, true, "test-version", mockHTTP.Client) - // Set endpoint, apiKey, and header to verify HTTP calls are made correctly client.endpoint = "https://test-telemetry-all-events.com/api" client.apiKey = "test-all-events-key" client.header = "test-header" @@ -332,7 +315,6 @@ func TestAllEventTypes(t *testing.T) { client.RecordSessionStart(ctx, agentName, sessionID) t.Run("CommandEvents", func(t *testing.T) { - // Test all major command events based on cmd/ files commands := []struct { action string args []string @@ -355,7 +337,6 @@ func TestAllEventTypes(t *testing.T) { for _, cmd := range commands { t.Run(cmd.action, func(t *testing.T) { - // Test successful command event := &CommandEvent{ Action: cmd.action, Args: cmd.args, @@ -363,7 +344,6 @@ func TestAllEventTypes(t *testing.T) { } client.Track(ctx, event) - // Test command with error errorEvent := &CommandEvent{ Action: cmd.action, Args: cmd.args, @@ -376,7 +356,6 @@ func TestAllEventTypes(t *testing.T) { }) t.Run("SessionEvents", func(t *testing.T) { - // Test session start event startEvent := &SessionStartEvent{ Action: "start", SessionID: sessionID, @@ -384,7 +363,6 @@ func TestAllEventTypes(t *testing.T) { } client.Track(ctx, startEvent) - // Test session end event endEvent := &SessionEndEvent{ Action: "end", SessionID: sessionID, @@ -399,7 +377,6 @@ func TestAllEventTypes(t *testing.T) { } client.Track(ctx, endEvent) - // Test session with errors errorSessionEvent := &SessionEndEvent{ Action: "end", SessionID: sessionID + "-error", @@ -416,7 +393,6 @@ func TestAllEventTypes(t *testing.T) { }) t.Run("ToolEvents", func(t *testing.T) { - // Test various tool events based on the tool system tools := []struct { name string success bool @@ -457,7 +433,6 @@ func TestAllEventTypes(t *testing.T) { }) t.Run("TokenEvents", func(t *testing.T) { - // Test token events for different models models := []struct { name string inputTokens int64 @@ -488,7 +463,6 @@ func TestAllEventTypes(t *testing.T) { }) } - // Test token event with error errorTokenEvent := &TokenEvent{ Action: "usage", ModelName: "failing-model", @@ -510,44 +484,35 @@ func TestAllEventTypes(t *testing.T) { // Give additional time for background processing time.Sleep(100 * time.Millisecond) - // Verify that HTTP requests were made for all events requestCount := mockHTTP.GetRequestCount() assert.Positive(t, requestCount, "Expected HTTP requests to be made for telemetry events") t.Logf("Total HTTP requests captured: %d", requestCount) - // Verify that all requests have correct structure requests := mockHTTP.GetRequests() bodies := mockHTTP.GetBodies() assert.Len(t, requests, len(bodies), "Mismatch between request count and body count") - // Verify each HTTP request has correct headers and endpoint for i, req := range requests { - // Verify method and URL assert.Equal(t, http.MethodPost, req.Method, "Request %d: Expected POST method", i) assert.Equal(t, "https://test-telemetry-all-events.com/api", req.URL.String(), "Request %d: Expected correct URL", i) - // Verify headers assert.Equal(t, "application/json", req.Header.Get("Content-Type"), "Request %d: Expected Content-Type application/json", i) assert.Equal(t, "cagent/test-version", req.Header.Get("User-Agent"), "Request %d: Expected User-Agent cagent/test-version", i) assert.Equal(t, "test-all-events-key", req.Header.Get("test-header"), "Request %d: Expected test-header test-all-events-key", i) - // Verify request body structure var requestBody map[string]any require.NoError(t, json.Unmarshal(bodies[i], &requestBody), "Request %d: Failed to unmarshal request body", i) - // Verify it has records array structure records, ok := requestBody["records"].([]any) require.True(t, ok, "Request %d: Expected 'records' array in request body", i) assert.Len(t, records, 1, "Request %d: Expected 1 record", i) - // Verify the event structure record := records[0].(map[string]any) eventType, ok := record["event"].(string) assert.True(t, ok && eventType != "", "Request %d: Expected non-empty event type", i) - // Verify properties exist _, ok = record["properties"].(map[string]any) assert.True(t, ok, "Request %d: Expected properties object in event", i) } @@ -654,13 +619,10 @@ func TestGlobalTelemetryFunctions(t *testing.T) { SetGlobalTelemetryVersion("test-version") SetGlobalTelemetryDebugMode(true) - // Test global command recording TrackCommand("test-command", []string{"arg1"}) - // Verify global client was initialized assert.NotNil(t, globalToolTelemetryClient) - // Test explicit initialization EnsureGlobalTelemetryInitialized() client := GetGlobalTelemetryClient() assert.NotNil(t, client) @@ -668,21 +630,17 @@ func TestGlobalTelemetryFunctions(t *testing.T) { // TestHTTPRequestVerification tests that HTTP requests are made correctly when telemetry is enabled func TestHTTPRequestVerification(t *testing.T) { - // Create logger with debug level to see all debug messages logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})) mockHTTP := NewMockHTTPClient() - // Create client with mock HTTP client, endpoint, and API key to trigger HTTP calls client := newClient(logger, true, true, "test-version", mockHTTP.Client) - // Set endpoint, API key, and header to ensure HTTP calls are made client.endpoint = "https://test-telemetry.example.com/api/events" client.apiKey = "test-api-key" client.header = "test-header" ctx := t.Context() - // Test command event HTTP request t.Run("CommandEventHTTPRequest", func(t *testing.T) { // Reset mock before test mockHTTP = NewMockHTTPClient() @@ -694,7 +652,6 @@ func TestHTTPRequestVerification(t *testing.T) { Success: true, } - // Verify the client is properly configured assert.NotEmpty(t, client.endpoint, "Client endpoint should be set for this test") assert.NotEmpty(t, client.apiKey, "Client API key should be set for this test") assert.True(t, client.enabled, "Client should be enabled for this test") @@ -709,45 +666,37 @@ func TestHTTPRequestVerification(t *testing.T) { // Debug output t.Logf("HTTP requests captured: %d", mockHTTP.GetRequestCount()) - // Verify HTTP request was made assert.Positive(t, mockHTTP.GetRequestCount(), "Expected HTTP request to be made") requests := mockHTTP.GetRequests() req := requests[0] - // Verify request method and URL assert.Equal(t, http.MethodPost, req.Method, "Expected POST request") assert.Equal(t, "https://test-telemetry.example.com/api/events", req.URL.String(), "Expected correct URL") - // Verify headers assert.Equal(t, "application/json", req.Header.Get("Content-Type"), "Expected Content-Type application/json") assert.Equal(t, "cagent/test-version", req.Header.Get("User-Agent"), "Expected User-Agent cagent/test-version") assert.Equal(t, "test-api-key", req.Header.Get("test-header"), "Expected test-header test-api-key") - // Verify request body structure bodies := mockHTTP.GetBodies() assert.NotEmpty(t, bodies, "Expected request body to be captured") var requestBody map[string]any require.NoError(t, json.Unmarshal(bodies[0], &requestBody), "Failed to unmarshal request body") - // Verify it has records array structure records, ok := requestBody["records"].([]any) require.True(t, ok, "Expected 'records' array in request body") assert.Len(t, records, 1, "Expected 1 record") - // Verify the event structure record := records[0].(map[string]any) assert.Equal(t, "command", record["event"], "Expected event type 'command'") - // Verify properties contain the command data properties, ok := record["properties"].(map[string]any) require.True(t, ok, "Expected properties object in event") assert.Equal(t, "run", properties["action"], "Expected action 'run'") assert.True(t, properties["is_success"].(bool), "Expected is_success true") }) - // Test that no HTTP calls are made when endpoint/apiKey are missing t.Run("NoHTTPWhenMissingCredentials", func(t *testing.T) { mockHTTP2 := NewMockHTTPClient() client2 := newClient(logger, true, true, "test-version", mockHTTP2.Client) @@ -763,11 +712,9 @@ func TestHTTPRequestVerification(t *testing.T) { client2.Track(ctx, event) - // Verify no HTTP requests were made assert.Zero(t, mockHTTP2.GetRequestCount(), "Expected no HTTP requests when endpoint/apiKey are missing") }) - // Test that no HTTP calls are made when client is disabled t.Run("NoHTTPWhenDisabled", func(t *testing.T) { mockHTTP3 := NewMockHTTPClient() client3 := newClient(logger, false, true, "test-version", mockHTTP3.Client) @@ -779,7 +726,6 @@ func TestHTTPRequestVerification(t *testing.T) { client3.Track(ctx, event) - // Verify no HTTP requests were made (client disabled means Track returns early) assert.Zero(t, mockHTTP3.GetRequestCount(), "Expected no HTTP requests when client is disabled") }) } @@ -821,7 +767,6 @@ func TestNon2xxHTTPResponseHandling(t *testing.T) { requestCount := mockHTTP.GetRequestCount() assert.Positive(t, requestCount, "Expected HTTP request to be made despite error response") - // Test additional error codes mockHTTP.SetResponse(&http.Response{ StatusCode: http.StatusNotFound, Status: "404 Not Found", diff --git a/pkg/tools/builtin/api.go b/pkg/tools/builtin/api.go new file mode 100644 index 000000000..1c495f409 --- /dev/null +++ b/pkg/tools/builtin/api.go @@ -0,0 +1,157 @@ +package builtin + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" + + latest "github.com/docker/cagent/pkg/config/v2" + "github.com/docker/cagent/pkg/js" + "github.com/docker/cagent/pkg/tools" +) + +const ( + ToolNameAPI = "api" +) + +type APITool struct { + tools.ElicitationTool + handler *apiHandler + config latest.APIToolConfig +} + +var _ tools.ToolSet = (*APITool)(nil) + +type apiHandler struct { + config latest.APIToolConfig +} + +func (h *apiHandler) CallTool(ctx context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + endpoint := h.config.Endpoint + var reqBody io.Reader = http.NoBody + switch h.config.Method { + case http.MethodGet: + if toolCall.Function.Arguments != "" { + var params map[string]string + + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), ¶ms); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + expanded, err := js.ExpandString(ctx, endpoint, params) + if err != nil { + return nil, fmt.Errorf("failed to expand endpoint: %w", err) + } + endpoint = expanded + } + case http.MethodPost: + var params map[string]any + + if err := json.Unmarshal([]byte(toolCall.Function.Arguments), ¶ms); err != nil { + return nil, fmt.Errorf("invalid arguments: %w", err) + } + jsonData, err := json.Marshal(params) + if err != nil { + return nil, fmt.Errorf("failed to marshal request body: %v", err) + } + reqBody = bytes.NewReader(jsonData) + } + + req, err := http.NewRequestWithContext(ctx, h.config.Method, endpoint, reqBody) + if err != nil { + return nil, fmt.Errorf("failed to create request: %v", err) + } + + req.Header.Set("User-Agent", userAgent) + if h.config.Method == http.MethodPost { + req.Header.Set("Content-Type", "application/json") + } + + for key, value := range h.config.Headers { + req.Header.Set(key, value) + } + + resp, err := client.Do(req) + if err != nil { + return nil, fmt.Errorf("request failed: %v", err) + } + defer resp.Body.Close() + + maxSize := int64(1 << 20) + body, err := io.ReadAll(io.LimitReader(resp.Body, maxSize)) + if err != nil { + return nil, fmt.Errorf("failed to read response body: %v", err) + } + + return &tools.ToolCallResult{Output: string(body)}, nil +} + +type APIToolOption func(*APITool) + +func NewAPITool(config latest.APIToolConfig) *APITool { + return &APITool{ + config: config, + handler: &apiHandler{ + config: config, + }, + } +} + +func (t *APITool) Instructions() string { + return t.config.Instruction +} + +func (t *APITool) Tools(context.Context) ([]tools.Tool, error) { + inputSchema, err := tools.SchemaToMap(map[string]any{ + "type": "object", + "properties": t.config.Args, + "required": t.config.Required, + }) + if err != nil { + return nil, fmt.Errorf("invalid schema: %w", err) + } + + parsedURL, err := url.Parse(t.config.Endpoint) + if err != nil { + return nil, fmt.Errorf("invalid URL: %w", err) + } + + if parsedURL.Scheme == "" || parsedURL.Host == "" { + return nil, fmt.Errorf("invalid URL: missing scheme or host") + } + + if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" { + return nil, fmt.Errorf("only HTTP and HTTPS URLs are supported") + } + + return []tools.Tool{ + { + Name: t.config.Name, + Category: "api", + Description: t.config.Instruction, + Parameters: inputSchema, + OutputSchema: tools.MustSchemaFor[string](), + Handler: t.handler.CallTool, + Annotations: tools.ToolAnnotations{ + ReadOnlyHint: true, + Title: "API URLs", + }, + }, + }, nil +} + +func (t *APITool) Start(context.Context) error { + return nil +} + +func (t *APITool) Stop(context.Context) error { + return nil +} diff --git a/pkg/tools/builtin/api_test.go b/pkg/tools/builtin/api_test.go new file mode 100644 index 000000000..e4163690e --- /dev/null +++ b/pkg/tools/builtin/api_test.go @@ -0,0 +1,122 @@ +package builtin + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + latest "github.com/docker/cagent/pkg/config/v2" + "github.com/docker/cagent/pkg/tools" +) + +type testServer struct { + serverURL string + receivedURL string + receivedHeaders http.Header + receivedMethod string + receivedBody []byte +} + +func getTestServer(t *testing.T) *testServer { + t.Helper() + + ts := &testServer{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ts.receivedHeaders = r.Header.Clone() + ts.receivedURL = r.URL.String() + ts.receivedMethod = r.Method + + var err error + ts.receivedBody, err = io.ReadAll(r.Body) + if err != nil { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"status":"ok"}`)) + })) + + ts.serverURL = server.URL + + t.Cleanup(func() { + server.Close() + }) + + return ts +} + +func TestAPITool_GET(t *testing.T) { + t.Parallel() + ts := getTestServer(t) + + tool := NewAPITool(latest.APIToolConfig{ + Method: http.MethodGet, + Endpoint: ts.serverURL + "/api?key=${key}&value=${value}", + }) + + result, err := tool.handler.CallTool(t.Context(), tools.ToolCall{ + Function: tools.FunctionCall{ + Arguments: `{"key": "mykey", "value": "myvalue"}`, + }, + }) + + require.NoError(t, err) + assert.JSONEq(t, `{"status":"ok"}`, result.Output) + assert.Equal(t, http.MethodGet, ts.receivedMethod) + assert.Equal(t, "/api?key=mykey&value=myvalue", ts.receivedURL) +} + +func TestAPITool_POST(t *testing.T) { + t.Parallel() + ts := getTestServer(t) + + tool := NewAPITool(latest.APIToolConfig{ + Method: http.MethodPost, + Endpoint: ts.serverURL, + }) + + result, err := tool.handler.CallTool(t.Context(), tools.ToolCall{ + Function: tools.FunctionCall{ + Arguments: `{"name":"John Doe","age":30}`, + }, + }) + + require.NoError(t, err) + assert.JSONEq(t, `{"status":"ok"}`, result.Output) + assert.Equal(t, http.MethodPost, ts.receivedMethod) + assert.Equal(t, "application/json", ts.receivedHeaders.Get("Content-Type")) + + var receivedData map[string]any + err = json.Unmarshal(ts.receivedBody, &receivedData) + require.NoError(t, err) + assert.Equal(t, "John Doe", receivedData["name"]) + assert.InEpsilon(t, 30.0, receivedData["age"], 0.01) +} + +func TestAPITool_Headers(t *testing.T) { + t.Parallel() + ts := getTestServer(t) + + tool := NewAPITool(latest.APIToolConfig{ + Method: http.MethodGet, + Endpoint: ts.serverURL, + Headers: map[string]string{ + "X-Custom-Header": "custom-value", + "X-API-Key": "secret-key", + "X-Another-Header": "another-value", + }, + }) + + result, err := tool.handler.CallTool(t.Context(), tools.ToolCall{}) + + require.NoError(t, err) + assert.JSONEq(t, `{"status":"ok"}`, result.Output) + assert.Equal(t, "custom-value", ts.receivedHeaders.Get("X-Custom-Header")) + assert.Equal(t, "secret-key", ts.receivedHeaders.Get("X-API-Key")) + assert.Equal(t, "another-value", ts.receivedHeaders.Get("X-Another-Header")) +} diff --git a/pkg/tools/builtin/fetch.go b/pkg/tools/builtin/fetch.go index e32794940..5d7471826 100644 --- a/pkg/tools/builtin/fetch.go +++ b/pkg/tools/builtin/fetch.go @@ -17,7 +17,11 @@ import ( "github.com/docker/cagent/pkg/tools" ) -const userAgent = "cagent/1.0" +const ( + userAgent = "cagent/1.0" + + ToolNameFetch = "fetch" +) type FetchTool struct { tools.ElicitationTool @@ -308,7 +312,7 @@ USAGE TIPS func (t *FetchTool) Tools(context.Context) ([]tools.Tool, error) { return []tools.Tool{ { - Name: "fetch", + Name: ToolNameFetch, Category: "fetch", Description: "Fetch content from one or more HTTP/HTTPS URLs. Returns the response body and metadata.", Parameters: map[string]any{ diff --git a/pkg/tools/builtin/fetch_test.go b/pkg/tools/builtin/fetch_test.go index ee9d085b2..982ff50e7 100644 --- a/pkg/tools/builtin/fetch_test.go +++ b/pkg/tools/builtin/fetch_test.go @@ -237,7 +237,6 @@ func toolCall(t *testing.T, args map[string]any) tools.ToolCall { } func TestFetch_RobotsAllowed(t *testing.T) { - // Create test server that serves robots.txt allowing all robotsContent := "User-agent: *\nAllow: /" url := runHTTPServer(t, func(w http.ResponseWriter, r *http.Request) { @@ -266,7 +265,6 @@ func TestFetch_RobotsAllowed(t *testing.T) { } func TestFetch_RobotsBlocked(t *testing.T) { - // Create test server that serves robots.txt disallowing the test path robotsContent := "User-agent: *\nDisallow: /blocked" url := runHTTPServer(t, func(w http.ResponseWriter, r *http.Request) { @@ -295,7 +293,6 @@ func TestFetch_RobotsBlocked(t *testing.T) { } func TestFetch_RobotsMissing(t *testing.T) { - // Create test server that doesn't serve robots.txt (404) url := runHTTPServer(t, func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/robots.txt" { http.NotFound(w, r) diff --git a/pkg/tools/builtin/filesystem.go b/pkg/tools/builtin/filesystem.go index 2b53a870f..fa7e4e46f 100644 --- a/pkg/tools/builtin/filesystem.go +++ b/pkg/tools/builtin/filesystem.go @@ -17,6 +17,23 @@ import ( "github.com/docker/cagent/pkg/tools" ) +const ( + ToolNameCreateDirectory = "create_directory" + ToolNameDirectoryTree = "directory_tree" + ToolNameEditFile = "edit_file" + ToolNameGetFileInfo = "get_file_info" + ToolNameListAllowedDirectories = "list_allowed_directories" + ToolNameAddAllowedDirectory = "add_allowed_directory" + ToolNameListDirectory = "list_directory" + ToolNameListDirectoryWithSizes = "list_directory_with_sizes" + ToolNameMoveFile = "move_file" + ToolNameReadFile = "read_file" + ToolNameReadMultipleFiles = "read_multiple_files" + ToolNameSearchFiles = "search_files" + ToolNameSearchFilesContent = "search_files_content" + ToolNameWriteFile = "write_file" +) + // PostEditConfig represents a post-edit command configuration type PostEditConfig struct { Path string // File path pattern (glob-style) @@ -91,9 +108,7 @@ type GetFileInfoArgs struct { } type AddAllowedDirectoryArgs struct { - Path string `json:"path" jsonschema:"The directory path to add to allowed directories"` - Reason string `json:"reason" jsonschema:"Explanation of why this directory needs to be added"` - Confirmed bool `json:"confirmed,omitempty" jsonschema:"Set to true to confirm that you consent to adding this directory"` + Path string `json:"path" jsonschema:"The directory path to add to allowed directories"` } type WriteFileArgs struct { @@ -145,7 +160,7 @@ type EditFileArgs struct { func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { return []tools.Tool{ { - Name: "create_directory", + Name: ToolNameCreateDirectory, Category: "filesystem", Description: "Create a new directory or ensure a directory exists. Can create multiple nested directories in one operation.", Parameters: tools.MustSchemaFor[CreateDirectoryArgs](), @@ -156,7 +171,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "directory_tree", + Name: ToolNameDirectoryTree, Category: "filesystem", Description: "Get a recursive tree view of files and directories as a JSON structure.", Parameters: tools.MustSchemaFor[DirectoryTreeArgs](), @@ -191,7 +206,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "edit_file", + Name: ToolNameEditFile, Category: "filesystem", Description: "Make line-based edits to a text file. Each edit replaces exact line sequences with new content.", Parameters: tools.MustSchemaFor[EditFileArgs](), @@ -202,7 +217,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "get_file_info", + Name: ToolNameGetFileInfo, Category: "filesystem", Description: "Retrieve detailed metadata about a file or directory.", Parameters: tools.MustSchemaFor[GetFileInfoArgs](), @@ -214,7 +229,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "list_allowed_directories", + Name: ToolNameListAllowedDirectories, Category: "filesystem", Description: "Returns a list of directories that the server has permission to access. Don't call if you access only the current working directory. It's always allowed.", OutputSchema: tools.MustSchemaFor[string](), @@ -225,7 +240,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "add_allowed_directory", + Name: ToolNameAddAllowedDirectory, Category: "filesystem", Description: "Request to add a new directory to the allowed directories list. This requires explicit user consent for security reasons.", Parameters: tools.MustSchemaFor[AddAllowedDirectoryArgs](), @@ -236,7 +251,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "list_directory", + Name: ToolNameListDirectory, Category: "filesystem", Description: "Get a detailed listing of all files and directories in a specified path.", Parameters: tools.MustSchemaFor[ListDirectoryArgs](), @@ -248,7 +263,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "list_directory_with_sizes", + Name: ToolNameListDirectoryWithSizes, Category: "filesystem", Description: "Get a detailed listing of all files and directories in a specified path, including sizes.", Parameters: tools.MustSchemaFor[ListDirectoryArgs](), @@ -260,7 +275,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "move_file", + Name: ToolNameMoveFile, Category: "filesystem", Description: "Move or rename files and directories.", Parameters: tools.MustSchemaFor[MoveFileArgs](), @@ -271,7 +286,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "read_file", + Name: ToolNameReadFile, Category: "filesystem", Description: "Read the complete contents of a file from the file system.", Parameters: tools.MustSchemaFor[ReadFileArgs](), @@ -283,7 +298,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "read_multiple_files", + Name: ToolNameReadMultipleFiles, Category: "filesystem", Description: "Read the contents of multiple files simultaneously.", Parameters: tools.MustSchemaFor[ReadMultipleFilesArgs](), @@ -296,7 +311,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "search_files", + Name: ToolNameSearchFiles, Category: "filesystem", Description: "Recursively search for files and directories matching a pattern. Prints the full paths of matching files and the total number of files found.", Parameters: tools.MustSchemaFor[SearchFilesArgs](), @@ -308,7 +323,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "search_files_content", + Name: ToolNameSearchFilesContent, Category: "filesystem", Description: "Searches for text or regex patterns in the content of files matching a GLOB pattern.", Parameters: tools.MustSchemaFor[SearchFilesContentArgs](), @@ -320,7 +335,7 @@ func (t *FilesystemTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "write_file", + Name: ToolNameWriteFile, Category: "filesystem", Description: "Create a new file or completely overwrite an existing file with new content.", Parameters: tools.MustSchemaFor[WriteFileArgs](), @@ -557,38 +572,12 @@ func (t *FilesystemTool) handleAddAllowedDirectory(_ context.Context, toolCall t } } - // If not confirmed, show consent request - if !args.Confirmed { - consentMsg := fmt.Sprintf(`SECURITY CONSENT REQUEST - -The agent is requesting permission to add a new directory to the allowed filesystem access list: - -Path: %s -Reason: %s - -This will grant the agent read/write access to this directory and all its subdirectories. - -IMPORTANT: Only grant this permission if: -1. You trust this request and understand the security implications -2. The directory contains files the agent legitimately needs to access -3. The directory doesn't contain sensitive personal data or system files - -To proceed, call this tool again with the same parameters but add "confirmed": true -To deny, do not call the tool again. - -Current allowed directories: -%s`, absPath, args.Reason, strings.Join(t.allowedDirectories, "\n")) - - return &tools.ToolCallResult{Output: consentMsg}, nil - } - // User has confirmed, add the directory return t.addAllowedDirectory(absPath) } // addAllowedDirectory adds a directory to the allowed directories list func (t *FilesystemTool) addAllowedDirectory(absPath string) (*tools.ToolCallResult, error) { - // Add the directory to the allowed list t.allowedDirectories = append(t.allowedDirectories, absPath) successMsg := fmt.Sprintf(`Directory successfully added to allowed directories list. diff --git a/pkg/tools/builtin/filesystem_test.go b/pkg/tools/builtin/filesystem_test.go index 2855f2664..251035d50 100644 --- a/pkg/tools/builtin/filesystem_test.go +++ b/pkg/tools/builtin/filesystem_test.go @@ -64,311 +64,6 @@ func TestFilesystemTool_Tools(t *testing.T) { for _, expected := range expectedTools { assert.Contains(t, toolNames, expected) } - - // Check create_directory parameters - schema, err := json.Marshal(allTools[0].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "path": { - "description": "The directory path to create", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path" - ] -}`, string(schema)) - - // Check directory_tree parameters - schema, err = json.Marshal(allTools[1].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "max_depth": { - "description": "Maximum depth to traverse (optional)", - "type": "integer" - }, - "path": { - "description": "The directory path to traverse", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path" - ] -}`, string(schema)) - - // Check edit_file parameters - schema, err = json.Marshal(allTools[2].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "edits": { - "description": "Array of edit operations", - "items": { - "properties": { - "newText": { - "description": "The replacement text", - "type": "string" - }, - "oldText": { - "description": "The exact text to replace", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "oldText", - "newText" - ], - "type": "object" - }, - "type": "array" - }, - "path": { - "description": "The file path to edit", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path", - "edits" - ] -}`, string(schema)) - - // Check get_file_info parameters - schema, err = json.Marshal(allTools[3].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "path": { - "description": "The file or directory path to inspect", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path" - ] -}`, string(schema)) - - // Check list_allowed_directories parameters - assert.Nil(t, allTools[4].Parameters) - - // Check add_allowed_directory parameters - schema, err = json.Marshal(allTools[5].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "confirmed": { - "description": "Set to true to confirm that you consent to adding this directory", - "type": "boolean" - }, - "path": { - "description": "The directory path to add to allowed directories", - "type": "string" - }, - "reason": { - "description": "Explanation of why this directory needs to be added", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path", - "reason" - ] -}`, string(schema)) - - // Check list_directory parameters - schema, err = json.Marshal(allTools[6].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "path": { - "description": "The directory path to list", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path" - ] -}`, string(schema)) - - // Check list_directory_with_sizes parameters - schema, err = json.Marshal(allTools[7].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "path": { - "description": "The directory path to list", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path" - ] -}`, string(schema)) - - // Check move_file parameters - schema, err = json.Marshal(allTools[8].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "destination": { - "description": "The destination path", - "type": "string" - }, - "source": { - "description": "The source path", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "source", - "destination" - ] -}`, string(schema)) - - // Check read_file parameters - schema, err = json.Marshal(allTools[9].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "path": { - "description": "The file path to read", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path" - ] -}`, string(schema)) - - // Check read_multiple_files parameters - schema, err = json.Marshal(allTools[10].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "json": { - "description": "Whether to return the result as JSON", - "type": "boolean" - }, - "paths": { - "description": "Array of file paths to read", - "items": { - "type": "string" - }, - "type": "array" - } - }, - "additionalProperties": false, - "required": [ - "paths" - ] -}`, string(schema)) - - // Check search_files parameters - schema, err = json.Marshal(allTools[11].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "excludePatterns": { - "description": "Patterns to exclude from search", - "items": { - "type": "string" - }, - "type": "array" - }, - "path": { - "description": "The starting directory path", - "type": "string" - }, - "pattern": { - "description": "The search pattern", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path", - "pattern" - ] -}`, string(schema)) - - // Check search_files_content parameters - schema, err = json.Marshal(allTools[12].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "excludePatterns": { - "description": "Patterns to exclude from search", - "items": { - "type": "string" - }, - "type": "array" - }, - "is_regex": { - "description": "If true, treat query as regex; otherwise literal text", - "type": "boolean" - }, - "path": { - "description": "The starting directory path", - "type": "string" - }, - "query": { - "description": "The text or regex pattern to search for", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path", - "query" - ] -}`, string(schema)) - - // Check write_file parameters - schema, err = json.Marshal(allTools[13].Parameters) - require.NoError(t, err) - assert.JSONEq(t, `{ - "type": "object", - "properties": { - "content": { - "description": "The content to write to the file", - "type": "string" - }, - "path": { - "description": "The file path to write", - "type": "string" - } - }, - "additionalProperties": false, - "required": [ - "path", - "content" - ] -}`, string(schema)) } func TestFilesystemTool_DisplayNames(t *testing.T) { @@ -387,12 +82,10 @@ func TestFilesystemTool_IsPathAllowed(t *testing.T) { tmpDir := t.TempDir() tool := NewFilesystemTool([]string{tmpDir}) - // Test allowed path allowedPath := filepath.Join(tmpDir, "subdir", "file.txt") err := tool.isPathAllowed(allowedPath) require.NoError(t, err) - // Test disallowed path disallowedPath := "/etc/passwd" err = tool.isPathAllowed(disallowedPath) require.Error(t, err) @@ -405,7 +98,6 @@ func TestFilesystemTool_CreateDirectory(t *testing.T) { handler := getToolHandler(t, tool, "create_directory") - // Test successful directory creation newDir := filepath.Join(tmpDir, "test", "nested", "dir") args := map[string]any{"path": newDir} result := callHandler(t, handler, args) @@ -413,7 +105,6 @@ func TestFilesystemTool_CreateDirectory(t *testing.T) { assert.Contains(t, result.Output, "Directory created successfully") assert.DirExists(t, newDir) - // Test disallowed path disallowedDir := "/etc/test" args = map[string]any{"path": disallowedDir} result = callHandler(t, handler, args) @@ -428,7 +119,6 @@ func TestFilesystemTool_WriteFile(t *testing.T) { handler := getToolHandler(t, tool, "write_file") - // Test successful file write testFile := filepath.Join(tmpDir, "test.txt") content := "Hello, World!" args := map[string]any{ @@ -440,12 +130,10 @@ func TestFilesystemTool_WriteFile(t *testing.T) { assert.Contains(t, result.Output, "File written successfully") assert.FileExists(t, testFile) - // Verify content writtenContent, err := os.ReadFile(testFile) require.NoError(t, err) assert.Equal(t, content, string(writtenContent)) - // Test disallowed path disallowedFile := "/etc/test.txt" args = map[string]any{ "path": disallowedFile, @@ -473,16 +161,13 @@ func TestFilesystemTool_WriteFile_NestedDirectory(t *testing.T) { } result := callHandler(t, handler, args) - // Verify success assert.Contains(t, result.Output, "File written successfully") assert.FileExists(t, nestedFile) - // Verify content writtenContent, err := os.ReadFile(nestedFile) require.NoError(t, err) assert.Equal(t, content, string(writtenContent)) - // Verify directories were created assert.DirExists(t, filepath.Join(tmpDir, "a")) assert.DirExists(t, filepath.Join(tmpDir, "a", "b")) assert.DirExists(t, filepath.Join(tmpDir, "a", "b", "c")) @@ -492,27 +177,23 @@ func TestFilesystemTool_ReadFile(t *testing.T) { tmpDir := t.TempDir() tool := NewFilesystemTool([]string{tmpDir}) - // Create test file testFile := filepath.Join(tmpDir, "test.txt") content := "Hello, World!" require.NoError(t, os.WriteFile(testFile, []byte(content), 0o644)) handler := getToolHandler(t, tool, "read_file") - // Test successful file read args := map[string]any{"path": testFile} result := callHandler(t, handler, args) assert.Equal(t, content, result.Output) - // Test non-existent file nonExistentFile := filepath.Join(tmpDir, "nonexistent.txt") args = map[string]any{"path": nonExistentFile} result = callHandler(t, handler, args) assert.Contains(t, result.Output, "Error reading file") - // Test disallowed path disallowedFile := "/etc/passwd" args = map[string]any{"path": disallowedFile} result = callHandler(t, handler, args) @@ -525,7 +206,6 @@ func TestFilesystemTool_ReadMultipleFiles(t *testing.T) { tmpDir := t.TempDir() tool := NewFilesystemTool([]string{tmpDir}) - // Create test files file1 := filepath.Join(tmpDir, "file1.txt") file2 := filepath.Join(tmpDir, "file2.txt") content1 := "Content 1" @@ -536,7 +216,6 @@ func TestFilesystemTool_ReadMultipleFiles(t *testing.T) { handler := getToolHandler(t, tool, "read_multiple_files") - // Test successful multiple file read args := map[string]any{"paths": []string{file1, file2}} result := callHandler(t, handler, args) @@ -545,7 +224,6 @@ func TestFilesystemTool_ReadMultipleFiles(t *testing.T) { assert.Contains(t, result.Output, "=== "+file2+" ===") assert.Contains(t, result.Output, content2) - // Test with non-existent file nonExistentFile := filepath.Join(tmpDir, "nonexistent.txt") args = map[string]any{"paths": []string{file1, nonExistentFile}} result = callHandler(t, handler, args) @@ -558,7 +236,6 @@ func TestFilesystemTool_ListDirectory(t *testing.T) { tmpDir := t.TempDir() tool := NewFilesystemTool([]string{tmpDir}) - // Create test files and directories testFile := filepath.Join(tmpDir, "test.txt") testDir := filepath.Join(tmpDir, "testdir") @@ -567,14 +244,12 @@ func TestFilesystemTool_ListDirectory(t *testing.T) { handler := getToolHandler(t, tool, "list_directory") - // Test successful directory listing args := map[string]any{"path": tmpDir} result := callHandler(t, handler, args) assert.Contains(t, result.Output, "FILE test.txt") assert.Contains(t, result.Output, "DIR testdir") - // Test non-existent directory nonExistentDir := filepath.Join(tmpDir, "nonexistent") args = map[string]any{"path": nonExistentDir} result = callHandler(t, handler, args) @@ -586,7 +261,6 @@ func TestFilesystemTool_ListDirectoryWithSizes(t *testing.T) { tmpDir := t.TempDir() tool := NewFilesystemTool([]string{tmpDir}) - // Create test files and directories testFile := filepath.Join(tmpDir, "test.txt") testDir := filepath.Join(tmpDir, "testdir") content := "Hello World" @@ -596,7 +270,6 @@ func TestFilesystemTool_ListDirectoryWithSizes(t *testing.T) { handler := getToolHandler(t, tool, "list_directory_with_sizes") - // Test successful directory listing with sizes args := map[string]any{"path": tmpDir} result := callHandler(t, handler, args) @@ -608,14 +281,12 @@ func TestFilesystemTool_GetFileInfo(t *testing.T) { tmpDir := t.TempDir() tool := NewFilesystemTool([]string{tmpDir}) - // Create test file testFile := filepath.Join(tmpDir, "test.txt") content := "Hello, World!" require.NoError(t, os.WriteFile(testFile, []byte(content), 0o644)) handler := getToolHandler(t, tool, "get_file_info") - // Test successful file info args := map[string]any{"path": testFile} result := callHandler(t, handler, args) @@ -626,7 +297,6 @@ func TestFilesystemTool_GetFileInfo(t *testing.T) { assert.InDelta(t, len(content), fileInfo["size"], 0.0) assert.Equal(t, false, fileInfo["isDir"]) - // Test directory info args = map[string]any{"path": tmpDir} result = callHandler(t, handler, args) @@ -638,7 +308,6 @@ func TestFilesystemTool_MoveFile(t *testing.T) { tmpDir := t.TempDir() tool := NewFilesystemTool([]string{tmpDir}) - // Create test file sourceFile := filepath.Join(tmpDir, "source.txt") destFile := filepath.Join(tmpDir, "dest.txt") content := "Hello, World!" @@ -646,7 +315,6 @@ func TestFilesystemTool_MoveFile(t *testing.T) { handler := getToolHandler(t, tool, "move_file") - // Test successful file move args := map[string]any{ "source": sourceFile, "destination": destFile, @@ -657,12 +325,10 @@ func TestFilesystemTool_MoveFile(t *testing.T) { assert.NoFileExists(t, sourceFile) assert.FileExists(t, destFile) - // Verify content preserved movedContent, err := os.ReadFile(destFile) require.NoError(t, err) assert.Equal(t, content, string(movedContent)) - // Test move to existing file (should fail) anotherFile := filepath.Join(tmpDir, "another.txt") require.NoError(t, os.WriteFile(anotherFile, []byte("test"), 0o644)) @@ -679,14 +345,12 @@ func TestFilesystemTool_EditFile(t *testing.T) { tmpDir := t.TempDir() tool := NewFilesystemTool([]string{tmpDir}) - // Create test file testFile := filepath.Join(tmpDir, "test.txt") originalContent := "Hello World\nThis is a test\nGoodbye World" require.NoError(t, os.WriteFile(testFile, []byte(originalContent), 0o644)) handler := getToolHandler(t, tool, "edit_file") - // Test successful file edit args := map[string]any{ "path": testFile, "edits": []map[string]any{ @@ -704,13 +368,11 @@ func TestFilesystemTool_EditFile(t *testing.T) { assert.Contains(t, result.Output, "File edited successfully") - // Verify changes editedContent, err := os.ReadFile(testFile) require.NoError(t, err) expected := "Hi Universe\nThis is a test\nSee you later" assert.Equal(t, expected, string(editedContent)) - // Test edit with non-existent text args = map[string]any{ "path": testFile, "edits": []map[string]any{ @@ -728,7 +390,6 @@ func TestFilesystemTool_EditFile(t *testing.T) { func TestFilesystemTool_SearchFiles(t *testing.T) { tmpDir := t.TempDir() - // Create test files require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.txt"), []byte("test"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "test.log"), []byte("log"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "data.txt"), []byte("data"), 0o644)) @@ -740,7 +401,6 @@ func TestFilesystemTool_SearchFiles(t *testing.T) { tool := NewFilesystemTool([]string{tmpDir}) handler := getToolHandler(t, tool, "search_files") - // Test search for files containing "asdf" args := map[string]any{ "path": tmpDir, "pattern": "asdf", @@ -751,7 +411,6 @@ func TestFilesystemTool_SearchFiles(t *testing.T) { assert.Len(t, lines, 1) // Should find test.txt, test.log, and test_sub.txt assert.Contains(t, lines, "No files found") - // Test search for files containing "test" args = map[string]any{ "path": tmpDir, "pattern": "test", @@ -762,7 +421,6 @@ func TestFilesystemTool_SearchFiles(t *testing.T) { assert.Contains(t, result.Output, "3 files found:\n") assert.Len(t, lines, 3+1) // Should find test.txt, test.log, and test_sub.txt - // Test search with exclude patterns args = map[string]any{ "path": tmpDir, "pattern": "test", @@ -778,7 +436,6 @@ func TestFilesystemTool_SearchFilesContent(t *testing.T) { tmpDir := t.TempDir() tool := NewFilesystemTool([]string{tmpDir}) - // Create test files with different content file1Content := "This is a test file\nwith multiple lines\ncontaining test data" file2Content := "Another file\nwith different content\nno matching terms here" file3Content := "Final file\nhas test in it\nand more test content" @@ -789,7 +446,6 @@ func TestFilesystemTool_SearchFilesContent(t *testing.T) { handler := getToolHandler(t, tool, "search_files_content") - // Test literal text search args := map[string]any{ "path": tmpDir, "pattern": "*.txt", @@ -804,7 +460,6 @@ func TestFilesystemTool_SearchFilesContent(t *testing.T) { assert.Contains(t, result.Output, "file3.txt:3:") assert.NotContains(t, result.Output, "file2.txt") - // Test regex search args = map[string]any{ "path": tmpDir, "pattern": "*.txt", @@ -815,7 +470,6 @@ func TestFilesystemTool_SearchFilesContent(t *testing.T) { assert.Contains(t, result.Output, "file1.txt:3:") - // Test invalid regex args = map[string]any{ "path": tmpDir, "pattern": "*.txt", @@ -830,7 +484,6 @@ func TestFilesystemTool_SearchFilesContent(t *testing.T) { func TestFilesystemTool_SearchFiles_RecursivePattern(t *testing.T) { tmpDir := t.TempDir() - // Create test files require.NoError(t, os.Mkdir(filepath.Join(tmpDir, "child"), 0o755)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "first.txt"), []byte("first"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(tmpDir, "ignored"), []byte("ignored"), 0o644)) @@ -841,7 +494,6 @@ func TestFilesystemTool_SearchFiles_RecursivePattern(t *testing.T) { tool := NewFilesystemTool([]string{tmpDir}) handler := getToolHandler(t, tool, "search_files") - // Test search for files containing ".txt" files args := map[string]any{ "path": tmpDir, "pattern": "*.txt", @@ -859,7 +511,6 @@ func TestFilesystemTool_ListAllowedDirectories(t *testing.T) { handler := getToolHandler(t, tool, "list_allowed_directories") - // Test listing allowed directories args := map[string]any{} result := callHandler(t, handler, args) @@ -875,7 +526,6 @@ func TestFilesystemTool_InvalidArguments(t *testing.T) { handler := getToolHandler(t, tool, "write_file") - // Test invalid JSON toolCall := tools.ToolCall{ Function: tools.FunctionCall{ Name: "write_file", @@ -891,11 +541,9 @@ func TestFilesystemTool_InvalidArguments(t *testing.T) { func TestFilesystemTool_StartStop(t *testing.T) { tool := NewFilesystemTool([]string{"/tmp"}) - // Test Start method err := tool.Start(t.Context()) require.NoError(t, err) - // Test Stop method err = tool.Stop(t.Context()) require.NoError(t, err) } @@ -968,7 +616,6 @@ func main() { require.NoError(t, err) assert.Contains(t, editResult.Output, "File edited successfully") - // Check that post-edit was run again _, err = os.Stat(formattedFile) require.NoError(t, err, "Post-edit command should have run after edit") }) @@ -1010,55 +657,16 @@ func callHandler(t *testing.T, handler tools.ToolHandler, args any) *tools.ToolC } func TestFilesystemTool_AddAllowedDirectory(t *testing.T) { - // Create temporary directories for testing tmpDir1 := t.TempDir() tmpDir2 := t.TempDir() - // Create filesystem tool with only tmpDir1 initially allowed tool := NewFilesystemTool([]string{tmpDir1}) + assert.Len(t, tool.allowedDirectories, 1) handler := getToolHandler(t, tool, "add_allowed_directory") - t.Run("request consent for new directory", func(t *testing.T) { - args := AddAllowedDirectoryArgs{ - Path: tmpDir2, - Reason: "Need access for testing", - } - result := callHandler(t, handler, args) - - // Should return consent request message - assert.Contains(t, result.Output, "SECURITY CONSENT REQUEST") - assert.Contains(t, result.Output, tmpDir2) - assert.Contains(t, result.Output, "Need access for testing") - assert.Contains(t, result.Output, "confirmed") - - // Directory should not be added yet - assert.Len(t, tool.allowedDirectories, 1) - assert.Equal(t, tmpDir1, tool.allowedDirectories[0]) - }) - - t.Run("add directory with confirmation", func(t *testing.T) { - args := AddAllowedDirectoryArgs{ - Path: tmpDir2, - Reason: "Need access for testing", - Confirmed: true, - } - result := callHandler(t, handler, args) - - // Should return success message - assert.Contains(t, result.Output, "Directory successfully added") - assert.Contains(t, result.Output, tmpDir2) - - // Directory should now be added - assert.Len(t, tool.allowedDirectories, 2) - assert.Contains(t, tool.allowedDirectories, tmpDir1) - assert.Contains(t, tool.allowedDirectories, tmpDir2) - }) - t.Run("attempt to add already allowed directory", func(t *testing.T) { args := AddAllowedDirectoryArgs{ - Path: tmpDir1, - Reason: "Testing duplicate", - Confirmed: true, + Path: tmpDir1, } result := callHandler(t, handler, args) @@ -1067,7 +675,7 @@ func TestFilesystemTool_AddAllowedDirectory(t *testing.T) { assert.Contains(t, result.Output, tmpDir1) // Should not add duplicate - assert.Len(t, tool.allowedDirectories, 2) + assert.Len(t, tool.allowedDirectories, 1) }) t.Run("attempt to add subdirectory of allowed directory", func(t *testing.T) { @@ -1076,9 +684,7 @@ func TestFilesystemTool_AddAllowedDirectory(t *testing.T) { require.NoError(t, err) args := AddAllowedDirectoryArgs{ - Path: subDir, - Reason: "Testing subdirectory", - Confirmed: true, + Path: subDir, } result := callHandler(t, handler, args) @@ -1088,15 +694,13 @@ func TestFilesystemTool_AddAllowedDirectory(t *testing.T) { assert.Contains(t, result.Output, tmpDir1) // Should not add subdirectory - assert.Len(t, tool.allowedDirectories, 2) + assert.Len(t, tool.allowedDirectories, 1) }) t.Run("attempt to add non-existent directory", func(t *testing.T) { nonExistent := "/path/that/does/not/exist" args := AddAllowedDirectoryArgs{ - Path: nonExistent, - Reason: "Testing non-existent", - Confirmed: true, + Path: nonExistent, } result := callHandler(t, handler, args) @@ -1104,19 +708,16 @@ func TestFilesystemTool_AddAllowedDirectory(t *testing.T) { assert.Contains(t, result.Output, "Error accessing path") // Should not add non-existent directory - assert.Len(t, tool.allowedDirectories, 2) + assert.Len(t, tool.allowedDirectories, 1) }) t.Run("attempt to add file instead of directory", func(t *testing.T) { - // Create a file tempFile := filepath.Join(tmpDir2, "testfile.txt") err := os.WriteFile(tempFile, []byte("test"), 0o644) require.NoError(t, err) args := AddAllowedDirectoryArgs{ - Path: tempFile, - Reason: "Testing file", - Confirmed: true, + Path: tempFile, } result := callHandler(t, handler, args) @@ -1124,7 +725,7 @@ func TestFilesystemTool_AddAllowedDirectory(t *testing.T) { assert.Contains(t, result.Output, "is not a directory") // Should not add file - assert.Len(t, tool.allowedDirectories, 2) + assert.Len(t, tool.allowedDirectories, 1) }) } diff --git a/pkg/tools/builtin/memory.go b/pkg/tools/builtin/memory.go index 53915746a..9f71619f4 100644 --- a/pkg/tools/builtin/memory.go +++ b/pkg/tools/builtin/memory.go @@ -10,6 +10,12 @@ import ( "github.com/docker/cagent/pkg/tools" ) +const ( + ToolNameAddMemory = "add_memory" + ToolNameGetMemories = "get_memories" + ToolNameDeleteMemory = "delete_memory" +) + type DB interface { AddMemory(ctx context.Context, memory database.UserMemory) error GetMemories(ctx context.Context) ([]database.UserMemory, error) @@ -51,7 +57,7 @@ Do not talk about using the tool, just use it. func (t *MemoryTool) Tools(context.Context) ([]tools.Tool, error) { return []tools.Tool{ { - Name: "add_memory", + Name: ToolNameAddMemory, Category: "memory", Description: "Add a new memory to the database", Parameters: tools.MustSchemaFor[AddMemoryArgs](), @@ -62,7 +68,7 @@ func (t *MemoryTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "get_memories", + Name: ToolNameGetMemories, Category: "memory", Description: "Retrieve all stored memories", OutputSchema: tools.MustSchemaFor[[]database.UserMemory](), @@ -73,7 +79,7 @@ func (t *MemoryTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "delete_memory", + Name: ToolNameDeleteMemory, Category: "memory", Description: "Delete a specific memory by ID", Parameters: tools.MustSchemaFor[DeleteMemoryArgs](), diff --git a/pkg/tools/builtin/memory_test.go b/pkg/tools/builtin/memory_test.go index 178d238ed..6d6b2db02 100644 --- a/pkg/tools/builtin/memory_test.go +++ b/pkg/tools/builtin/memory_test.go @@ -62,12 +62,10 @@ func TestMemoryTool_Tools(t *testing.T) { assert.Equal(t, "memory", tool.Category) } - // Verify tool functions assert.Equal(t, "add_memory", allTools[0].Name) assert.Equal(t, "get_memories", allTools[1].Name) assert.Equal(t, "delete_memory", allTools[2].Name) - // Check add_memory parameters schema, err := json.Marshal(allTools[0].Parameters) require.NoError(t, err) assert.JSONEq(t, `{ @@ -84,10 +82,8 @@ func TestMemoryTool_Tools(t *testing.T) { ] }`, string(schema)) - // Check get_memories parameters assert.Nil(t, allTools[1].Parameters) - // Check delete_memory parameters schema, err = json.Marshal(allTools[2].Parameters) require.NoError(t, err) assert.JSONEq(t, `{ @@ -122,12 +118,10 @@ func TestMemoryTool_HandleAddMemory(t *testing.T) { manager := new(MockDB) tool := NewMemoryTool(manager) - // Setup mock using database.UserMemory manager.On("AddMemory", mock.Anything, mock.MatchedBy(func(memory database.UserMemory) bool { return memory.Memory == "test memory" })).Return(nil) - // Create tool call args := AddMemoryArgs{ Memory: "test memory", } @@ -141,10 +135,8 @@ func TestMemoryTool_HandleAddMemory(t *testing.T) { }, } - // Call handler result, err := tool.handleAddMemory(t.Context(), toolCall) - // Verify require.NoError(t, err) assert.Contains(t, result.Output, "Memory added successfully") manager.AssertExpectations(t) @@ -154,7 +146,6 @@ func TestMemoryTool_HandleGetMemories(t *testing.T) { manager := new(MockDB) tool := NewMemoryTool(manager) - // Setup mock using database.UserMemory memories := []database.UserMemory{ { ID: "1", @@ -169,7 +160,6 @@ func TestMemoryTool_HandleGetMemories(t *testing.T) { } manager.On("GetMemories", mock.Anything).Return(memories, nil) - // Create tool call toolCall := tools.ToolCall{ Function: tools.FunctionCall{ Name: "get_memories", @@ -177,10 +167,8 @@ func TestMemoryTool_HandleGetMemories(t *testing.T) { }, } - // Call handler result, err := tool.handleGetMemories(t.Context(), toolCall) - // Verify require.NoError(t, err) var returnedMemories []database.UserMemory @@ -196,12 +184,10 @@ func TestMemoryTool_HandleDeleteMemory(t *testing.T) { manager := new(MockDB) tool := NewMemoryTool(manager) - // Setup mock using database.UserMemory manager.On("DeleteMemory", mock.Anything, mock.MatchedBy(func(memory database.UserMemory) bool { return memory.ID == "1" })).Return(nil) - // Create tool call args := DeleteMemoryArgs{ ID: "1", } @@ -215,10 +201,8 @@ func TestMemoryTool_HandleDeleteMemory(t *testing.T) { }, } - // Call handler result, err := tool.handleDeleteMemory(t.Context(), toolCall) - // Verify require.NoError(t, err) assert.Contains(t, result.Output, "Memory with ID 1 deleted successfully") manager.AssertExpectations(t) @@ -257,11 +241,9 @@ func TestMemoryTool_StartStop(t *testing.T) { manager := new(MockDB) tool := NewMemoryTool(manager) - // Test Start method err := tool.Start(t.Context()) require.NoError(t, err) - // Test Stop method err = tool.Stop(t.Context()) require.NoError(t, err) } diff --git a/pkg/tools/builtin/script_shell_test.go b/pkg/tools/builtin/script_shell_test.go index 6f88f8d9c..3c7a644af 100644 --- a/pkg/tools/builtin/script_shell_test.go +++ b/pkg/tools/builtin/script_shell_test.go @@ -2,7 +2,6 @@ package builtin import ( "encoding/json" - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -33,7 +32,6 @@ func TestNewScriptShellTool_ToolNoArg(t *testing.T) { assert.Len(t, allTools, 1) schema, err := json.Marshal(allTools[0].Parameters) - fmt.Println(string(schema)) require.NoError(t, err) assert.JSONEq(t, `{ "type": "object", diff --git a/pkg/tools/builtin/shell.go b/pkg/tools/builtin/shell.go index fa6ad885d..44d311558 100644 --- a/pkg/tools/builtin/shell.go +++ b/pkg/tools/builtin/shell.go @@ -9,10 +9,13 @@ import ( "os/exec" "runtime" "strings" + "time" "github.com/docker/cagent/pkg/tools" ) +const ToolNameShell = "shell" + type ShellTool struct { tools.ElicitationTool handler *shellHandler @@ -25,11 +28,13 @@ type shellHandler struct { shell string shellArgsPrefix []string env []string + timeout time.Duration } type RunShellArgs struct { - Cmd string `json:"cmd" jsonschema:"The shell command to execute"` - Cwd string `json:"cwd" jsonschema:"The working directory to execute the command in"` + Cmd string `json:"cmd" jsonschema:"The shell command to execute"` + Cwd string `json:"cwd" jsonschema:"The working directory to execute the command in"` + Timeout int `json:"timeout,omitempty" jsonschema:"Command execution timeout in seconds (default: 30)"` } func (h *shellHandler) RunShell(ctx context.Context, toolCall tools.ToolCall) (*tools.ToolCallResult, error) { @@ -38,6 +43,16 @@ func (h *shellHandler) RunShell(ctx context.Context, toolCall tools.ToolCall) (* return nil, fmt.Errorf("invalid arguments: %w", err) } + // Determine effective timeout + effectiveTimeout := h.timeout + if params.Timeout > 0 { + effectiveTimeout = time.Duration(params.Timeout) * time.Second + } + + // Create timeout context + timeoutCtx, cancel := context.WithTimeout(ctx, effectiveTimeout) + defer cancel() + cmd := exec.Command(h.shell, append(h.shellArgsPrefix, params.Cmd)...) cmd.Env = h.env if params.Cwd != "" { @@ -73,12 +88,21 @@ func (h *shellHandler) RunShell(ctx context.Context, toolCall tools.ToolCall) (* }() select { - case <-ctx.Done(): + case <-timeoutCtx.Done(): if cmd.Process != nil { _ = kill(cmd.Process, pg) } + output := outBuf.String() + // Check if parent context was cancelled or if it was a timeout + if ctx.Err() != nil { + // Parent context was cancelled + return &tools.ToolCallResult{ + Output: "Command cancelled", + }, nil + } + // Timeout occurred return &tools.ToolCallResult{ - Output: "Command cancelled", + Output: fmt.Sprintf("Command timed out after %v\nOutput: %s", effectiveTimeout, output), }, nil case err := <-done: output := outBuf.String() @@ -136,6 +160,7 @@ func NewShellTool(env []string) *ShellTool { shell: shell, shellArgsPrefix: argsPrefix, env: env, + timeout: 30 * time.Second, }, } } @@ -158,12 +183,15 @@ On Unix-like systems, ${SHELL} is used or /bin/sh as fallback. **Command Isolation**: Each tool call creates a fresh shell session - no state persists between executions. +**Timeout Protection**: Commands have a default 30-second timeout to prevent hanging. For longer operations, specify a custom timeout. + ## Parameter Reference | Parameter | Type | Required | Description | |-----------|--------|----------|-------------| | cmd | string | Yes | Shell command to execute | | cwd | string | Yes | Working directory (use "." for current) | +| timeout | int | No | Timeout in seconds (default: 30) | ## Best Practices @@ -174,16 +202,20 @@ On Unix-like systems, ${SHELL} is used or /bin/sh as fallback. - Write advanced scripts with heredocs, that replace a lot of simple commands or tool calls - This tool is great at reading and writing multiple files at once - Avoid writing shell scripts to the disk. Instead, use heredocs to pipe the script to the SHELL +- Use the timeout parameter for long-running operations (e.g., builds, tests) ## Usage Examples **Basic command execution:** { "cmd": "ls -la", "cwd": "." } +**Long-running command with custom timeout:** +{ "cmd": "npm run build", "cwd": ".", "timeout": 120 } + **Language-specific operations:** -{ "cmd": "go test ./...", "cwd": "." } +{ "cmd": "go test ./...", "cwd": ".", "timeout": 180 } { "cmd": "npm install", "cwd": "frontend" } -{ "cmd": "python -m pytest tests/", "cwd": "backend" } +{ "cmd": "python -m pytest tests/", "cwd": "backend", "timeout": 90 } **File operations:** { "cmd": "find . -name '*.go' -type f", "cwd": "." } @@ -203,13 +235,14 @@ EOF" } ## Error Handling -Commands that exit with non-zero status codes will return error information along with any output produced before failure.` +Commands that exit with non-zero status codes will return error information along with any output produced before failure. +Commands that exceed their timeout will be terminated automatically.` } func (t *ShellTool) Tools(context.Context) ([]tools.Tool, error) { return []tools.Tool{ { - Name: "shell", + Name: ToolNameShell, Category: "shell", Description: `Executes the given shell command in the user's default shell.`, Parameters: tools.MustSchemaFor[RunShellArgs](), diff --git a/pkg/tools/builtin/shell_test.go b/pkg/tools/builtin/shell_test.go index 042499136..c989fbd4c 100644 --- a/pkg/tools/builtin/shell_test.go +++ b/pkg/tools/builtin/shell_test.go @@ -11,7 +11,6 @@ import ( ) func TestNewShellTool(t *testing.T) { - // Test with SHELL env var set t.Setenv("SHELL", "/bin/bash") tool := NewShellTool(nil) @@ -19,7 +18,6 @@ func TestNewShellTool(t *testing.T) { assert.NotNil(t, tool.handler) assert.Equal(t, "/bin/bash", tool.handler.shell) - // Test with no SHELL env var t.Setenv("SHELL", "") tool = NewShellTool(nil) @@ -39,7 +37,6 @@ func TestShellTool_Tools(t *testing.T) { assert.NotNil(t, tool.Handler) assert.Equal(t, "shell", tool.Category) } - // Verify bash function assert.Equal(t, "shell", allTools[0].Name) assert.Contains(t, allTools[0].Description, "Executes the given shell command") @@ -55,6 +52,10 @@ func TestShellTool_Tools(t *testing.T) { "cwd": { "description": "The working directory to execute the command in", "type": "string" + }, + "timeout": { + "description": "Command execution timeout in seconds (default: 30)", + "type": "integer" } }, "additionalProperties": false, @@ -81,14 +82,12 @@ func TestShellTool_HandlerEcho(t *testing.T) { // This is a simple test that should work on most systems tool := NewShellTool(nil) - // Get handler from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 1) handler := tls[0].Handler - // Create tool call for a simple echo command args := RunShellArgs{ Cmd: "echo 'hello world'", Cwd: "", @@ -103,10 +102,8 @@ func TestShellTool_HandlerEcho(t *testing.T) { }, } - // Call handler result, err := handler(t.Context(), toolCall) - // Verify require.NoError(t, err) assert.Contains(t, result.Output, "hello world") } @@ -115,14 +112,12 @@ func TestShellTool_HandlerWithCwd(t *testing.T) { // This test verifies the cwd parameter works tool := NewShellTool(nil) - // Get handler from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 1) handler := tls[0].Handler - // Create tool call for pwd command with specific cwd tmpDir := t.TempDir() // Create a temporary directory for testing args := RunShellArgs{ @@ -139,10 +134,8 @@ func TestShellTool_HandlerWithCwd(t *testing.T) { }, } - // Call handler result, err := handler(t.Context(), toolCall) - // Verify require.NoError(t, err) // The output might contain extra newlines or other characters, // so we just check if it contains the temp dir path @@ -153,14 +146,12 @@ func TestShellTool_HandlerError(t *testing.T) { // This test verifies error handling tool := NewShellTool(nil) - // Get handler from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 1) handler := tls[0].Handler - // Create tool call for a command that should fail args := RunShellArgs{ Cmd: "command_that_does_not_exist", Cwd: "", @@ -175,10 +166,8 @@ func TestShellTool_HandlerError(t *testing.T) { }, } - // Call handler result, err := handler(t.Context(), toolCall) - // Verify require.NoError(t, err, "Handler should not return an error") assert.Contains(t, result.Output, "Error executing command") } @@ -186,7 +175,6 @@ func TestShellTool_HandlerError(t *testing.T) { func TestShellTool_InvalidArguments(t *testing.T) { tool := NewShellTool(nil) - // Get handler from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 1) @@ -209,11 +197,9 @@ func TestShellTool_InvalidArguments(t *testing.T) { func TestShellTool_StartStop(t *testing.T) { tool := NewShellTool(nil) - // Test Start method err := tool.Start(t.Context()) require.NoError(t, err) - // Test Stop method err = tool.Stop(t.Context()) require.NoError(t, err) } diff --git a/pkg/tools/builtin/think.go b/pkg/tools/builtin/think.go index 3c640ba2e..f7b09af96 100644 --- a/pkg/tools/builtin/think.go +++ b/pkg/tools/builtin/think.go @@ -9,6 +9,8 @@ import ( "github.com/docker/cagent/pkg/tools" ) +const ToolNameThink = "think" + type ThinkTool struct { tools.ElicitationTool handler *thinkHandler @@ -59,7 +61,7 @@ Before taking any action or responding to the user after receiving tool results, func (t *ThinkTool) Tools(context.Context) ([]tools.Tool, error) { return []tools.Tool{ { - Name: "think", + Name: ToolNameThink, Category: "think", Description: "Use the tool to think about something. It will not obtain new information or change the database, but just append the thought to the log. Use it when complex reasoning or some cache memory is needed.", Parameters: tools.MustSchemaFor[ThinkArgs](), diff --git a/pkg/tools/builtin/think_test.go b/pkg/tools/builtin/think_test.go index 2b8ad15bd..deb80c870 100644 --- a/pkg/tools/builtin/think_test.go +++ b/pkg/tools/builtin/think_test.go @@ -38,11 +38,9 @@ func TestThinkTool_Tools(t *testing.T) { assert.Equal(t, "think", tool.Category) } - // Verify think function assert.Equal(t, "think", allTools[0].Name) assert.Contains(t, allTools[0].Description, "Use the tool to think about something") - // Check parameters schema, err := json.Marshal(allTools[0].Parameters) require.NoError(t, err) assert.JSONEq(t, `{ @@ -75,14 +73,12 @@ func TestThinkTool_DisplayNames(t *testing.T) { func TestThinkTool_Handler(t *testing.T) { tool := NewThinkTool() - // Get handler from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 1) handler := tls[0].Handler - // Create tool call with thought args := ThinkArgs{ Thought: "This is a test thought", } @@ -96,14 +92,11 @@ func TestThinkTool_Handler(t *testing.T) { }, } - // Call handler result, err := handler(t.Context(), toolCall) - // Verify require.NoError(t, err) assert.Contains(t, result.Output, "This is a test thought") - // Add another thought args.Thought = "Another thought" argsBytes, err = json.Marshal(args) require.NoError(t, err) @@ -112,7 +105,6 @@ func TestThinkTool_Handler(t *testing.T) { result, err = handler(t.Context(), toolCall) - // Verify both thoughts are in output require.NoError(t, err) assert.Contains(t, result.Output, "This is a test thought") assert.Contains(t, result.Output, "Another thought") @@ -121,7 +113,6 @@ func TestThinkTool_Handler(t *testing.T) { func TestThinkTool_InvalidArguments(t *testing.T) { tool := NewThinkTool() - // Get handler from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 1) @@ -144,11 +135,9 @@ func TestThinkTool_InvalidArguments(t *testing.T) { func TestThinkTool_StartStop(t *testing.T) { tool := NewThinkTool() - // Test Start method err := tool.Start(t.Context()) require.NoError(t, err) - // Test Stop method err = tool.Stop(t.Context()) require.NoError(t, err) } diff --git a/pkg/tools/builtin/todo.go b/pkg/tools/builtin/todo.go index 5069982eb..b67fa2ea6 100644 --- a/pkg/tools/builtin/todo.go +++ b/pkg/tools/builtin/todo.go @@ -11,6 +11,13 @@ import ( "github.com/docker/cagent/pkg/tools" ) +const ( + ToolNameCreateTodo = "create_todo" + ToolNameCreateTodos = "create_todos" + ToolNameUpdateTodo = "update_todo" + ToolNameListTodos = "list_todos" +) + type TodoTool struct { tools.ElicitationTool handler *todoHandler @@ -159,7 +166,7 @@ func (h *todoHandler) listTodos(context.Context, tools.ToolCall) (*tools.ToolCal func (t *TodoTool) Tools(context.Context) ([]tools.Tool, error) { return []tools.Tool{ { - Name: "create_todo", + Name: ToolNameCreateTodo, Category: "todo", Description: "Create a new todo item with a description", Parameters: tools.MustSchemaFor[CreateTodoArgs](), @@ -171,7 +178,7 @@ func (t *TodoTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "create_todos", + Name: ToolNameCreateTodos, Category: "todo", Description: "Create a list of new todo items with descriptions", Parameters: tools.MustSchemaFor[CreateTodosArgs](), @@ -183,7 +190,7 @@ func (t *TodoTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "update_todo", + Name: ToolNameUpdateTodo, Category: "todo", Description: "Update the status of a todo item", Parameters: tools.MustSchemaFor[UpdateTodoArgs](), @@ -195,7 +202,7 @@ func (t *TodoTool) Tools(context.Context) ([]tools.Tool, error) { }, }, { - Name: "list_todos", + Name: ToolNameListTodos, Category: "todo", Description: "List all current todos with their status", OutputSchema: tools.MustSchemaFor[string](), diff --git a/pkg/tools/builtin/todo_test.go b/pkg/tools/builtin/todo_test.go index 5cc858fdb..c129975a0 100644 --- a/pkg/tools/builtin/todo_test.go +++ b/pkg/tools/builtin/todo_test.go @@ -39,13 +39,11 @@ func TestTodoTool_Tools(t *testing.T) { assert.Equal(t, "todo", tool.Category) } - // Verify tool functions assert.Equal(t, "create_todo", allTools[0].Name) assert.Equal(t, "create_todos", allTools[1].Name) assert.Equal(t, "update_todo", allTools[2].Name) assert.Equal(t, "list_todos", allTools[3].Name) - // Check create_todo parameters schema, err := json.Marshal(allTools[0].Parameters) require.NoError(t, err) assert.JSONEq(t, `{ @@ -62,7 +60,6 @@ func TestTodoTool_Tools(t *testing.T) { ] }`, string(schema)) - // Check create_todos parameters schema, err = json.Marshal(allTools[1].Parameters) require.NoError(t, err) assert.JSONEq(t, `{ @@ -82,7 +79,6 @@ func TestTodoTool_Tools(t *testing.T) { "additionalProperties": false }`, string(schema)) - // Check update_todo parameters schema, err = json.Marshal(allTools[2].Parameters) require.NoError(t, err) assert.JSONEq(t, `{ @@ -104,7 +100,6 @@ func TestTodoTool_Tools(t *testing.T) { ] }`, string(schema)) - // Check list_todos parameters assert.Nil(t, allTools[3].Parameters) } @@ -123,14 +118,12 @@ func TestTodoTool_DisplayNames(t *testing.T) { func TestTodoTool_CreateTodo(t *testing.T) { tool := NewTodoTool() - // Get handler from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 4) createHandler := tls[0].Handler - // Create tool call args := CreateTodoArgs{ Description: "Test todo item", } @@ -144,14 +137,11 @@ func TestTodoTool_CreateTodo(t *testing.T) { }, } - // Call handler result, err := createHandler(t.Context(), toolCall) - // Verify require.NoError(t, err) assert.Contains(t, result.Output, "Created todo [todo_1]: Test todo item") - // Verify todo was added to the handler's todos map assert.Equal(t, 1, tool.handler.todos.Length()) todo, exists := tool.handler.todos.Load("todo_1") assert.True(t, exists) @@ -162,14 +152,12 @@ func TestTodoTool_CreateTodo(t *testing.T) { func TestTodoTool_CreateTodos(t *testing.T) { tool := NewTodoTool() - // Get handler from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 4) createTodosHandler := tls[1].Handler - // Create multiple todos args := CreateTodosArgs{ Descriptions: []string{ "First todo item", @@ -187,20 +175,16 @@ func TestTodoTool_CreateTodos(t *testing.T) { }, } - // Call handler result, err := createTodosHandler(t.Context(), toolCall) - // Verify require.NoError(t, err) assert.Contains(t, result.Output, "Created 3 todos:") assert.Contains(t, result.Output, "todo_1") assert.Contains(t, result.Output, "todo_2") assert.Contains(t, result.Output, "todo_3") - // Verify todos were added to the handler's todos map assert.Equal(t, 3, tool.handler.todos.Length()) - // Create multiple todos args = CreateTodosArgs{ Descriptions: []string{ "Last todo item", @@ -216,7 +200,6 @@ func TestTodoTool_CreateTodos(t *testing.T) { }, } - // Call handler result, err = createTodosHandler(t.Context(), toolCall) require.NoError(t, err) @@ -228,7 +211,6 @@ func TestTodoTool_CreateTodos(t *testing.T) { func TestTodoTool_UpdateTodo(t *testing.T) { tool := NewTodoTool() - // Get handlers from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 4) @@ -268,14 +250,11 @@ func TestTodoTool_UpdateTodo(t *testing.T) { }, } - // Call update handler result, err := updateHandler(t.Context(), updateToolCall) - // Verify require.NoError(t, err) assert.Contains(t, result.Output, "Updated todo [todo_1] to status: [completed]") - // Verify todo status was updated todo, exists := tool.handler.todos.Load("todo_1") assert.True(t, exists) assert.Equal(t, "completed", todo.Status) @@ -284,7 +263,6 @@ func TestTodoTool_UpdateTodo(t *testing.T) { func TestTodoTool_ListTodos(t *testing.T) { tool := NewTodoTool() - // Get handlers from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 4) @@ -292,7 +270,6 @@ func TestTodoTool_ListTodos(t *testing.T) { createHandler := tls[0].Handler listHandler := tls[3].Handler - // Create a few todos todos := []string{ "First todo item", "Second todo item", @@ -325,10 +302,8 @@ func TestTodoTool_ListTodos(t *testing.T) { }, } - // Call list handler result, err := listHandler(t.Context(), listToolCall) - // Verify require.NoError(t, err) assert.Contains(t, result.Output, "Current todos:") for _, todoDesc := range todos { @@ -340,7 +315,6 @@ func TestTodoTool_ListTodos(t *testing.T) { func TestTodoTool_UpdateNonexistentTodo(t *testing.T) { tool := NewTodoTool() - // Get update handler from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 4) @@ -362,17 +336,14 @@ func TestTodoTool_UpdateNonexistentTodo(t *testing.T) { }, } - // Call update handler _, err = updateHandler(t.Context(), updateToolCall) - // Verify error assert.ErrorContains(t, err, "not found") } func TestTodoTool_InvalidArguments(t *testing.T) { tool := NewTodoTool() - // Get handlers from tool tls, err := tool.Tools(t.Context()) require.NoError(t, err) require.Len(t, tls, 4) @@ -406,11 +377,9 @@ func TestTodoTool_InvalidArguments(t *testing.T) { func TestTodoTool_StartStop(t *testing.T) { tool := NewTodoTool() - // Test Start method err := tool.Start(t.Context()) require.NoError(t, err) - // Test Stop method err = tool.Stop(t.Context()) require.NoError(t, err) } diff --git a/pkg/tools/builtin/transfertask.go b/pkg/tools/builtin/transfertask.go index 1f30a1009..9902b5f35 100644 --- a/pkg/tools/builtin/transfertask.go +++ b/pkg/tools/builtin/transfertask.go @@ -6,6 +6,8 @@ import ( "github.com/docker/cagent/pkg/tools" ) +const ToolNameTransferTask = "transfer_task" + type TransferTaskTool struct { tools.ElicitationTool } @@ -30,7 +32,7 @@ func (t *TransferTaskTool) Instructions() string { func (t *TransferTaskTool) Tools(context.Context) ([]tools.Tool, error) { return []tools.Tool{ { - Name: "transfer_task", + Name: ToolNameTransferTask, Category: "transfer", Description: `Use this function to transfer a task to the selected team member. You must provide a clear and concise description of the task the member should achieve AND the expected output.`, diff --git a/pkg/tools/builtin/transfertask_test.go b/pkg/tools/builtin/transfertask_test.go index bece47031..4eb6b8519 100644 --- a/pkg/tools/builtin/transfertask_test.go +++ b/pkg/tools/builtin/transfertask_test.go @@ -27,15 +27,12 @@ func TestTaskTool_Tools(t *testing.T) { require.NoError(t, err) assert.Len(t, allTools, 1) - // Verify transfer_task function assert.Equal(t, "transfer_task", allTools[0].Name) assert.Equal(t, "transfer", allTools[0].Category) assert.Contains(t, allTools[0].Description, "transfer a task to the selected team member") - // Verify no handler is provided (it's handled externally) assert.Nil(t, allTools[0].Handler) - // Check parameters schema, err := json.Marshal(allTools[0].Parameters) require.NoError(t, err) assert.JSONEq(t, `{ @@ -79,11 +76,9 @@ func TestTaskTool_DisplayNames(t *testing.T) { func TestTaskTool_StartStop(t *testing.T) { tool := NewTransferTaskTool() - // Test Start method err := tool.Start(t.Context()) require.NoError(t, err) - // Test Stop method err = tool.Stop(t.Context()) require.NoError(t, err) } diff --git a/pkg/tools/codemode/codemode_test.go b/pkg/tools/codemode/codemode_test.go index 4dac9d263..beccd5d2b 100644 --- a/pkg/tools/codemode/codemode_test.go +++ b/pkg/tools/codemode/codemode_test.go @@ -3,7 +3,6 @@ package codemode import ( "context" "encoding/json" - "fmt" "testing" "github.com/stretchr/testify/assert" @@ -41,7 +40,6 @@ func TestCodeModeTool_Tools(t *testing.T) { }`, string(inputSchema)) outputSchema, err := json.Marshal(fetchTool.OutputSchema) - fmt.Println(string(outputSchema)) require.NoError(t, err) assert.JSONEq(t, `{ "type": "object", diff --git a/pkg/tui/commands/commands.go b/pkg/tui/commands/commands.go index d1bc3f4df..0db7fb180 100644 --- a/pkg/tui/commands/commands.go +++ b/pkg/tui/commands/commands.go @@ -3,7 +3,7 @@ package commands import ( "context" - tea "github.com/charmbracelet/bubbletea/v2" + tea "charm.land/bubbletea/v2" "github.com/docker/cagent/pkg/app" "github.com/docker/cagent/pkg/feedback" @@ -100,7 +100,7 @@ func builtInFeedbackCommands() []Item { }, }, { - ID: "feedback.feeedback", + ID: "feedback.feedback", Label: "Give Feedback", Description: "Provide feedback about cagent", Category: "Feedback", diff --git a/pkg/tui/components/completion/completion.go b/pkg/tui/components/completion/completion.go index 25628e11d..8118e36f4 100644 --- a/pkg/tui/components/completion/completion.go +++ b/pkg/tui/components/completion/completion.go @@ -4,13 +4,14 @@ import ( "sort" "strings" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/junegunn/fzf/src/algo" "github.com/junegunn/fzf/src/util" "github.com/docker/cagent/pkg/tui/core" + "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/styles" ) @@ -78,7 +79,7 @@ func defaultCompletionKeyMap() completionKeyMap { // Manager manages the dialog stack and rendering type Manager interface { - tea.Model + layout.Model GetLayers() []*lipgloss.Layer Open() bool @@ -112,7 +113,7 @@ func (c *manager) Open() bool { return c.visible } -func (c *manager) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (c *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: c.width = msg.Width @@ -166,6 +167,12 @@ func (c *manager) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return c, nil } +func (c *manager) SetSize(width, height int) tea.Cmd { + c.width = width + c.height = height + return nil +} + func (c *manager) View() string { if !c.visible { return "" @@ -221,7 +228,7 @@ func (c *manager) GetLayers() []*lipgloss.Layer { view := c.View() viewHeight := lipgloss.Height(view) - editorHeight := 5 + editorHeight := 4 yPos := max(c.height-viewHeight-editorHeight-1, 0) return []*lipgloss.Layer{ diff --git a/pkg/tui/components/editor/editor.go b/pkg/tui/components/editor/editor.go index e101e8837..4f6309e3d 100644 --- a/pkg/tui/components/editor/editor.go +++ b/pkg/tui/components/editor/editor.go @@ -3,10 +3,8 @@ package editor import ( "strings" - "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/textarea" - tea "github.com/charmbracelet/bubbletea/v2" + "charm.land/bubbles/v2/textarea" + tea "charm.land/bubbletea/v2" "github.com/docker/cagent/pkg/app" "github.com/docker/cagent/pkg/history" @@ -27,7 +25,6 @@ type Editor interface { layout.Model layout.Sizeable layout.Focusable - layout.Help SetWorking(working bool) tea.Cmd } @@ -72,7 +69,7 @@ func (e *editor) Init() tea.Cmd { } // Update handles messages and updates the component state -func (e *editor) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (e *editor) Update(msg tea.Msg) (layout.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -203,21 +200,6 @@ func (e *editor) Blur() tea.Cmd { return nil } -// Bindings returns key bindings for the component -func (e *editor) Bindings() []key.Binding { - return []key.Binding{ - key.NewBinding( - key.WithKeys("enter"), - key.WithHelp("enter", "send"), - ), - } -} - -// Help returns the help information -func (e *editor) Help() help.KeyMap { - return core.NewSimpleHelp(e.Bindings()) -} - func (e *editor) SetWorking(working bool) tea.Cmd { e.working = working return nil diff --git a/pkg/tui/components/message/message.go b/pkg/tui/components/message/message.go index 606c96af9..72d0d6b55 100644 --- a/pkg/tui/components/message/message.go +++ b/pkg/tui/components/message/message.go @@ -5,8 +5,8 @@ import ( "regexp" "strings" - "github.com/charmbracelet/bubbles/v2/spinner" - tea "github.com/charmbracelet/bubbletea/v2" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" "github.com/docker/cagent/pkg/tui/components/markdown" "github.com/docker/cagent/pkg/tui/core/layout" @@ -56,7 +56,7 @@ func (mv *messageModel) SetMessage(msg *types.Message) { } // Update handles messages and updates the message view state -func (mv *messageModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (mv *messageModel) Update(msg tea.Msg) (layout.Model, tea.Cmd) { if mv.message.Type == types.MessageTypeSpinner { var cmd tea.Cmd mv.spinner, cmd = mv.spinner.Update(msg) @@ -117,10 +117,15 @@ func (mv *messageModel) Render(width int) string { return styles.MutedStyle.Render("•" + strings.Repeat("─", mv.width-3) + "•") case types.MessageTypeCancelled: return styles.WarningStyle.Render("⚠ stream cancelled ⚠") + case types.MessageTypeWelcome: + // Render welcome message with a distinct style + rendered, err := markdown.NewRenderer(width).Render(msg.Content) + if err != nil { + return styles.MutedStyle.Render(msg.Content) + } + return styles.MutedStyle.Render(strings.TrimRight(rendered, "\n\r\t ")) case types.MessageTypeError: return styles.ErrorStyle.Render("│ " + msg.Content) - case types.MessageTypeWarning: - return styles.WarningStyle.Render(msg.Content) default: return msg.Content } diff --git a/pkg/tui/components/messages/messages.go b/pkg/tui/components/messages/messages.go index 8aea5e3a4..de9961fa9 100644 --- a/pkg/tui/components/messages/messages.go +++ b/pkg/tui/components/messages/messages.go @@ -1,15 +1,14 @@ package messages import ( - "fmt" "strings" "time" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" "github.com/charmbracelet/x/ansi" "github.com/mattn/go-runewidth" @@ -42,19 +41,20 @@ type Model interface { layout.Sizeable layout.Focusable layout.Help + layout.Positionable AddUserMessage(content string) tea.Cmd AddErrorMessage(content string) tea.Cmd AddAssistantMessage() tea.Cmd AddSeparatorMessage() tea.Cmd AddCancelledMessage() tea.Cmd + AddWelcomeMessage(content string) tea.Cmd AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status types.ToolStatus) tea.Cmd AddToolResult(msg *runtime.ToolCallResponseEvent, status types.ToolStatus) tea.Cmd AppendToLastMessage(agentName string, messageType types.MessageType, content string) tea.Cmd - ScrollToBottom() tea.Cmd AddShellOutputMessage(content string) tea.Cmd - AddWarningMessage(content string) tea.Cmd - PlainTextTranscript() string + + ScrollToBottom() tea.Cmd IsAtBottom() bool } @@ -111,6 +111,10 @@ type model struct { totalHeight int // Total height of all content in lines selection selectionState + + splitDiffView bool + + xPos, yPos int } // New creates a new message list component @@ -120,6 +124,7 @@ func New(a *app.App) Model { height: 24, app: a, renderedItems: make(map[int]renderedItem), + splitDiffView: true, } } @@ -138,20 +143,17 @@ func (m *model) Init() tea.Cmd { } // Update handles messages and updates the component state -func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { case StreamCancelledMsg: // Handle stream cancellation internally m.removeSpinner() - m.cancelPendingToolCalls() + m.removePendingToolCallMessages() return m, nil case tea.WindowSizeMsg: - cmd := m.SetSize(msg.Width, msg.Height) - if cmd != nil { - cmds = append(cmds, cmd) - } + cmds = append(cmds, m.SetSize(msg.Width, msg.Height)) case tea.MouseClickMsg: if msg.Button == tea.MouseLeft { @@ -218,6 +220,20 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case tool.ToggleDiffViewMsg: + m.splitDiffView = !m.splitDiffView + + var cmds []tea.Cmd + for i, view := range m.views { + updatedView, cmd := view.Update(tool.ToggleDiffViewMsg{}) + m.views[i] = updatedView + cmds = append(cmds, cmd) + } + + m.invalidateAllItems() + + return m, tea.Batch(cmds...) + case tea.KeyPressMsg: switch msg.String() { case "esc": @@ -247,12 +263,8 @@ func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Forward updates to all message views for i, view := range m.views { updatedView, cmd := view.Update(msg) - if updatedView != nil { - m.views[i] = updatedView.(layout.Model) - } - if cmd != nil { - cmds = append(cmds, cmd) - } + m.views[i] = updatedView + cmds = append(cmds, cmd) } return m, tea.Batch(cmds...) @@ -311,6 +323,12 @@ func (m *model) SetSize(width, height int) tea.Cmd { return nil } +func (m *model) SetPosition(x, y int) tea.Cmd { + m.xPos = x + m.yPos = y + return nil +} + // GetSize returns the current dimensions func (m *model) GetSize() (width, height int) { return m.width, m.height @@ -412,7 +430,7 @@ func (m *model) renderItem(index int, view layout.Model) renderedItem { // Render the item (always for dynamic content, or when not cached) rendered := view.View() - height := strings.Count(rendered, "\n") + 1 + height := lipgloss.Height(rendered) if rendered == "" { height = 0 } @@ -513,14 +531,6 @@ func (m *model) AddShellOutputMessage(content string) tea.Cmd { }) } -func (m *model) AddWarningMessage(content string) tea.Cmd { - // Create a new warning message - return m.addMessage(&types.Message{ - Type: types.MessageTypeWarning, - Content: content, - }) -} - // AddAssistantMessage adds an assistant message to the chat func (m *model) AddAssistantMessage() tea.Cmd { return m.addMessage(&types.Message{ @@ -580,6 +590,24 @@ func (m *model) AddCancelledMessage() tea.Cmd { return view.Init() } +// AddWelcomeMessage adds a welcome message to the chat +func (m *model) AddWelcomeMessage(content string) tea.Cmd { + if content == "" { + return nil + } + + msg := types.Message{ + Type: types.MessageTypeWelcome, + Content: content, + } + m.messages = append(m.messages, msg) + + view := m.createMessageView(&msg) + m.views = append(m.views, view) + + return view.Init() +} + // AddOrUpdateToolCall adds a tool call or updates existing one with the given status func (m *model) AddOrUpdateToolCall(agentName string, toolCall tools.ToolCall, toolDef tools.Tool, status types.ToolStatus) tea.Cmd { // First try to update existing tool by ID @@ -678,39 +706,8 @@ func (m *model) ScrollToBottom() tea.Cmd { } } -// PlainTextTranscript returns the conversation as plain text suitable for copying -func (m *model) PlainTextTranscript() string { - var builder strings.Builder - - for i := range m.messages { - msg := m.messages[i] - switch msg.Type { - case types.MessageTypeUser: - writeTranscriptSection(&builder, "User", msg.Content) - case types.MessageTypeAssistant: - label := assistantLabel(msg.Sender) - writeTranscriptSection(&builder, label, msg.Content) - case types.MessageTypeAssistantReasoning: - label := assistantLabel(msg.Sender) + " (thinking)" - writeTranscriptSection(&builder, label, msg.Content) - case types.MessageTypeShellOutput: - writeTranscriptSection(&builder, "Shell Output", msg.Content) - case types.MessageTypeError: - writeTranscriptSection(&builder, "Error", msg.Content) - case types.MessageTypeToolCall: - callLabel := toolCallLabel(msg) - writeTranscriptSection(&builder, callLabel, formatToolCallContent(msg)) - case types.MessageTypeToolResult: - resultLabel := toolResultLabel(msg) - writeTranscriptSection(&builder, resultLabel, msg.Content) - } - } - - return strings.TrimSpace(builder.String()) -} - func (m *model) createToolCallView(msg *types.Message) layout.Model { - view := tool.New(msg, m.app, markdown.NewRenderer(m.width)) + view := tool.New(msg, m.app, markdown.NewRenderer(m.width), m.splitDiffView) view.SetSize(m.width, 0) return view } @@ -738,8 +735,8 @@ func (m *model) removeSpinner() { } } -// cancelPendingToolCalls removes any tool calls that are in pending or running state -func (m *model) cancelPendingToolCalls() { +// removePendingToolCallMessages removes any tool call messages that are in pending or running state +func (m *model) removePendingToolCallMessages() { var newMessages []types.Message var newViews []layout.Model @@ -764,67 +761,15 @@ func (m *model) cancelPendingToolCalls() { } } -func assistantLabel(sender string) string { - trimmed := strings.TrimSpace(sender) - if trimmed == "" || trimmed == "root" { - return "Assistant" - } - return trimmed -} - -func writeTranscriptSection(builder *strings.Builder, title, content string) { - text := strings.TrimSpace(content) - if text == "" { - return - } - if builder.Len() > 0 { - builder.WriteString("\n\n") - } - builder.WriteString(title) - builder.WriteString(":\n") - builder.WriteString(text) -} - -func toolCallLabel(msg types.Message) string { - name := strings.TrimSpace(msg.ToolCall.Function.Name) - if name == "" { - return "Tool Call" - } - return fmt.Sprintf("Tool Call (%s)", name) -} - -func formatToolCallContent(msg types.Message) string { - sender := assistantLabel(msg.Sender) - name := strings.TrimSpace(msg.ToolCall.Function.Name) - if name == "" { - name = "tool" - } - var parts []string - parts = append(parts, fmt.Sprintf("%s invoked %s", sender, name)) - if args := strings.TrimSpace(msg.ToolCall.Function.Arguments); args != "" { - parts = append(parts, "Arguments:", args) - } - return strings.Join(parts, "\n") -} - -func toolResultLabel(msg types.Message) string { - name := strings.TrimSpace(msg.ToolCall.Function.Name) - if name == "" { - return "Tool Result" - } - return fmt.Sprintf("Tool Result (%s)", name) -} - // mouseToLineCol converts mouse position to line/column in rendered content func (m *model) mouseToLineCol(x, y int) (line, col int) { - // Adjust for header (2 lines: text + bottom padding) - adjustedY := max(0, y-2) - line = m.scrollOffset + adjustedY - // Adjust for left padding (1 column from AppStyle) - adjustedX := max(0, x-1) + adjustedX := max(0, x-1-m.xPos) col = adjustedX + adjustedY := max(0, y-m.yPos) + line = m.scrollOffset + adjustedY + return line, col } @@ -851,50 +796,41 @@ func (m *model) extractSelectedText() string { endLine = len(lines) - 1 } - // Single line selection - if startLine == endLine { - if startLine < len(lines) { - line := ansi.Strip(lines[startLine]) - // Convert display width to rune indices - startIdx := displayWidthToRuneIndex(line, startCol) - endIdx := displayWidthToRuneIndex(line, endCol) - runes := []rune(line) - if startIdx < len(runes) && startIdx < endIdx { - if endIdx > len(runes) { - endIdx = len(runes) - } - return string(runes[startIdx:endIdx]) - } - } - return "" - } - - // Multi-line selection var result strings.Builder for i := startLine; i <= endLine && i < len(lines); i++ { line := ansi.Strip(lines[i]) runes := []rune(line) + var lineText string switch i { case startLine: + if startLine == endLine { + startIdx := displayWidthToRuneIndex(line, startCol) + endIdx := min(displayWidthToRuneIndex(line, endCol), len(runes)) + if startIdx < len(runes) && startIdx < endIdx { + lineText = strings.TrimSpace(string(runes[startIdx:endIdx])) + } + break + } // First line: from startCol to end startIdx := displayWidthToRuneIndex(line, startCol) if startIdx < len(runes) { - result.WriteString(string(runes[startIdx:])) + lineText = strings.TrimSpace(string(runes[startIdx:])) } case endLine: // Last line: from start to endCol endIdx := min(displayWidthToRuneIndex(line, endCol), len(runes)) - result.WriteString(string(runes[:endIdx])) + lineText = strings.TrimSpace(string(runes[:endIdx])) default: // Middle lines: entire line - result.WriteString(line) + lineText = strings.TrimSpace(line) } - // Add newline except for last line - if i < endLine { - result.WriteString("\n") + if lineText != "" { + result.WriteString(lineText) } + + result.WriteString("\n") } return result.String() @@ -905,7 +841,7 @@ func (m *model) copySelectionToClipboard() tea.Cmd { return nil } - selectedText := m.extractSelectedText() + selectedText := strings.TrimSpace(m.extractSelectedText()) if selectedText == "" { return nil } @@ -967,26 +903,33 @@ func (m *model) applySelectionHighlight(lines []string, viewportStartLine int) [ } func (m *model) highlightLine(line string, startCol, endCol int) string { + // Get plain text for boundary checks plainLine := ansi.Strip(line) + plainWidth := runewidth.StringWidth(plainLine) - startRuneIdx := displayWidthToRuneIndex(plainLine, startCol) - endRuneIdx := displayWidthToRuneIndex(plainLine, endCol) - - if startRuneIdx >= len([]rune(plainLine)) { + // Validate and normalize boundaries + if startCol >= plainWidth { return line } - if startRuneIdx >= endRuneIdx { + if startCol >= endCol { return line } - - runes := []rune(plainLine) - before := string(runes[:startRuneIdx]) - selected := styles.SelectionStyle.Render(string(runes[startRuneIdx:endRuneIdx])) - after := "" - if endRuneIdx < len(runes) { - after = string(runes[endRuneIdx:]) + if endCol > plainWidth { + endCol = plainWidth } + // Extract the three parts while preserving ANSI codes + // before: from start to startCol (preserves original styling) + before := ansi.Cut(line, 0, startCol) + + // selected: from startCol to endCol (strip styling, apply selection style) + selectedText := ansi.Cut(line, startCol, endCol) + selectedPlain := ansi.Strip(selectedText) + selected := styles.SelectionStyle.Render(selectedPlain) + + // after: from endCol to end (preserves original styling) + after := ansi.Cut(line, endCol, plainWidth) + return before + selected + after } @@ -1014,12 +957,7 @@ func (m *model) autoScroll() tea.Cmd { // Use stored screen Y coordinate to check if mouse is in autoscroll region // mouseToLineCol subtracts 2 for header, so viewport-relative Y is mouseY - 2 - viewportY := m.selection.mouseY - 2 - - // Ensure viewportY is valid (can't be negative or beyond viewport) - if viewportY < 0 { - viewportY = 0 - } + viewportY := max(m.selection.mouseY-2, 0) if viewportY < scrollThreshold && m.scrollOffset > 0 { // Scroll up - mouse is near top of viewport diff --git a/pkg/tui/components/messages/messages_test.go b/pkg/tui/components/messages/messages_test.go deleted file mode 100644 index 2d2b526ca..000000000 --- a/pkg/tui/components/messages/messages_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package messages - -import ( - "testing" - - "github.com/docker/cagent/pkg/tools" - "github.com/docker/cagent/pkg/tui/types" -) - -func TestPlainTextTranscript(t *testing.T) { - m := &model{ - messages: []types.Message{ - {Type: types.MessageTypeUser, Content: "Hello"}, - {Type: types.MessageTypeAssistant, Sender: "helper", Content: "Hi"}, - {Type: types.MessageTypeAssistantReasoning, Sender: "helper", Content: "Thinking"}, - { - Type: types.MessageTypeToolCall, - Sender: "helper", - ToolCall: tools.ToolCall{Function: tools.FunctionCall{ - Name: "search", - Arguments: `{"q":"test"}`, - }}, - }, - { - Type: types.MessageTypeToolResult, - ToolCall: tools.ToolCall{Function: tools.FunctionCall{Name: "search"}}, - Content: "Result", - }, - {Type: types.MessageTypeError, Content: "Oops"}, - }, - } - - expected := `User: -Hello - -helper: -Hi - -helper (thinking): -Thinking - -Tool Call (search): -helper invoked search -Arguments: -{"q":"test"} - -Tool Result (search): -Result - -Error: -Oops` - if got := m.PlainTextTranscript(); got != expected { - t.Fatalf("unexpected transcript:\nexpected:\n%q\n\ngot:\n%q", expected, got) - } -} diff --git a/pkg/tui/components/notification/notification.go b/pkg/tui/components/notification/notification.go index c4d44d314..a1c235221 100644 --- a/pkg/tui/components/notification/notification.go +++ b/pkg/tui/components/notification/notification.go @@ -5,8 +5,8 @@ import ( "sync/atomic" "time" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/tui/styles" ) diff --git a/pkg/tui/components/sidebar/sidebar.go b/pkg/tui/components/sidebar/sidebar.go index 41886b457..e14d04554 100644 --- a/pkg/tui/components/sidebar/sidebar.go +++ b/pkg/tui/components/sidebar/sidebar.go @@ -3,11 +3,13 @@ package sidebar import ( "fmt" "os" + "sort" // ensure deterministic breakdown ordering "strings" - "github.com/charmbracelet/bubbles/v2/spinner" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/dustin/go-humanize" // provides comma-separated number formatting "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/tools" @@ -16,46 +18,92 @@ import ( "github.com/docker/cagent/pkg/tui/styles" ) +type Mode int + +const ( + ModeVertical Mode = iota + ModeHorizontal +) + // Model represents a sidebar component -type Model interface { +type Model interface { // interface defines sidebar contract layout.Model layout.Sizeable - SetTokenUsage(usage *runtime.Usage) + SetTokenUsage(event *runtime.TokenUsageEvent) // accepts enriched runtime events for usage tracking SetTodos(toolCall tools.ToolCall) error SetWorking(working bool) tea.Cmd - SetMCPInitializing(initializing bool) tea.Cmd + SetMode(mode Mode) + GetSize() (width, height int) } // model implements Model -type model struct { - width int - height int - usage *runtime.Usage - todoComp *todo.Component - working bool - mcpInit bool - spinner spinner.Model +type model struct { // tea model for sidebar component + width int // viewport width + height int // viewport height + usageState usageState // aggregated usage tracking state + todoComp *todo.Component // embedded todo component + working bool // indicates if runtime is working + mcpInit bool // indicates MCP initialization state + spinner spinner.Model // spinner for busy indicator + mode Mode // layout mode + sessionTitle string // current session title +} + +type usageState struct { // holds all token usage snapshots for sidebar + sessions map[string]*runtime.Usage // per-session self usage snapshots + sessionAgents map[string]string // optional agent name mapping per session + rootInclusive *runtime.Usage // inclusive usage snapshot emitted by root + rootSessionID string // session ID associated with root agent + rootAgentName string // resolved root agent name for comparisons + activeSessionID string // currently active session ID for highlighting } -// New creates a new sidebar component func New() Model { return &model{ - width: 20, // Default width - height: 24, // Default height - usage: &runtime.Usage{}, - todoComp: todo.NewComponent(), - spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), + width: 20, // default width matches initial layout + height: 24, // default height matches initial layout + usageState: usageState{ // initialize usage tracking containers + sessions: make(map[string]*runtime.Usage), // allocate map to avoid nil lookups + sessionAgents: make(map[string]string), // track agent names per session + }, + todoComp: todo.NewComponent(), // instantiate todo component + spinner: spinner.New(spinner.WithSpinner(spinner.Dot)), // configure spinner visuals + sessionTitle: "New session", // initial placeholder title } } -// Init initializes the component func (m *model) Init() tea.Cmd { return nil } -func (m *model) SetTokenUsage(usage *runtime.Usage) { - m.usage = usage +func (m *model) SetTokenUsage(event *runtime.TokenUsageEvent) { // updates usage state from runtime events + if event == nil { // guard against nil events + return // nothing to do when event missing + } + + if event.AgentContext.AgentName != "" && m.usageState.rootAgentName == "" { // capture root agent name from first event + m.usageState.rootAgentName = event.AgentContext.AgentName // remember orchestrator name to identify later events + } + + if event.SessionID != "" { // update currently active session ID + m.usageState.activeSessionID = event.SessionID // track active session for totals/highlighting + } + + if event.SelfUsage != nil && event.SessionID != "" { // store self snapshot per session + m.usageState.sessions[event.SessionID] = cloneUsage(event.SelfUsage) // clone to avoid aliasing runtime memory + } + + if event.AgentContext.AgentName != "" && event.SessionID != "" { // map session ID to agent name for breakdown rows + m.usageState.sessionAgents[event.SessionID] = event.AgentContext.AgentName // remember descriptive label for later rendering + } + + if event.AgentContext.AgentName == m.usageState.rootAgentName && event.InclusiveUsage != nil { // update root inclusive snapshot when orchestrator reports + m.usageState.rootInclusive = cloneUsage(event.InclusiveUsage) // persist inclusive totals for team view + if event.SessionID != "" { // also note root session ID for comparisons + m.usageState.rootSessionID = event.SessionID // record root session identifier + } + } } func (m *model) SetTodos(toolCall tools.ToolCall) error { @@ -72,23 +120,9 @@ func (m *model) SetWorking(working bool) tea.Cmd { return nil } -// SetMCPInitializing toggles the MCP initialization spinner state -func (m *model) SetMCPInitializing(initializing bool) tea.Cmd { - m.mcpInit = initializing - if initializing { - return m.spinner.Tick - } - return nil -} - -// formatTokenCount formats a token count with K/M suffixes for readability +// formatTokenCount formats a token count with grouping separators for readability func formatTokenCount(count int) string { - if count >= 1000000 { - return fmt.Sprintf("%.1fM", float64(count)/1000000) - } else if count >= 1000 { - return fmt.Sprintf("%.1fK", float64(count)/1000) - } - return fmt.Sprintf("%d", count) + return humanize.Comma(int64(count)) } // getCurrentWorkingDirectory returns the current working directory with home directory replaced by ~/ @@ -107,100 +141,119 @@ func getCurrentWorkingDirectory() string { } // Update handles messages and updates the component state -func (m *model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (m *model) Update(msg tea.Msg) (layout.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: cmd := m.SetSize(msg.Width, msg.Height) return m, cmd + case *runtime.MCPInitStartedEvent: + m.mcpInit = true + return m, m.spinner.Tick + case *runtime.MCPInitFinishedEvent: + m.mcpInit = false + return m, nil + case *runtime.SessionTitleEvent: + m.sessionTitle = msg.Title + return m, nil default: - // Update spinner when working or initializing MCP if m.working || m.mcpInit { var cmd tea.Cmd m.spinner, cmd = m.spinner.Update(msg) return m, cmd } + return m, nil } - - return m, nil } // View renders the component func (m *model) View() string { - // Calculate token usage metrics - totalTokens := m.usage.InputTokens + m.usage.OutputTokens - var usagePercent float64 - if m.usage.ContextLimit > 0 { - usagePercent = (float64(m.usage.ContextLength) / float64(m.usage.ContextLimit)) * 100 + if m.mode == ModeVertical { + return m.verticalView() } - // Use predefined styles for the usage display + return m.horizontalView() +} - // Build top content (title + pwd + token usage) - topContent := "" +func (m *model) horizontalView() string { + pwd := getCurrentWorkingDirectory() + gapWidth := m.width - lipgloss.Width(pwd) - lipgloss.Width(m.tokenUsage()) - 2 + title := m.sessionTitle + " " + m.workingIndicator() + return lipgloss.JoinVertical(lipgloss.Top, title, fmt.Sprintf("%s%*s%s", styles.MutedStyle.Render(pwd), gapWidth, "", m.tokenUsage())) +} + +func (m *model) verticalView() string { + topContent := m.sessionTitle - // Add current working directory in grey if pwd := getCurrentWorkingDirectory(); pwd != "" { topContent += styles.MutedStyle.Render(pwd) + "\n\n" } - // Format each part with its respective color - percentageText := styles.MutedStyle.Render(fmt.Sprintf("%.0f%%", usagePercent)) - totalTokensText := styles.SubtleStyle.Render(fmt.Sprintf("(%s)", formatTokenCount(totalTokens))) - costText := styles.MutedStyle.Render(fmt.Sprintf("$%.2f", m.usage.Cost)) + topContent += m.tokenUsage() + topContent += "\n" + m.workingIndicator() + + m.todoComp.SetSize(m.width) + todoContent := strings.TrimSuffix(m.todoComp.Render(), "\n") + + // Calculate available height for content + availableHeight := m.height - 2 // Account for borders + topHeight := strings.Count(topContent, "\n") + 1 + todoHeight := strings.Count(todoContent, "\n") + 1 + + // Calculate padding needed to push todos to bottom + paddingHeight := max(availableHeight-topHeight-todoHeight, 0) + for range paddingHeight { + topContent += "\n" + } + topContent += todoContent + + return styles.BaseStyle. + Width(m.width). + Height(m.height-2). + Align(lipgloss.Left, lipgloss.Top). + Render(topContent) +} - topContent += fmt.Sprintf("%s %s %s", percentageText, totalTokensText, costText) - // Add working/initializing indicator if active +func (m *model) workingIndicator() string { if m.mcpInit || m.working { label := "Working..." if m.mcpInit { label = "Initializing MCP servers..." } - indicator := styles.ActiveStyle.Render(m.spinner.View() + " " + label) - topContent += "\n" + indicator + indicator := styles.ActiveStyle.Render(m.spinner.View() + label) + return indicator } - // Get todo content (if any) - m.todoComp.SetSize(m.width) - todoContent := m.todoComp.Render() - - // If we have todos, create a layout with todos at the bottom - if todoContent != "" { - // Remove trailing newline from todoContent if present - todoContent = strings.TrimSuffix(todoContent, "\n") - - // Calculate available height for content - availableHeight := m.height - 2 // Account for borders - topHeight := strings.Count(topContent, "\n") + 1 - todoHeight := strings.Count(todoContent, "\n") + 1 - - // Calculate padding needed to push todos to bottom - paddingHeight := availableHeight - topHeight - todoHeight - if paddingHeight < 0 { - paddingHeight = 0 - } + return "" +} - // Build final content with padding - finalContent := topContent - for range paddingHeight { - finalContent += "\n" - } - finalContent += todoContent +func (m *model) tokenUsage() string { // renders aggregate usage summary line + breakdown + label, totals := m.renderTotals() // get friendly label plus computed totals + totalTokens := totals.InputTokens + totals.OutputTokens // sum user + assistant tokens for display - sidebarStyle := styles.BaseStyle. - Width(m.width). - Height(m.height-2). - Align(lipgloss.Left, lipgloss.Top) + // var usagePercent float64 + // if totals.ContextLimit > 0 { + // usagePercent = (float64(totals.ContextLength) / float64(totals.ContextLimit)) * 100 + // } + // percentageText := styles.MutedStyle.Render(fmt.Sprintf("%.0f%%", usagePercent)) - return sidebarStyle.Render(finalContent) + var builder strings.Builder // assemble multiline output + builder.WriteString(styles.SubtleStyle.Render("TOTAL USAGE")) // heading for total usage + if label != "" { // append contextual label when available + builder.WriteString(fmt.Sprintf(" (%s)", label)) // show whether totals are team/session scoped + } + builder.WriteString(fmt.Sprintf("\n Tokens: %s | Cost: $%.2f\n", formatTokenCount(totalTokens), totals.Cost)) // display totals line + builder.WriteString("--------------------------------\n") // visual separator + builder.WriteString(styles.SubtleStyle.Render("SESSION BREAKDOWN")) // heading for per-session details + + breakdown := m.sessionBreakdownLines() // fetch breakdown blocks + if len(breakdown) > 0 { // append breakdown when data available + builder.WriteString("\n") // ensure newline before blocks + builder.WriteString(strings.Join(breakdown, "\n\n")) // place blank line between blocks } else { - // No todos, just render top content normally - sidebarStyle := styles.BaseStyle. - Width(m.width). - Height(m.height-2). - Align(lipgloss.Left, lipgloss.Top) - - return sidebarStyle.Render(topContent) + builder.WriteString("\n No session usage yet") // fallback text when no sessions reported } + + return builder.String() // return composed view } // SetSize sets the dimensions of the component @@ -215,3 +268,173 @@ func (m *model) SetSize(width, height int) tea.Cmd { func (m *model) GetSize() (width, height int) { return m.width, m.height } + +func (m *model) SetMode(mode Mode) { + m.mode = mode +} + +func cloneUsage(u *runtime.Usage) *runtime.Usage { // helper to copy runtime usage structs safely + if u == nil { // avoid panics on nil usage snapshots + return nil // nothing to clone when nil + } + clone := *u // copy by value to detach from original pointer + return &clone // return pointer to independent copy +} + +func (m *model) renderTotals() (string, *runtime.Usage) { // resolves label + totals for display + totals := m.computeTeamTotals() // compute aggregate usage first + if totals == nil { // ensure downstream code always receives a struct + totals = &runtime.Usage{} // fall back to zero snapshot + } + + label := "Session Total" // default label when only one session present + if m.usageState.rootInclusive != nil { // when root inclusive exists we can show team wording + label = "Team Total" // highlight that totals represent the whole team + if m.usageState.activeSessionID != "" && m.usageState.activeSessionID != m.usageState.rootSessionID { // active child contributes live usage + label = "Team Total (incl. active child)" // clarify that active child is included + } + } + + return label, totals // return computed label with totals +} + +func (m *model) computeTeamTotals() *runtime.Usage { // derives aggregate totals for the team line + base := cloneUsage(m.usageState.rootInclusive) // start with root inclusive snapshot, if any + active := m.currentSessionUsage() // get self usage for currently active session + + if base == nil { // when root has not reported yet + return cloneUsage(active) // either return active session usage or nil + } + + if active != nil && m.usageState.activeSessionID != "" && m.usageState.activeSessionID != m.usageState.rootSessionID { // only add active child when it differs from root session + base = mergeUsageTotals(base, active) // merge child self usage into inclusive total for live view + } + + return base // return computed totals (may still be nil if nothing reported) +} + +func (m *model) currentSessionUsage() *runtime.Usage { // fetches usage snapshot for active session + if m.usageState.activeSessionID == "" { // when no active session tracked + return nil // nothing to return + } + return m.usageState.sessions[m.usageState.activeSessionID] // look up snapshot in map (may be nil) +} + +func mergeUsageTotals(base, delta *runtime.Usage) *runtime.Usage { // adds token/cost fields from delta into base + if base == nil { // handle nil base by cloning delta + return cloneUsage(delta) // ensure caller gets independent struct + } + if delta == nil { // nothing to add if delta missing + return base // return base unchanged + } + base.InputTokens += delta.InputTokens // accumulate input tokens + base.OutputTokens += delta.OutputTokens // accumulate output tokens + base.ContextLength += delta.ContextLength // accumulate context length for completeness + if delta.ContextLimit > base.ContextLimit { // prefer higher limit to avoid regressions + base.ContextLimit = delta.ContextLimit // update context limit when child limit is larger + } + base.Cost += delta.Cost // accumulate cost for overall spend + return base // return augmented total +} + +func (m *model) sessionBreakdownLines() []string { // renders per-session self usage rows + if len(m.usageState.sessions) == 0 { // nothing to render when map empty + return nil // keep caller logic simple + } + + ids := make([]string, 0, len(m.usageState.sessions)) // gather session IDs for deterministic ordering + for id := range m.usageState.sessions { // iterate known sessions + ids = append(ids, id) // record id for sorting + } + sort.Strings(ids) // ensure stable ordering regardless of map iteration + + lines := make([]string, 0, len(ids)+1) // include space for root block + + if rootBlock := m.rootSessionBlock(); rootBlock != "" { // prepend root block when available + lines = append(lines, rootBlock) + } + + for _, id := range ids { // build block for each session + if id == m.usageState.rootSessionID { // skip root session since totals already shown above + continue + } + usage := m.usageState.sessions[id] // fetch stored snapshot + if usage == nil { // skip if snapshot missing + continue // nothing to render for this id + } + agentName := m.usageState.sessionAgents[id] // resolve display name + if agentName == "" { // fallback when agent name unknown + agentName = id // show session ID as identifier + } + + if block := formatSessionBlock(agentName, usage, id == m.usageState.activeSessionID); block != "" { // compose + style block + lines = append(lines, block) // add block to breakdown list + } + } + + return lines // return composed rows +} + +func (m *model) rootSessionBlock() string { // formats root agent entry with exclusive usage + exclusive := m.computeRootExclusiveUsage() // derive exclusive self usage + if exclusive == nil { + return "" + } + + name := m.usageState.rootAgentName // prefer configured agent name + if name == "" { + name = "Root" + } + + return formatSessionBlock(name, exclusive, m.usageState.activeSessionID == m.usageState.rootSessionID) +} + +func (m *model) computeRootExclusiveUsage() *runtime.Usage { // subtracts child usage from root inclusive totals + if m.usageState.rootInclusive == nil { + return nil + } + + exclusive := cloneUsage(m.usageState.rootInclusive) // operate on a copy + for id, usage := range m.usageState.sessions { + if id == m.usageState.rootSessionID || usage == nil { + continue // skip root entry and nil snapshots + } + exclusive = subtractUsage(exclusive, usage) // remove child contribution + } + + return exclusive +} + +func subtractUsage(base, delta *runtime.Usage) *runtime.Usage { // subtracts usage safely + if base == nil || delta == nil { + return base + } + + base.InputTokens -= delta.InputTokens + if base.InputTokens < 0 { + base.InputTokens = 0 + } + base.OutputTokens -= delta.OutputTokens + if base.OutputTokens < 0 { + base.OutputTokens = 0 + } + base.ContextLength = base.InputTokens + base.OutputTokens + base.Cost -= delta.Cost + if base.Cost < 0 { + base.Cost = 0 + } + + return base +} + +func formatSessionBlock(agentName string, usage *runtime.Usage, isActive bool) string { // helper to render a single block + if usage == nil { + return "" + } + + block := fmt.Sprintf(" %s\n Tokens: %s | Cost: $%.2f", agentName, formatTokenCount(usage.InputTokens+usage.OutputTokens), usage.Cost) + if isActive { + return styles.ActiveStyle.Render(block) + } + return block +} diff --git a/pkg/tui/components/statusbar/statusbar.go b/pkg/tui/components/statusbar/statusbar.go index ca92f8c4d..5e0fc0fb5 100644 --- a/pkg/tui/components/statusbar/statusbar.go +++ b/pkg/tui/components/statusbar/statusbar.go @@ -3,8 +3,8 @@ package statusbar import ( "strings" - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/bubbles/v2/key" + "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/styles" @@ -50,7 +50,7 @@ func (s *StatusBar) formatHelpString(bindings []key.Binding) string { // View renders the status bar func (s *StatusBar) View() string { - versionText := styles.MutedStyle.Render(version.Version) + versionText := styles.MutedStyle.Render("cagent " + version.Version) var helpText string if s.help != nil { diff --git a/pkg/tui/components/todo/todo.go b/pkg/tui/components/todo/todo.go index 79cd80cb0..f53ff46d6 100644 --- a/pkg/tui/components/todo/todo.go +++ b/pkg/tui/components/todo/todo.go @@ -5,7 +5,7 @@ import ( "fmt" "strings" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/tools" "github.com/docker/cagent/pkg/tools/builtin" @@ -42,7 +42,7 @@ func (c *Component) SetTodos(toolCall tools.ToolCall) error { toolName := toolCall.Function.Name arguments := toolCall.Function.Arguments switch toolName { - case "create_todo": + case builtin.ToolNameCreateTodo: var params builtin.CreateTodoArgs if err := json.Unmarshal([]byte(arguments), ¶ms); err != nil { return err @@ -56,7 +56,7 @@ func (c *Component) SetTodos(toolCall tools.ToolCall) error { } c.todos = append(c.todos, newTodo) - case "create_todos": + case builtin.ToolNameCreateTodos: var params builtin.CreateTodosArgs if err := json.Unmarshal([]byte(arguments), ¶ms); err != nil { return err @@ -72,7 +72,7 @@ func (c *Component) SetTodos(toolCall tools.ToolCall) error { c.todos = append(c.todos, newTodo) } - case "update_todo": + case builtin.ToolNameUpdateTodo: var params builtin.UpdateTodoArgs if err := json.Unmarshal([]byte(arguments), ¶ms); err != nil { return err diff --git a/pkg/tui/components/tool/builtins.go b/pkg/tui/components/tool/builtins.go index af06083e8..f5e9176af 100644 --- a/pkg/tui/components/tool/builtins.go +++ b/pkg/tui/components/tool/builtins.go @@ -10,7 +10,7 @@ import ( "github.com/docker/cagent/pkg/tui/styles" ) -func renderEditFile(toolCall tools.ToolCall, width int) (string, string) { +func renderEditFile(toolCall tools.ToolCall, width int, splitView bool) (string, string) { var args builtin.EditFileArgs if err := json.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil { return "", "" @@ -26,8 +26,12 @@ func renderEditFile(toolCall tools.ToolCall, width int) (string, string) { output.WriteString("Edit #" + string(rune(i+1+'0')) + ":\n") } - diff := computeDiff(edit.OldText, edit.NewText) - output.WriteString(renderDiffWithSyntaxHighlight(diff, args.Path, width)) + diff := computeDiff(args.Path, edit.OldText, edit.NewText) + if splitView { + output.WriteString(renderSplitDiffWithSyntaxHighlight(diff, args.Path, width)) + } else { + output.WriteString(renderDiffWithSyntaxHighlight(diff, args.Path, width)) + } } return output.String(), args.Path diff --git a/pkg/tui/components/tool/diff.go b/pkg/tui/components/tool/diff.go index f82cfa0e1..533fd8cd8 100644 --- a/pkg/tui/components/tool/diff.go +++ b/pkg/tui/components/tool/diff.go @@ -1,16 +1,60 @@ package tool import ( + "os" + "strings" + "github.com/aymanbagabas/go-udiff" ) -func computeDiff(oldText, newText string) []*udiff.Hunk { - edits := udiff.Strings(oldText, newText) +func computeDiff(path, oldText, newText string) []*udiff.Hunk { + currentContent, err := os.ReadFile(path) + if err != nil { + return []*udiff.Hunk{} + } - diff, err := udiff.ToUnifiedDiff("old", "new", oldText, edits, 3) + // Generate the old contents by applying inverse diff, the current file has + // newText applied, so we need to reverse it + oldContent := strings.Replace(string(currentContent), newText, oldText, 1) + + // Now compute diff between old (reconstructed) and new (complete file) + edits := udiff.Strings(oldContent, string(currentContent)) + + diff, err := udiff.ToUnifiedDiff("old", "new", oldContent, edits, 3) if err != nil { return []*udiff.Hunk{} } - return diff.Hunks + return normalizeDiff(diff.Hunks) +} + +func normalizeDiff(diff []*udiff.Hunk) []*udiff.Hunk { + for _, hunk := range diff { + if len(hunk.Lines) == 0 { + continue + } + + normalized := make([]udiff.Line, 0, len(hunk.Lines)) + for i := 0; i < len(hunk.Lines); i++ { + line := hunk.Lines[i] + + if line.Kind == udiff.Delete && i+1 < len(hunk.Lines) { + next := hunk.Lines[i+1] + if next.Kind == udiff.Insert && line.Content == next.Content { + normalized = append(normalized, udiff.Line{ + Kind: udiff.Equal, + Content: line.Content, + }) + i++ + continue + } + } + + normalized = append(normalized, line) + } + + hunk.Lines = normalized + } + + return diff } diff --git a/pkg/tui/components/tool/syntax.go b/pkg/tui/components/tool/syntax.go index 6331f48dc..878bc7251 100644 --- a/pkg/tui/components/tool/syntax.go +++ b/pkg/tui/components/tool/syntax.go @@ -1,17 +1,37 @@ package tool import ( + "fmt" "strings" + "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2" "github.com/alecthomas/chroma/v2/lexers" "github.com/aymanbagabas/go-udiff" - "github.com/charmbracelet/lipgloss/v2" + "github.com/charmbracelet/x/ansi" "github.com/mattn/go-runewidth" "github.com/docker/cagent/pkg/tui/styles" ) +const ( + tabWidth = 4 + lineNumWidth = 5 + minWidth = 80 +) + +type chromaToken struct { + Text string + Style lipgloss.Style +} + +type linePair struct { + old *udiff.Line + new *udiff.Line + oldLineNum int + newLineNum int +} + // syntaxHighlight applies syntax highlighting to code and returns styled text func syntaxHighlight(code, filePath string) []chromaToken { lexer := lexers.Match(filePath) @@ -31,22 +51,15 @@ func syntaxHighlight(code, filePath string) []chromaToken { if token.Value == "" { continue } - - lipStyle := chromaToLipgloss(token.Type, style) tokens = append(tokens, chromaToken{ Text: token.Value, - Style: lipStyle, + Style: chromaToLipgloss(token.Type, style), }) } return tokens } -type chromaToken struct { - Text string - Style lipgloss.Style -} - func chromaToLipgloss(tokenType chroma.TokenType, style *chroma.Style) lipgloss.Style { entry := style.Get(tokenType) lipStyle := lipgloss.NewStyle() @@ -54,19 +67,12 @@ func chromaToLipgloss(tokenType chroma.TokenType, style *chroma.Style) lipgloss. if entry.Colour.IsSet() { lipStyle = lipStyle.Foreground(lipgloss.Color(entry.Colour.String())) } - - if entry.Background.IsSet() { - lipStyle = lipStyle.Background(lipgloss.Color(entry.Background.String())) - } - if entry.Bold == chroma.Yes { lipStyle = lipStyle.Bold(true) } - if entry.Italic == chroma.Yes { lipStyle = lipStyle.Italic(true) } - if entry.Underline == chroma.Yes { lipStyle = lipStyle.Underline(true) } @@ -74,73 +80,214 @@ func chromaToLipgloss(tokenType chroma.TokenType, style *chroma.Style) lipgloss. return lipStyle } +// renderDiffWithSyntaxHighlight renders a unified diff view func renderDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, width int) string { - fullWidth := min(120, width) - const tabWidth = 4 var output strings.Builder + contentWidth := width - lineNumWidth for _, hunk := range diff { + oldLineNum := hunk.FromLine + newLineNum := hunk.ToLine + for _, line := range hunk.Lines { - var lineStyle lipgloss.Style - - switch line.Kind { - case udiff.Delete: - lineStyle = styles.DiffRemoveStyle - case udiff.Insert: - lineStyle = styles.DiffAddStyle - case udiff.Equal: - lineStyle = styles.DiffUnchangedStyle - } + lineNum := getDisplayLineNumber(&line, &oldLineNum, &newLineNum) + content := prepareContent(line.Content, contentWidth) - expandedContent := strings.ReplaceAll(line.Content, "\t", strings.Repeat(" ", tabWidth)) - expandedContent = strings.TrimRight(expandedContent, "\n") + lineNumStr := styles.LineNumberStyle.Render(fmt.Sprintf("%4d ", lineNum)) + styledLine := renderLine(content, line.Kind, filePath, contentWidth) - contentWidth := runewidth.StringWidth(expandedContent) - if contentWidth > fullWidth { - expandedContent = runewidth.Truncate(expandedContent, fullWidth-3, "...") - } + output.WriteString(lineNumStr + styledLine + "\n") + } + } - tokens := syntaxHighlight(expandedContent, filePath) + return strings.TrimSuffix(output.String(), "\n") +} - var lineBuilder strings.Builder +// renderSplitDiffWithSyntaxHighlight renders a split diff view with old/new side-by-side +func renderSplitDiffWithSyntaxHighlight(diff []*udiff.Hunk, filePath string, width int) string { + // Fall back to unified diff if terminal is too narrow + separator := styles.SeparatorStyle.Render(" │ ") + separatorWidth := ansi.StringWidth(separator) + contentWidth := (width - separatorWidth - (lineNumWidth * 2)) / 2 - contentLength := 0 - for i := range tokens { - contentLength += runewidth.StringWidth(tokens[i].Text) - } + if width < minWidth || contentWidth < 10 { + return renderDiffWithSyntaxHighlight(diff, filePath, width) + } - paddingNeeded := 0 - if contentLength < fullWidth { - paddingNeeded = fullWidth - contentLength - } + var output strings.Builder + + for _, hunk := range diff { + for _, pair := range pairDiffLines(hunk.Lines, hunk.FromLine, hunk.ToLine) { + leftSide := renderSplitSide(pair.old, pair.oldLineNum, filePath, contentWidth) + rightSide := renderSplitSide(pair.new, pair.newLineNum, filePath, contentWidth) + + line := leftSide + separator + rightSide + line = ensureWidth(line, width) + + output.WriteString(line + "\n") + } + } + + return strings.TrimSuffix(output.String(), "\n") +} + +// getDisplayLineNumber returns the appropriate line number and updates counters +func getDisplayLineNumber(line *udiff.Line, oldLineNum, newLineNum *int) int { + switch line.Kind { + case udiff.Delete: + num := *oldLineNum + *oldLineNum++ + return num + case udiff.Insert: + num := *newLineNum + *newLineNum++ + return num + case udiff.Equal: + num := *oldLineNum + *oldLineNum++ + *newLineNum++ + return num + } + return 0 +} + +// prepareContent normalizes content for display +func prepareContent(content string, maxWidth int) string { + content = strings.ReplaceAll(content, "\t", strings.Repeat(" ", tabWidth)) + content = strings.TrimRight(content, "\n") + if runewidth.StringWidth(content) > maxWidth { + content = runewidth.Truncate(content, maxWidth-3, "...") + } + return content +} + +// renderLine renders a line with syntax highlighting and appropriate styling +func renderLine(content string, kind udiff.OpKind, filePath string, width int) string { + tokens := syntaxHighlight(content, filePath) + lineStyle := getLineStyle(kind) + + rendered := renderTokensWithStyle(tokens, lineStyle) + + return padToWidth(rendered, width, lineStyle) +} + +// renderSplitSide renders one side of a split diff +func renderSplitSide(line *udiff.Line, lineNum int, filePath string, width int) string { + lineNumStr := formatLineNum(line, lineNum) + + if line == nil { + emptySpace := styles.DiffUnchangedStyle.Render(strings.Repeat(" ", width)) + return styles.LineNumberStyle.Render(lineNumStr) + emptySpace + } + + content := prepareContent(line.Content, width) + styledContent := renderLine(content, line.Kind, filePath, width) + + return styles.LineNumberStyle.Render(lineNumStr) + styledContent +} + +// renderTokensWithStyle applies consistent styling to tokens +func renderTokensWithStyle(tokens []chromaToken, lineStyle lipgloss.Style) string { + var output strings.Builder + + for _, token := range tokens { + styledToken := token.Style.Background(lineStyle.GetBackground()) + output.WriteString(styledToken.Render(token.Text)) + } + + return output.String() +} - if line.Kind != udiff.Equal { - var contentBuilder strings.Builder - for i := range tokens { - token := &tokens[i] - styledToken := token.Style.Background(lineStyle.GetBackground()) - contentBuilder.WriteString(styledToken.Render(token.Text)) - } +// padToWidth adds padding to reach the desired width +func padToWidth(content string, width int, style lipgloss.Style) string { + currentWidth := ansi.StringWidth(content) + if paddingNeeded := width - currentWidth; paddingNeeded > 0 { + padding := strings.Repeat(" ", paddingNeeded) + return content + style.Render(padding) + } + return content +} - if paddingNeeded > 0 { - contentBuilder.WriteString(lineStyle.Render(strings.Repeat(" ", paddingNeeded))) - } +// ensureWidth ensures a line has consistent width +func ensureWidth(line string, width int) string { + if lineWidth := ansi.StringWidth(line); lineWidth < width { + padding := styles.DiffUnchangedStyle.Render(strings.Repeat(" ", width-lineWidth)) + return line + padding + } + return line +} - lineBuilder.WriteString(contentBuilder.String()) +// getLineStyle returns the style for a diff line type +func getLineStyle(kind udiff.OpKind) lipgloss.Style { + switch kind { + case udiff.Delete: + return styles.DiffRemoveStyle + case udiff.Insert: + return styles.DiffAddStyle + default: + return styles.DiffUnchangedStyle + } +} + +// formatLineNum formats a line number or returns empty space +func formatLineNum(line *udiff.Line, lineNum int) string { + if line == nil { + return strings.Repeat(" ", lineNumWidth) + } + return fmt.Sprintf("%4d ", lineNum) +} + +// pairDiffLines pairs old and new lines for split view rendering +func pairDiffLines(lines []udiff.Line, fromLine, toLine int) []linePair { + var pairs []linePair + oldLineNum, newLineNum := fromLine, toLine + + for i := 0; i < len(lines); i++ { + line := &lines[i] + + switch line.Kind { + case udiff.Equal: + pairs = append(pairs, linePair{ + old: line, + new: line, + oldLineNum: oldLineNum, + newLineNum: newLineNum, + }) + oldLineNum++ + newLineNum++ + + case udiff.Delete: + // Check if next line is an insert to pair them + if i+1 < len(lines) && lines[i+1].Kind == udiff.Insert { + pairs = append(pairs, linePair{ + old: line, + new: &lines[i+1], + oldLineNum: oldLineNum, + newLineNum: newLineNum, + }) + oldLineNum++ + newLineNum++ + i++ // Skip the paired insert } else { - for i := range tokens { - token := &tokens[i] - lineBuilder.WriteString(token.Style.Render(token.Text)) - } - if paddingNeeded > 0 { - lineBuilder.WriteString(strings.Repeat(" ", paddingNeeded)) - } + // Unpaired delete + pairs = append(pairs, linePair{ + old: line, + new: nil, + oldLineNum: oldLineNum, + }) + oldLineNum++ } - output.WriteString(lineBuilder.String()) - output.WriteString("\n") + case udiff.Insert: + // Unpaired insert (paired inserts are handled above) + pairs = append(pairs, linePair{ + old: nil, + new: line, + newLineNum: newLineNum, + }) + newLineNum++ } } - return strings.TrimSuffix(output.String(), "\n") + return pairs } diff --git a/pkg/tui/components/tool/tool.go b/pkg/tui/components/tool/tool.go index 1b502bcd0..756b97a85 100644 --- a/pkg/tui/components/tool/tool.go +++ b/pkg/tui/components/tool/tool.go @@ -5,16 +5,19 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/v2/spinner" - tea "github.com/charmbracelet/bubbletea/v2" + "charm.land/bubbles/v2/spinner" + tea "charm.land/bubbletea/v2" "github.com/charmbracelet/glamour/v2" "github.com/docker/cagent/pkg/app" + "github.com/docker/cagent/pkg/tools/builtin" "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/styles" "github.com/docker/cagent/pkg/tui/types" ) +type ToggleDiffViewMsg struct{} + // toolModel implements Model type toolModel struct { message *types.Message @@ -26,6 +29,8 @@ type toolModel struct { height int app *app.App + + splitDiffView bool } // SetSize implements Model. @@ -36,20 +41,21 @@ func (mv *toolModel) SetSize(width, height int) tea.Cmd { } // New creates a new tool view -func New(msg *types.Message, a *app.App, renderer *glamour.TermRenderer) layout.Model { - if msg.ToolCall.Function.Name == "transfer_task" { +func New(msg *types.Message, a *app.App, renderer *glamour.TermRenderer, splitDiffView bool) layout.Model { + if msg.ToolCall.Function.Name == builtin.ToolNameTransferTask { return &transferTaskModel{ msg: msg, } } return &toolModel{ - message: msg, - width: 80, - height: 1, - spinner: spinner.New(spinner.WithSpinner(spinner.Points)), - renderer: renderer, - app: a, + message: msg, + width: 80, + height: 1, + spinner: spinner.New(spinner.WithSpinner(spinner.Points)), + renderer: renderer, + app: a, + splitDiffView: splitDiffView, } } @@ -71,9 +77,12 @@ func (mv *toolModel) Init() tea.Cmd { return nil } -// Update handles messages and updates the message view state -func (mv *toolModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { - // Handle spinner updates for empty assistant messages or pending/running tools +func (mv *toolModel) Update(msg tea.Msg) (layout.Model, tea.Cmd) { + if _, ok := msg.(ToggleDiffViewMsg); ok { + mv.splitDiffView = !mv.splitDiffView + return mv, nil + } + switch mv.message.Type { case types.MessageTypeAssistant: if mv.message.Content == "" { @@ -94,20 +103,17 @@ func (mv *toolModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (mv *toolModel) View() string { msg := mv.message - displayName := msg.ToolDefinition.DisplayName() - content := fmt.Sprintf("%s %s", icon(msg.ToolStatus), styles.HighlightStyle.Render(displayName)) - // Add spinner for pending and running tools if msg.ToolStatus == types.ToolStatusPending || msg.ToolStatus == types.ToolStatusRunning { content += " " + mv.spinner.View() } if msg.ToolCall.Function.Arguments != "" { switch msg.ToolCall.Function.Name { - case "edit_file": - diff, path := renderEditFile(msg.ToolCall, mv.width-4) + case builtin.ToolNameEditFile: + diff, path := renderEditFile(msg.ToolCall, mv.width-4, mv.splitDiffView) if diff != "" { var editFile string editFile += styles.ToolCallArgKey.Render("path:") @@ -120,7 +126,6 @@ func (mv *toolModel) View() string { } } - // Add tool result content if available (for completed tools with content) var resultContent string if (msg.ToolStatus == types.ToolStatusCompleted || msg.ToolStatus == types.ToolStatusError) && msg.Content != "" { var content string @@ -140,7 +145,6 @@ func (mv *toolModel) View() string { // Wrap long lines to fit the component width lines := wrapLines(content, availableWidth) - // Take only first 10 lines after wrapping header := "output" if len(lines) > 10 { lines = lines[:10] diff --git a/pkg/tui/components/tool/transfer_task.go b/pkg/tui/components/tool/transfer_task.go index 1c51a8692..7e508d1a5 100644 --- a/pkg/tui/components/tool/transfer_task.go +++ b/pkg/tui/components/tool/transfer_task.go @@ -3,8 +3,9 @@ package tool import ( "encoding/json" - tea "github.com/charmbracelet/bubbletea/v2" + tea "charm.land/bubbletea/v2" + "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/styles" "github.com/docker/cagent/pkg/tui/types" ) @@ -21,7 +22,7 @@ func (m *transferTaskModel) SetSize(_, _ int) tea.Cmd { return nil } -func (m *transferTaskModel) Update(tea.Msg) (tea.Model, tea.Cmd) { +func (m *transferTaskModel) Update(tea.Msg) (layout.Model, tea.Cmd) { return m, nil } diff --git a/pkg/tui/core/core.go b/pkg/tui/core/core.go index 675a60075..d2083997d 100644 --- a/pkg/tui/core/core.go +++ b/pkg/tui/core/core.go @@ -1,9 +1,9 @@ package core import ( - "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" ) // KeyMapHelp interface for components that provide help diff --git a/pkg/tui/core/layout/layout.go b/pkg/tui/core/layout/layout.go index dcb909a81..5c1245566 100644 --- a/pkg/tui/core/layout/layout.go +++ b/pkg/tui/core/layout/layout.go @@ -1,9 +1,9 @@ package layout import ( - "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" ) // Sizeable represents components that can be resized @@ -17,6 +17,10 @@ type Focusable interface { Blur() tea.Cmd } +type Positionable interface { + SetPosition(x, y int) tea.Cmd +} + // Help represents components that provide help information type Help interface { Bindings() []key.Binding @@ -25,7 +29,8 @@ type Help interface { // Model is the base interface for all TUI models type Model interface { - tea.Model - tea.ViewModel + Init() tea.Cmd + Update(tea.Msg) (Model, tea.Cmd) + View() string Sizeable } diff --git a/pkg/tui/dialog/command_palette.go b/pkg/tui/dialog/command_palette.go index d10bd52b7..95d725e7c 100644 --- a/pkg/tui/dialog/command_palette.go +++ b/pkg/tui/dialog/command_palette.go @@ -3,13 +3,14 @@ package dialog import ( "strings" - "github.com/charmbracelet/bubbles/v2/key" - "github.com/charmbracelet/bubbles/v2/textinput" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/bubbles/v2/key" + "charm.land/bubbles/v2/textinput" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/tui/commands" "github.com/docker/cagent/pkg/tui/core" + "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/styles" ) @@ -87,7 +88,7 @@ func (d *commandPaletteDialog) Init() tea.Cmd { } // Update handles messages for the command palette dialog -func (d *commandPaletteDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (d *commandPaletteDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { diff --git a/pkg/tui/dialog/command_palette_test.go b/pkg/tui/dialog/command_palette_test.go index e9e672c47..c774e7749 100644 --- a/pkg/tui/dialog/command_palette_test.go +++ b/pkg/tui/dialog/command_palette_test.go @@ -3,7 +3,7 @@ package dialog import ( "testing" - tea "github.com/charmbracelet/bubbletea/v2" + tea "charm.land/bubbletea/v2" "github.com/stretchr/testify/require" "github.com/docker/cagent/pkg/tui/commands" diff --git a/pkg/tui/dialog/dialog.go b/pkg/tui/dialog/dialog.go index c8ba81762..2869be56c 100644 --- a/pkg/tui/dialog/dialog.go +++ b/pkg/tui/dialog/dialog.go @@ -1,8 +1,8 @@ package dialog import ( - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/tui/core/layout" ) @@ -26,7 +26,7 @@ type Dialog interface { // Manager manages the dialog stack and rendering type Manager interface { - tea.Model + layout.Model GetLayers() []*lipgloss.Layer Open() bool @@ -51,7 +51,7 @@ func (d *manager) Init() tea.Cmd { } // Update handles messages and updates dialog state -func (d *manager) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (d *manager) Update(msg tea.Msg) (layout.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: d.width = msg.Width @@ -61,9 +61,7 @@ func (d *manager) Update(msg tea.Msg) (tea.Model, tea.Cmd) { for i := range d.dialogStack { u, cmd := d.dialogStack[i].Update(msg) d.dialogStack[i] = u.(Dialog) - if cmd != nil { - cmds = append(cmds, cmd) - } + cmds = append(cmds, cmd) } return d, tea.Batch(cmds...) @@ -99,7 +97,7 @@ func (d *manager) View() string { } // handleOpen processes dialog opening requests and adds to stack -func (d *manager) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) { +func (d *manager) handleOpen(msg OpenDialogMsg) (layout.Model, tea.Cmd) { d.dialogStack = append(d.dialogStack, msg.Model) var cmds []tea.Cmd @@ -116,7 +114,7 @@ func (d *manager) handleOpen(msg OpenDialogMsg) (tea.Model, tea.Cmd) { } // handleClose processes dialog closing requests (pops top dialog from stack) -func (d *manager) handleClose() (tea.Model, tea.Cmd) { +func (d *manager) handleClose() (layout.Model, tea.Cmd) { if len(d.dialogStack) != 0 { d.dialogStack = d.dialogStack[:len(d.dialogStack)-1] } @@ -125,7 +123,7 @@ func (d *manager) handleClose() (tea.Model, tea.Cmd) { } // handleCloseAll closes all dialogs in the stack -func (d *manager) handleCloseAll() (tea.Model, tea.Cmd) { +func (d *manager) handleCloseAll() (layout.Model, tea.Cmd) { d.dialogStack = make([]Dialog, 0) return d, nil } @@ -135,6 +133,12 @@ func (d *manager) Open() bool { return len(d.dialogStack) > 0 } +func (d *manager) SetSize(width, height int) tea.Cmd { + d.width = width + d.height = height + return nil +} + // GetLayers returns lipgloss layers for rendering all dialogs in the stack // Dialogs are returned in order from bottom to top (index 0 is bottom-most) func (d *manager) GetLayers() []*lipgloss.Layer { diff --git a/pkg/tui/dialog/max_iterations.go b/pkg/tui/dialog/max_iterations.go index c5cf2c2d0..7de39491d 100644 --- a/pkg/tui/dialog/max_iterations.go +++ b/pkg/tui/dialog/max_iterations.go @@ -4,12 +4,14 @@ import ( "fmt" "strings" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/app" + "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/tui/core" + "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/styles" ) @@ -63,7 +65,7 @@ func (d *maxIterationsDialog) Init() tea.Cmd { } // Update handles messages for the max iterations confirmation dialog -func (d *maxIterationsDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (d *maxIterationsDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: d.width = msg.Width @@ -73,17 +75,10 @@ func (d *maxIterationsDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch { case key.Matches(msg, d.keyMap.Yes): - if d.app != nil { - d.app.Resume("approve") - } - return d, core.CmdHandler(CloseDialogMsg{}) + return d, tea.Sequence(core.CmdHandler(CloseDialogMsg{}), core.CmdHandler(RuntimeResumeMsg{Response: runtime.ResumeTypeApprove})) case key.Matches(msg, d.keyMap.No): - if d.app != nil { - d.app.Resume("reject") - } - return d, core.CmdHandler(CloseDialogMsg{}) + return d, tea.Sequence(core.CmdHandler(CloseDialogMsg{}), core.CmdHandler(RuntimeResumeMsg{Response: runtime.ResumeTypeReject})) } - if msg.String() == "ctrl+c" { return d, tea.Quit } diff --git a/pkg/tui/dialog/oauth_authorization.go b/pkg/tui/dialog/oauth_authorization.go index 88b197ed3..64a90a9f1 100644 --- a/pkg/tui/dialog/oauth_authorization.go +++ b/pkg/tui/dialog/oauth_authorization.go @@ -3,12 +3,13 @@ package dialog import ( "fmt" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/app" "github.com/docker/cagent/pkg/tui/core" + "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/styles" ) @@ -62,7 +63,7 @@ func (d *oauthAuthorizationDialog) Init() tea.Cmd { } // Update handles messages for the OAuth authorization confirmation dialog -func (d *oauthAuthorizationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (d *oauthAuthorizationDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: d.width = msg.Width diff --git a/pkg/tui/dialog/tool_confirmation.go b/pkg/tui/dialog/tool_confirmation.go index 2d927134e..6b196a70a 100644 --- a/pkg/tui/dialog/tool_confirmation.go +++ b/pkg/tui/dialog/tool_confirmation.go @@ -6,17 +6,25 @@ import ( "sort" "strings" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" - "github.com/docker/cagent/pkg/app" + "github.com/docker/cagent/pkg/runtime" "github.com/docker/cagent/pkg/tools" + "github.com/docker/cagent/pkg/tools/builtin" "github.com/docker/cagent/pkg/tui/components/todo" "github.com/docker/cagent/pkg/tui/core" + "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/styles" ) +type ( + RuntimeResumeMsg struct { + Response runtime.ResumeType + } +) + // ToolConfirmationResponse represents the user's response to tool confirmation type ToolConfirmationResponse struct { Response string // "approve", "reject", or "approve-session" @@ -26,7 +34,6 @@ type ToolConfirmationResponse struct { type toolConfirmationDialog struct { width, height int toolCall tools.ToolCall - app *app.App keyMap toolConfirmationKeyMap } @@ -63,10 +70,9 @@ func defaultToolConfirmationKeyMap() toolConfirmationKeyMap { } // NewToolConfirmationDialog creates a new tool confirmation dialog -func NewToolConfirmationDialog(toolCall tools.ToolCall, appInstance *app.App) Dialog { +func NewToolConfirmationDialog(toolCall tools.ToolCall) Dialog { return &toolConfirmationDialog{ toolCall: toolCall, - app: appInstance, keyMap: defaultToolConfirmationKeyMap(), } } @@ -77,7 +83,7 @@ func (d *toolConfirmationDialog) Init() tea.Cmd { } // Update handles messages for the tool confirmation dialog -func (d *toolConfirmationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (d *toolConfirmationDialog) Update(msg tea.Msg) (layout.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: d.width = msg.Width @@ -87,20 +93,11 @@ func (d *toolConfirmationDialog) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case tea.KeyPressMsg: switch { case key.Matches(msg, d.keyMap.Yes): - if d.app != nil { - d.app.Resume("approve") - } - return d, core.CmdHandler(CloseDialogMsg{}) + return d, tea.Sequence(core.CmdHandler(CloseDialogMsg{}), core.CmdHandler(RuntimeResumeMsg{Response: runtime.ResumeTypeApprove})) case key.Matches(msg, d.keyMap.No): - if d.app != nil { - d.app.Resume("reject") - } - return d, core.CmdHandler(CloseDialogMsg{}) + return d, tea.Sequence(core.CmdHandler(CloseDialogMsg{}), core.CmdHandler(RuntimeResumeMsg{Response: runtime.ResumeTypeReject})) case key.Matches(msg, d.keyMap.All): - if d.app != nil { - d.app.Resume("approve-session") - } - return d, core.CmdHandler(CloseDialogMsg{}) + return d, tea.Sequence(core.CmdHandler(CloseDialogMsg{}), core.CmdHandler(RuntimeResumeMsg{Response: runtime.ResumeTypeApproveSession})) } if msg.String() == "ctrl+c" { @@ -185,7 +182,7 @@ func (d *toolConfirmationDialog) View() string { // Arguments section var argumentsSection string - if d.toolCall.Function.Name == "create_todos" || d.toolCall.Function.Name == "create_todo" { + if d.toolCall.Function.Name == builtin.ToolNameCreateTodos || d.toolCall.Function.Name == builtin.ToolNameCreateTodo { argumentsSection = d.renderTodo(contentWidth) } else { argumentsSection = d.renderArguments(contentWidth) @@ -315,7 +312,7 @@ func (d *toolConfirmationDialog) Position() (row, col int) { } // Add height for todo preview section if todo-related tools - if d.toolCall.Function.Name == "create_todos" || d.toolCall.Function.Name == "create_todo" && d.toolCall.Function.Arguments != "" { + if d.toolCall.Function.Name == builtin.ToolNameCreateTodos || d.toolCall.Function.Name == builtin.ToolNameCreateTodo && d.toolCall.Function.Arguments != "" { // Add height for preview section header and content // Rough estimation: 2 lines for header + variable lines for todos dialogHeight += 6 diff --git a/pkg/tui/page/chat/chat.go b/pkg/tui/page/chat/chat.go index a0af33cd4..1b50d2148 100644 --- a/pkg/tui/page/chat/chat.go +++ b/pkg/tui/page/chat/chat.go @@ -6,11 +6,10 @@ import ( "os" "strings" - "github.com/atotto/clipboard" - "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" "github.com/docker/cagent/pkg/app" "github.com/docker/cagent/pkg/history" @@ -19,6 +18,7 @@ import ( "github.com/docker/cagent/pkg/tui/components/messages" "github.com/docker/cagent/pkg/tui/components/notification" "github.com/docker/cagent/pkg/tui/components/sidebar" + "github.com/docker/cagent/pkg/tui/components/tool" "github.com/docker/cagent/pkg/tui/core" "github.com/docker/cagent/pkg/tui/core/layout" "github.com/docker/cagent/pkg/tui/dialog" @@ -32,6 +32,10 @@ type FocusedPanel string const ( PanelChat FocusedPanel = "chat" PanelEditor FocusedPanel = "editor" + + sidebarWidth = 40 + // Hide sidebar if window width is less than this + minWindowWidth = 120 ) // Page represents the main chat page @@ -40,14 +44,12 @@ type Page interface { layout.Sizeable layout.Help CompactSession() tea.Cmd - CopySessionToClipboard() tea.Cmd Cleanup() } // chatPage implements Page type chatPage struct { width, height int - sessionTitle string // Components sidebar sidebar.Model @@ -63,8 +65,7 @@ type chatPage struct { // Key map keyMap KeyMap - title string - app *app.App + app *app.App history *history.History @@ -88,7 +89,7 @@ func defaultKeyMap() KeyMap { ), Cancel: key.NewBinding( key.WithKeys("esc"), - key.WithHelp("esc", "cancel stream"), + key.WithHelp("esc", "cancel"), ), } } @@ -101,7 +102,6 @@ func New(a *app.App) Page { } return &chatPage{ - title: a.Title(), sidebar: sidebar.New(), messages: messages.New(a), editor: editor.New(a, historyStore), @@ -114,26 +114,26 @@ func New(a *app.App) Page { // Init initializes the chat page func (p *chatPage) Init() tea.Cmd { - cmds := []tea.Cmd{ + var cmds []tea.Cmd + + // Add welcome message if present + welcomeMsg := p.app.CurrentWelcomeMessage(context.Background()) + if welcomeMsg != "" { + cmds = append(cmds, p.messages.AddWelcomeMessage(welcomeMsg)) + } + + cmds = append(cmds, p.sidebar.Init(), p.messages.Init(), p.editor.Init(), p.editor.Focus(), - } - - if firstMessage := p.app.FirstMessage(); firstMessage != nil { - cmds = append(cmds, func() tea.Msg { - return editor.SendMsg{ - Content: *firstMessage, - } - }) - } + ) return tea.Batch(cmds...) } // Update handles messages and updates the page state -func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { +func (p *chatPage) Update(msg tea.Msg) (layout.Model, tea.Cmd) { var cmds []tea.Cmd switch msg := msg.(type) { @@ -158,6 +158,12 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, tea.Batch(cmds...) case tea.KeyPressMsg: + if msg.String() == "ctrl+t" { + model, cmd := p.messages.Update(tool.ToggleDiffViewMsg{}) + p.messages = model.(messages.Model) + return p, cmd + } + switch { case key.Matches(msg, p.keyMap.Tab): p.switchFocus() @@ -209,12 +215,6 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case *runtime.ErrorEvent: cmd := p.messages.AddErrorMessage(msg.Error) return p, tea.Batch(cmd, p.messages.ScrollToBottom()) - case *runtime.MCPInitStartedEvent: - spinnerCmd := p.sidebar.SetMCPInitializing(true) - return p, spinnerCmd - case *runtime.MCPInitFinishedEvent: - spinnerCmd := p.sidebar.SetMCPInitializing(false) - return p, spinnerCmd case *runtime.ShellOutputEvent: cmd := p.messages.AddShellOutputMessage(msg.Output) return p, tea.Batch(cmd, p.messages.ScrollToBottom()) @@ -248,10 +248,8 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return p, tea.Batch(cmd, p.messages.ScrollToBottom()) } return p, cmd - case *runtime.SessionTitleEvent: - p.sessionTitle = msg.Title case *runtime.TokenUsageEvent: - p.sidebar.SetTokenUsage(msg.Usage) + p.sidebar.SetTokenUsage(msg) // forward full event so sidebar can track per-session usage case *runtime.StreamStoppedEvent: spinnerCmd := p.setWorking(false) cmd := p.messages.AddSeparatorMessage() @@ -272,7 +270,7 @@ func (p *chatPage) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Open tool confirmation dialog dialogCmd := core.CmdHandler(dialog.OpenDialogMsg{ - Model: dialog.NewToolConfirmationDialog(msg.ToolCall, p.app), + Model: dialog.NewToolConfirmationDialog(msg.ToolCall), }) return p, tea.Batch(cmd, p.messages.ScrollToBottom(), spinnerCmd, dialogCmd) @@ -337,34 +335,50 @@ func (p *chatPage) setWorking(working bool) tea.Cmd { // View renders the chat page func (p *chatPage) View() string { - // Header - headerText := p.title - header := styles.HeaderStyle.Render(headerText + " " + p.sessionTitle) - // Main chat content area (without input) - // Calculate chat width (85% of available width) innerWidth := p.width // subtract app style padding - sidebarWidth := int(float64(innerWidth) * 0.15) - chatWidth := innerWidth - sidebarWidth - - chatView := styles.ChatStyle. - Height(p.chatHeight). - Width(chatWidth). - Render(p.messages.View()) - - // Sidebar with explicit height constraint to prevent disappearing during scroll - sidebarView := lipgloss.NewStyle(). - Width(sidebarWidth). - Height(p.chatHeight). - Align(lipgloss.Left, lipgloss.Top). - Render(p.sidebar.View()) - - // Create horizontal layout with chat content and sidebar - bodyContent := lipgloss.JoinHorizontal( - lipgloss.Top, - chatView, - sidebarView, - ) + + var bodyContent string + + if p.width >= minWindowWidth { + chatWidth := innerWidth - sidebarWidth + + chatView := styles.ChatStyle. + Height(p.chatHeight). + Width(chatWidth). + Render(p.messages.View()) + + sidebarView := lipgloss.NewStyle(). + Width(sidebarWidth). + Height(p.chatHeight). + Align(lipgloss.Left, lipgloss.Top). + Render(p.sidebar.View()) + + bodyContent = lipgloss.JoinHorizontal( + lipgloss.Left, + chatView, + sidebarView, + ) + } else { + sidebarWidth, sidebarHeight := p.sidebar.GetSize() + + chatView := styles.ChatStyle. + Height(p.chatHeight). + Width(innerWidth). + Render(p.messages.View()) + + sidebarView := lipgloss.NewStyle(). + Width(sidebarWidth). + Height(sidebarHeight). + Align(lipgloss.Left, lipgloss.Top). + Render(p.sidebar.View()) + + bodyContent = lipgloss.JoinVertical( + lipgloss.Top, + sidebarView, + chatView, + ) + } // Input field spans full width below everything input := p.editor.View() @@ -372,7 +386,6 @@ func (p *chatPage) View() string { // Create a full-height layout with header, body, and input content := lipgloss.JoinVertical( lipgloss.Left, - header, bodyContent, input, ) @@ -382,7 +395,6 @@ func (p *chatPage) View() string { Render(content) } -// SetSize sets the dimensions of the chat page func (p *chatPage) SetSize(width, height int) tea.Cmd { p.width = width p.height = height @@ -390,25 +402,31 @@ func (p *chatPage) SetSize(width, height int) tea.Cmd { var cmds []tea.Cmd // Calculate heights accounting for padding - headerHeight := 3 // header + top/bottom padding editorHeight := 3 // fixed 3 lines for multi-line input // Calculate available space, ensuring status bar remains visible - availableHeight := height - headerHeight - p.inputHeight = editorHeight + 2 // account for editor padding - p.chatHeight = availableHeight - p.inputHeight + p.inputHeight = editorHeight + 3 // account for editor padding // Account for horizontal padding in width innerWidth := width - 2 // subtract left/right padding - // Calculate sidebar and main content widths (15% sidebar, 85% main) - sidebarWidth := int(float64(innerWidth) * 0.15) - mainWidth := innerWidth - sidebarWidth + var mainWidth int + if width >= minWindowWidth { + mainWidth = innerWidth - sidebarWidth + p.chatHeight = height - p.inputHeight + p.sidebar.SetMode(sidebar.ModeVertical) + cmds = append(cmds, p.sidebar.SetSize(sidebarWidth, p.chatHeight), p.messages.SetPosition(0, 0)) + } else { + const horizontalSidebarHeight = 3 + mainWidth = innerWidth + p.chatHeight = height - p.inputHeight - horizontalSidebarHeight + p.sidebar.SetMode(sidebar.ModeHorizontal) + cmds = append(cmds, p.sidebar.SetSize(width, horizontalSidebarHeight), p.messages.SetPosition(0, horizontalSidebarHeight)) + } // Set component sizes cmds = append(cmds, p.messages.SetSize(mainWidth, p.chatHeight), - p.sidebar.SetSize(sidebarWidth, p.chatHeight), p.editor.SetSize(innerWidth, editorHeight), // Use calculated editor height ) @@ -427,12 +445,8 @@ func (p *chatPage) Bindings() []key.Binding { p.keyMap.Cancel, } - // Add focused component bindings - switch p.focusedPanel { - case PanelChat: + if p.focusedPanel == PanelChat { bindings = append(bindings, p.messages.Bindings()...) - case PanelEditor: - bindings = append(bindings, p.editor.Bindings()...) } return bindings @@ -503,20 +517,6 @@ func (p *chatPage) processMessage(content string) tea.Cmd { return p.messages.ScrollToBottom() } -func (p *chatPage) CopySessionToClipboard() tea.Cmd { - transcript := p.messages.PlainTextTranscript() - if transcript == "" { - cmd := core.CmdHandler(notification.ShowMsg{Text: "Conversation is empty; nothing copied."}) - return cmd - } - - if err := clipboard.WriteAll(transcript); err != nil { - return core.CmdHandler(notification.ShowMsg{Text: "Failed to copy conversation: " + err.Error(), Type: notification.TypeError}) - } - - return core.CmdHandler(notification.ShowMsg{Text: "Conversation copied to clipboard."}) -} - // CompactSession generates a summary and compacts the session history func (p *chatPage) CompactSession() tea.Cmd { // Cancel any active stream without showing cancellation message diff --git a/pkg/tui/styles/styles.go b/pkg/tui/styles/styles.go index d1ebbaed8..c0cc34857 100644 --- a/pkg/tui/styles/styles.go +++ b/pkg/tui/styles/styles.go @@ -3,10 +3,10 @@ package styles import ( "strings" + "charm.land/bubbles/v2/textarea" + "charm.land/lipgloss/v2" "github.com/alecthomas/chroma/v2" - "github.com/charmbracelet/bubbles/v2/textarea" "github.com/charmbracelet/glamour/v2/ansi" - "github.com/charmbracelet/lipgloss/v2" ) const ( @@ -37,6 +37,14 @@ const ( ColorDiffAddBg = "#20303B" // Dark blue-green ColorDiffRemoveBg = "#3C2A2A" // Dark red-brown + // Line number and UI element colors + ColorLineNumber = "#565F89" // Muted blue-grey (same as ColorMutedBlue) + ColorSeparator = "#414868" // Dark blue-grey (same as ColorBorderSecondary) + + // Word-level diff highlight colors (visible but not harsh) + ColorDiffWordAddBg = "#2D4F3F" // Medium dark teal with green tint + ColorDiffWordRemoveBg = "#4F2D3A" // Medium dark burgundy with red tint + // Interactive element colors ColorSelected = "#364A82" // Dark blue for selected items ColorHover = "#2D3F5F" // Slightly lighter than selected @@ -113,6 +121,10 @@ var ( DiffAddFg = lipgloss.Color(ColorSuccessGreen) DiffRemoveFg = lipgloss.Color(ColorErrorRed) + // UI element colors + LineNumber = lipgloss.Color(ColorLineNumber) + Separator = lipgloss.Color(ColorSeparator) + // Interactive element colors Selected = lipgloss.Color(ColorSelected) SelectedFg = lipgloss.Color(ColorTextPrimary) @@ -266,11 +278,17 @@ var ( Background(DiffRemoveBg). Foreground(DiffRemoveFg) - DiffUnchangedStyle = lipgloss.NewStyle() + DiffUnchangedStyle = BaseStyle.Background(BackgroundAlt) DiffContextStyle = BaseStyle ) +// Syntax highlighting UI element styles +var ( + LineNumberStyle = BaseStyle.Foreground(LineNumber).Background(BackgroundAlt) + SeparatorStyle = BaseStyle.Foreground(Separator).Background(BackgroundAlt) +) + // Tool Call Styles var ( ToolCallArgs = BaseStyle. diff --git a/pkg/tui/tui.go b/pkg/tui/tui.go index 90a9d412c..59e14f3cc 100644 --- a/pkg/tui/tui.go +++ b/pkg/tui/tui.go @@ -5,10 +5,11 @@ import ( "fmt" "time" - "github.com/charmbracelet/bubbles/v2/help" - "github.com/charmbracelet/bubbles/v2/key" - tea "github.com/charmbracelet/bubbletea/v2" - "github.com/charmbracelet/lipgloss/v2" + "charm.land/bubbles/v2/help" + "charm.land/bubbles/v2/key" + tea "charm.land/bubbletea/v2" + "charm.land/lipgloss/v2" + "github.com/atotto/clipboard" "github.com/docker/cagent/pkg/app" "github.com/docker/cagent/pkg/browser" @@ -68,10 +69,6 @@ type KeyMap struct { // DefaultKeyMap returns the default global key bindings func DefaultKeyMap() KeyMap { return KeyMap{ - Quit: key.NewBinding( - key.WithKeys("ctrl+c"), - key.WithHelp("ctrl+c", "quit"), - ), CommandPalette: key.NewBinding( key.WithKeys("ctrl+p"), key.WithHelp("ctrl+p", "command palette"), @@ -97,10 +94,20 @@ func New(a *app.App) tea.Model { // Init initializes the application func (a *appModel) Init() tea.Cmd { - return tea.Batch( + cmds := []tea.Cmd{ a.dialog.Init(), a.chatPage.Init(), - ) + } + + if firstMessage := a.application.FirstMessage(); firstMessage != nil { + cmds = append(cmds, func() tea.Msg { + return editor.SendMsg{ + Content: a.application.ResolveCommand(context.Background(), *firstMessage), + } + }) + } + + return tea.Batch(cmds...) } // Help returns help information @@ -167,7 +174,16 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return a, a.chatPage.CompactSession() case commands.CopySessionToClipboardMsg: - return a, a.chatPage.CopySessionToClipboard() + transcript := a.application.PlainTextTranscript() + if transcript == "" { + return a, core.CmdHandler(notification.ShowMsg{Text: "Conversation is empty; nothing copied."}) + } + + if err := clipboard.WriteAll(transcript); err != nil { + return a, core.CmdHandler(notification.ShowMsg{Text: "Failed to copy conversation: " + err.Error(), Type: notification.TypeError}) + } + + return a, core.CmdHandler(notification.ShowMsg{Text: "Conversation copied to clipboard."}) case commands.AgentCommandMsg: resolvedCommand := a.application.ResolveCommand(context.Background(), msg.Command) @@ -177,6 +193,10 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { _ = browser.Open(context.Background(), msg.URL) return a, nil + case dialog.RuntimeResumeMsg: + a.application.Resume(msg.Response) + return a, nil + case error: a.err = msg return a, nil @@ -218,7 +238,7 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd { var cmds []tea.Cmd // Update dimensions - a.width, a.height = width, height-2 // Account for status bar + a.width, a.height = width, height-1 // Account for status bar if !a.ready { a.ready = true @@ -227,22 +247,10 @@ func (a *appModel) handleWindowResize(width, height int) tea.Cmd { // Update dialog system u, cmd := a.dialog.Update(tea.WindowSizeMsg{Width: width, Height: height}) a.dialog = u.(dialog.Manager) - if cmd != nil { - cmds = append(cmds, cmd) - } + cmds = append(cmds, cmd) - // Update chat page - if sizable, ok := a.chatPage.(interface{ SetSize(int, int) tea.Cmd }); ok { - cmd := sizable.SetSize(a.width, a.height) - cmds = append(cmds, cmd) - } else { - // Fallback: send window size message - updated, cmd := a.chatPage.Update(tea.WindowSizeMsg{Width: a.width, Height: a.height}) - a.chatPage = updated.(chat.Page) - if cmd != nil { - cmds = append(cmds, cmd) - } - } + cmd = a.chatPage.SetSize(a.width, a.height) + cmds = append(cmds, cmd) // Update status bar width a.statusBar.SetWidth(a.width) @@ -302,21 +310,6 @@ func (a *appModel) handleKeyPressMsg(msg tea.KeyPressMsg) tea.Cmd { // View renders the complete application interface func (a *appModel) View() tea.View { - // Handle minimum window size - if a.wWidth < 25 || a.wHeight < 15 { - return toFullscreenView(styles.CenterStyle. - Width(a.wWidth). - Height(a.wHeight). - Render( - styles.BorderStyle. - Padding(1, 1). - Foreground(lipgloss.Color("#ffffff")). - BorderForeground(lipgloss.Color("#ff5f87")). - Render("Window too small!"), - ), - ) - } - // Show error if present if a.err != nil { return toFullscreenView(styles.ErrorStyle.Render(a.err.Error())) @@ -378,6 +371,9 @@ func (a *appModel) View() tea.View { func toFullscreenView(content string) tea.View { view := tea.NewView(content) + view.AltScreen = true + view.MouseMode = tea.MouseModeCellMotion view.BackgroundColor = styles.Background + return view } diff --git a/pkg/tui/types/types.go b/pkg/tui/types/types.go index 4f377b90a..357307e7f 100644 --- a/pkg/tui/types/types.go +++ b/pkg/tui/types/types.go @@ -16,7 +16,7 @@ const ( MessageTypeCancelled MessageTypeToolCall MessageTypeToolResult - MessageTypeWarning + MessageTypeWelcome ) // ToolStatus represents the status of a tool call diff --git a/token_usage_chunks.log b/token_usage_chunks.log new file mode 100644 index 000000000..567f85c19 --- /dev/null +++ b/token_usage_chunks.log @@ -0,0 +1,13 @@ +2025-11-09T17:34:07.568371873+05:30 session=9e3580a4-341b-4899-8111-e8f0cd3fe49f agent=root input=720 output=10 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:34:08.171943534+05:30 session=e327cdc5-11e4-47e3-87ae-e05673762000 agent=root input=97 output=2 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:34:16.33567944+05:30 session=9e3580a4-341b-4899-8111-e8f0cd3fe49f agent=root input=742 output=65 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:34:59.857226661+05:30 session=9e3580a4-341b-4899-8111-e8f0cd3fe49f agent=root input=819 output=162 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:35:06.745218933+05:30 session=5e1dc6b8-88e6-410d-9dfe-dea8dcc4fd51 agent=prompt_chooser input=278 output=468 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:35:08.629256513+05:30 session=9e3580a4-341b-4899-8111-e8f0cd3fe49f agent=root input=1457 output=139 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:35:19.77913659+05:30 session=85079200-c849-4a44-bda3-a8de96b3a965 agent=writer input=218 output=858 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:35:21.667533929+05:30 session=9e3580a4-341b-4899-8111-e8f0cd3fe49f agent=root input=2462 output=66 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:39:39.618370876+05:30 session=9e3580a4-341b-4899-8111-e8f0cd3fe49f agent=root input=2542 output=162 cached_input=2432 cached_output=0 reasoning=0 +2025-11-09T17:39:43.307039589+05:30 session=8e2ab5d4-1eb8-4d5e-a344-eafd897588da agent=prompt_chooser input=278 output=213 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:39:45.380776269+05:30 session=9e3580a4-341b-4899-8111-e8f0cd3fe49f agent=root input=2925 output=128 cached_input=1536 cached_output=0 reasoning=0 +2025-11-09T17:39:55.516726784+05:30 session=720bb438-fb2a-44a7-a7ac-eeb5f67fff3a agent=writer input=207 output=896 cached_input=0 cached_output=0 reasoning=0 +2025-11-09T17:39:56.614901861+05:30 session=9e3580a4-341b-4899-8111-e8f0cd3fe49f agent=root input=3957 output=65 cached_input=2688 cached_output=0 reasoning=0