diff --git a/.github/workflows/tag.yaml b/.github/workflows/tag.yaml index 09a83703ca..298129bd21 100644 --- a/.github/workflows/tag.yaml +++ b/.github/workflows/tag.yaml @@ -104,7 +104,7 @@ jobs: uv sync --all-extras # Bump every package version to $VERSION first uv version $VERSION --package kagent-core - uv version $VERSION --package kagent-skills + uv version $VERSION --package kagent-tools uv version $VERSION --package kagent-adk uv version $VERSION --package kagent-langgraph uv version $VERSION --package kagent-crewai @@ -115,13 +115,13 @@ jobs: # Without this, a user upgrading e.g. kagent-adk via pip/uv could have # older intra-repo siblings still locked at a stale version that may be # missing symbols the new package requires. - for pkg in kagent-core kagent-skills agentsts-core agentsts-adk; do + for pkg in kagent-core kagent-tools agentsts-core agentsts-adk; do find packages -name "pyproject.toml" \ -exec sed -i "s/\"${pkg}>=[^\"]*\"/\"${pkg}>=${VERSION}\"/g" {} + done # Build all packages uv build --package kagent-core - uv build --package kagent-skills + uv build --package kagent-tools uv build --package kagent-adk uv build --package kagent-langgraph uv build --package kagent-crewai diff --git a/docs/architecture/process-configuration.md b/docs/architecture/process-configuration.md index 299c42156c..d18f84f2a3 100644 --- a/docs/architecture/process-configuration.md +++ b/docs/architecture/process-configuration.md @@ -175,7 +175,7 @@ compiles `runtimeDefaults` into these payloads independently of its own settings "log_level": "info", "port": 80, "max_content_length": "0", - "skill_tools": { "file_search_tools": false }, + "builtin_tools": { "disable_file_search": false }, "token_propagation": { "enabled": false } } } @@ -211,17 +211,22 @@ the same `TelemetryConfig` as controller settings (including protobuf durations) An absent request size limit uses the runtime default; zero means unlimited. -Skill tool settings live together under `runtime.skill_tools`: -`file_search_tools`, `bash_venv_path`, and `disable_bash`. Move existing flat -settings into this block when updating configuration files. -Go ADK automatically registers the built-in `bash` tool when skills are loaded. -Set `runtime.skill_tools.disable_bash` to `true` to omit it for the root agent and all -subagents. Skill discovery and loading, `read_file`, `write_file`, and `edit_file` -remain available; `list_files` and `grep_file` still depend on -`runtime.skill_tools.file_search_tools`. For controller-managed runtimes, set -`config.runtimeDefaults.skillTools.disableBash: true` in Helm values. The default is `false`; -this setting affects only the built-in Go ADK bash tool, not Python or tools -provided through MCP or other integrations. +Built-in tool settings live under `runtime.builtin_tools`: +`disable_file_search`, `bash_venv_path`, and `disable_bash`. This replaces the +former `runtime.skill_tools` block (Helm: `config.runtimeDefaults.builtinTools`). +Replace the former `file_search_tools` flag with `disable_file_search`, reversing +its value: `file_search_tools: true` becomes `disable_file_search: false`. +Go and Python ADK register `read_file`, `write_file`, `edit_file`, `list_files`, +`grep_file`, and `bash` for the root agent and subagents, even when no skills are +configured or installed. +Set `runtime.builtin_tools.disable_bash` to `true` to omit built-in bash. +Set `runtime.builtin_tools.disable_file_search` to `true` to omit `list_files` +and `grep_file`. Both opt-outs default to `false`. Skill discovery and loading +remain conditional on installed skills. +For controller-managed runtimes, set +`config.runtimeDefaults.builtinTools.disableBash: true` in Helm values. +`bash_venv_path` selects a separate environment for shell commands in both runtimes: +its `bin` directory is prepended to `PATH`, and `VIRTUAL_ENV` is set. Python integrations load this document with `KAgentConfig.from_file()` or receive an explicit generated `RuntimeConfig`. Python ADK loads the full document with diff --git a/docs/architecture/runtime-and-lifecycle.md b/docs/architecture/runtime-and-lifecycle.md index 54a75b938c..a054ccedfe 100644 --- a/docs/architecture/runtime-and-lifecycle.md +++ b/docs/architecture/runtime-and-lifecycle.md @@ -238,6 +238,23 @@ state there—local framework state, workspaces, and downloaded assets that must survive Actor replacement. This state is runtime-private; public task history remains in PostgreSQL. +The ADK's built-in file and shell tools use `/data/workspace`, shared by the root +agent and subagents in one Actor. Workspace files survive VOLUMES snapshots, resume, +and checkpoint forks. Skills are discovered independently; the workspace has no +session-specific directories or mutable skills symlinks. The workspace is created +on first use, with no predefined upload or output directories. File tools accept +normal relative and absolute paths inside the sandbox; Substrate owns isolation. + +Each bash invocation starts a fresh shell with the Actor environment. The working +directory and configured skills directory are added to `PYTHONPATH`. Commands use +a 60-second default timeout; `timeout_seconds` can select up to 600 seconds. +Stdout and stderr are combined, captured up to 64 KiB, and returned with an +explicit truncation marker. Large results should be redirected to workspace files. +Commands and their process groups are stopped when a call finishes, times out, +or is canceled. Background processes are not durable work and have no process +handle API in the bash tool. Suspending an Actor preserves files, not running +commands. Command payloads and output are not logged by the shell helpers. + Substrate v0.6.0-alpha3 uses snapshot fidelity. Agent and standalone sandbox templates use VOLUMES. Resume starts fresh containers from the OCI image with the saved durable directories. Harnesses recover pending diff --git a/go/adk/pkg/README.md b/go/adk/pkg/README.md index 50666fdab8..707e658bb5 100644 --- a/go/adk/pkg/README.md +++ b/go/adk/pkg/README.md @@ -13,7 +13,7 @@ Shared types, interfaces, and implementations for the Kagent Go ADK. - **models/** - LLM model adapters (OpenAI, Anthropic) implementing Google ADK's `model.LLM` - **runner/** - Google ADK `runner.Config` creation from `AgentConfig` (agent, session service, memory, plugins, context compaction) - **session/** - Session management, persistence, and ADK session service adapter -- **tools/** - Agent tools, including skill filesystem and shell execution +- **tools/** - Agent tools, including built-in filesystem and shell execution - **taskstore/** - A2A task persistence through the kagent controller API - **telemetry/** - OpenTelemetry tracing utilities diff --git a/go/adk/pkg/agent/agent.go b/go/adk/pkg/agent/agent.go index 5cd142fb28..4496aebf83 100644 --- a/go/adk/pkg/agent/agent.go +++ b/go/adk/pkg/agent/agent.go @@ -70,6 +70,15 @@ func createGoogleADKAgent(ctx context.Context, agentConfig *configv1.ADKAgentCon } toolsets := mcp.CreateToolsets(ctx, agentConfig.HttpTools, agentConfig.SseTools, agentConfig.StdioTools, propagateToken, dynamicHeaderProvider) skillsDirectory := agentConfig.SkillsDirectory + builtinTools := runtime.GetBuiltinTools() + executionTools, err := tools.NewBuiltinTools(tools.BuiltinToolsConfig{ + SkillsDirectory: skillsDirectory, BashVenvPath: builtinTools.GetBashVenvPath(), + DisableFileSearch: builtinTools.GetDisableFileSearch(), DisableBash: builtinTools.GetDisableBash(), + }) + if err != nil { + return nil, fmt.Errorf("failed to create built-in tools: %w", err) + } + extraTools = append(extraTools, executionTools...) if skillsDirectory != "" { skillsSource := skill.NewFileSystemSource(os.DirFS(skillsDirectory)) skills, err := skillsSource.ListFrontmatters(ctx) @@ -77,19 +86,12 @@ func createGoogleADKAgent(ctx context.Context, agentConfig *configv1.ADKAgentCon return nil, fmt.Errorf("failed to load skills: %w", err) } if len(skills) > 0 { - skillTools := runtime.GetSkillTools() - executionTools, err := tools.NewSkillExecutionTools(skillsDirectory, skillTools.GetFileSearchTools(), skillTools.GetDisableBash()) - if err != nil { - return nil, fmt.Errorf("failed to create skill execution tools: %w", err) - } - extraTools = append(extraTools, executionTools...) - skillsToolset, err := skilltoolset.New(ctx, skilltoolset.Config{Source: skillsSource}) if err != nil { return nil, fmt.Errorf("failed to create skill toolset: %w", err) } toolsets = append(toolsets, skillsToolset) - log.InfoContext(ctx, "wired local skills", "skills_directory", skillsDirectory, "skill_count", len(skills), "execution_tool_count", len(executionTools)) + log.InfoContext(ctx, "wired local skills", "skills_directory", skillsDirectory, "skill_count", len(skills)) } } mcpAppToolNames := mcp.MCPAppToolNamesFromToolsets(toolsets) diff --git a/go/adk/pkg/agent/skills_test.go b/go/adk/pkg/agent/skills_test.go index a870e6b9d7..5097b2648d 100644 --- a/go/adk/pkg/agent/skills_test.go +++ b/go/adk/pkg/agent/skills_test.go @@ -22,75 +22,89 @@ import ( // Exercise the runtime configuration through agent construction and the runner, // inspecting the tools actually advertised to the model for each agent. -func TestSkillToolsRespectRuntimeDisableBash(t *testing.T) { +func TestBuiltinToolsIndependentOfSkills(t *testing.T) { t.Setenv("OPENAI_API_KEY", "test-key") - for _, disableBash := range []bool{false, true} { - for _, fileSearch := range []bool{false, true} { - t.Run(fmt.Sprintf("disable_bash=%t/file_search=%t", disableBash, fileSearch), func(t *testing.T) { - requests := make(chan []string, 2) - server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - var request struct { - Tools []struct { - Function struct { - Name string `json:"name"` - } `json:"function"` - } `json:"tools"` + for _, skillsMode := range []string{"none", "empty", "installed"} { + for _, disableBash := range []bool{false, true} { + for _, disableFileSearch := range []bool{false, true} { + t.Run(fmt.Sprintf("skills=%s/disable_bash=%t/disable_file_search=%t", skillsMode, disableBash, disableFileSearch), func(t *testing.T) { + requests := make(chan []string, 2) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Errorf("decode model request: %v", err) + http.Error(w, "invalid request", http.StatusBadRequest) + return + } + var names []string + for _, tool := range request.Tools { + names = append(names, tool.Function.Name) + } + requests <- names + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"id":"test","object":"chat.completion","model":"test","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`)) + if err != nil { + t.Errorf("write model response: %v", err) + } + })) + t.Cleanup(server.Close) + + var skillsDir string + if skillsMode != "none" { + skillsDir = t.TempDir() } - if err := json.NewDecoder(r.Body).Decode(&request); err != nil { - t.Errorf("decode model request: %v", err) - http.Error(w, "invalid request", http.StatusBadRequest) - return + if skillsMode == "installed" { + require.NoError(t, os.Mkdir(filepath.Join(skillsDir, "demo"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(skillsDir, "demo", "SKILL.md"), []byte("---\nname: demo\ndescription: Demo skill.\n---\nRead files.\n"), 0644)) } - var names []string - for _, tool := range request.Tools { - names = append(names, tool.Function.Name) + + model := &configv1.ADKModelConfig{Model: "gpt-4o", Provider: &configv1.ADKModelConfig_Openai{Openai: &configv1.ADKOpenAI{BaseUrl: server.URL}}} + cfg := &configv1.ADKConfig{ + Runtime: &configv1.ADKRuntimeConfig{BuiltinTools: &configv1.BuiltinToolsConfig{DisableBash: disableBash, DisableFileSearch: disableFileSearch}}, + Agent: &configv1.ADKAgentConfig{Model: model, SkillsDirectory: skillsDir, Stream: new(false), + SubAgents: []*configv1.ADKAgentConfig{{Name: "child", Model: model, SkillsDirectory: skillsDir, Stream: new(false)}}, + }, } - requests <- names - w.Header().Set("Content-Type", "application/json") - _, err := w.Write([]byte(`{"id":"test","object":"chat.completion","model":"test","choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`)) - if err != nil { - t.Errorf("write model response: %v", err) + if !disableBash && !disableFileSearch { + cfg.Runtime = nil // Exercise absent tool configuration defaults. } - })) - t.Cleanup(server.Close) - - skillsDir := t.TempDir() - require.NoError(t, os.Mkdir(filepath.Join(skillsDir, "demo"), 0755)) - require.NoError(t, os.WriteFile(filepath.Join(skillsDir, "demo", "SKILL.md"), []byte("---\nname: demo\ndescription: Demo skill.\n---\nRead files.\n"), 0644)) - model := &configv1.ADKModelConfig{Model: "gpt-4o", Provider: &configv1.ADKModelConfig_Openai{Openai: &configv1.ADKOpenAI{BaseUrl: server.URL}}} - cfg := &configv1.ADKConfig{ - Runtime: &configv1.ADKRuntimeConfig{SkillTools: &configv1.SkillToolsConfig{DisableBash: disableBash, FileSearchTools: fileSearch}}, - Agent: &configv1.ADKAgentConfig{Model: model, SkillsDirectory: skillsDir, Stream: new(false), - SubAgents: []*configv1.ADKAgentConfig{{Name: "child", Model: model, SkillsDirectory: skillsDir, Stream: new(false)}}, - }, - } - root, err := CreateGoogleADKAgent(t.Context(), cfg, "root", nil) - require.NoError(t, err) - for _, agent := range []adkagent.Agent{root, root.SubAgents()[0]} { - t.Run(agent.Name(), func(t *testing.T) { - sessions := adksession.InMemoryService() - r, err := runner.New(runner.Config{AppName: "test", Agent: agent, SessionService: sessions}) - require.NoError(t, err) - sess, err := sessions.Create(t.Context(), &adksession.CreateRequest{AppName: "test", UserID: "user"}) - require.NoError(t, err) - for _, err := range r.Run(t.Context(), "user", sess.Session.ID(), genai.NewContentFromText("List your tools.", genai.RoleUser), adkagent.RunConfig{}) { + root, err := CreateGoogleADKAgent(t.Context(), cfg, "root", nil) + require.NoError(t, err) + for _, agent := range []adkagent.Agent{root, root.SubAgents()[0]} { + t.Run(agent.Name(), func(t *testing.T) { + sessions := adksession.InMemoryService() + r, err := runner.New(runner.Config{AppName: "test", Agent: agent, SessionService: sessions}) require.NoError(t, err) - } - select { - case names := <-requests: - for _, name := range []string{"read_file", "write_file", "edit_file", "load_skill", "load_skill_resource", "list_skills"} { - require.Contains(t, names, name) + sess, err := sessions.Create(t.Context(), &adksession.CreateRequest{AppName: "test", UserID: "user"}) + require.NoError(t, err) + for _, err := range r.Run(t.Context(), "user", sess.Session.ID(), genai.NewContentFromText("List your tools.", genai.RoleUser), adkagent.RunConfig{}) { + require.NoError(t, err) } - require.Equal(t, !disableBash, slices.Contains(names, "bash")) - for _, name := range []string{"list_files", "grep_file"} { - require.Equal(t, fileSearch, slices.Contains(names, name), name) + select { + case names := <-requests: + for _, name := range []string{"read_file", "write_file", "edit_file"} { + require.Contains(t, names, name) + } + for _, name := range []string{"load_skill", "load_skill_resource", "list_skills"} { + require.Equal(t, skillsMode == "installed", slices.Contains(names, name), name) + } + require.Equal(t, !disableBash, slices.Contains(names, "bash")) + for _, name := range []string{"list_files", "grep_file"} { + require.Equal(t, !disableFileSearch, slices.Contains(names, name), name) + } + case <-time.After(time.Second): + t.Fatal("model received no request") } - case <-time.After(time.Second): - t.Fatal("model received no request") - } - }) - } - }) + }) + } + }) + } } } } diff --git a/go/adk/pkg/config/config_usage.go b/go/adk/pkg/config/config_usage.go index d67d9f9704..bce163ccfc 100644 --- a/go/adk/pkg/config/config_usage.go +++ b/go/adk/pkg/config/config_usage.go @@ -31,7 +31,7 @@ import ( // - Used in create_agent() to add tools to the agent // // Agent.Spec.A2AConfig.Skills -> Not in config.json, handled separately -// - Skills are added via SkillsPlugin in Python +// - Python adds skill discovery alongside the built-in tools // - In go-adk, skills are handled via AgentConfig.SkillsDirectory // ValidateAgentConfigUsage validates that all AgentConfig fields are properly used diff --git a/go/adk/pkg/config/runtime_test.go b/go/adk/pkg/config/runtime_test.go index 0872811f90..aff04b6b30 100644 --- a/go/adk/pkg/config/runtime_test.go +++ b/go/adk/pkg/config/runtime_test.go @@ -21,9 +21,9 @@ func TestSharedRuntimeContract(t *testing.T) { require.NotNil(t, cfg.MaxContentLength) require.Zero(t, *cfg.MaxContentLength) require.False(t, cfg.TokenPropagation.Enabled) - require.Equal(t, "/opt/venv", cfg.GetSkillTools().GetBashVenvPath()) - require.False(t, cfg.GetSkillTools().GetFileSearchTools()) - require.True(t, cfg.GetSkillTools().GetDisableBash()) + require.Equal(t, "/opt/venv", cfg.GetBuiltinTools().GetBashVenvPath()) + require.False(t, cfg.GetBuiltinTools().GetDisableFileSearch()) + require.True(t, cfg.GetBuiltinTools().GetDisableBash()) require.False(t, cfg.Telemetry.CaptureContent) require.Equal(t, int64(1500), cfg.Telemetry.Traces.Timeout.AsDuration().Milliseconds()) root := &configv1.ADKConfig{Runtime: &cfg, Agent: &configv1.ADKAgentConfig{SubAgents: []*configv1.ADKAgentConfig{{}}}} @@ -35,8 +35,8 @@ func TestRuntimeDefaultsAndValidation(t *testing.T) { var cfg configv1.ADKRuntimeConfig require.NoError(t, protovalidate.Validate(&cfg)) require.Nil(t, cfg.MaxContentLength) - require.False(t, cfg.GetSkillTools().GetFileSearchTools()) - require.False(t, cfg.GetSkillTools().GetDisableBash()) + require.False(t, cfg.GetBuiltinTools().GetDisableFileSearch()) + require.False(t, cfg.GetBuiltinTools().GetDisableBash()) for _, data := range []string{ `{"port":"0"}`, `{"max_content_length":-1}`, `{"control_plane_url":"file:///private"}`, `{"token_propagation":{"resources":[""]}}`, `{"telemetry":{"max_capture_bytes":-1}}`, diff --git a/go/adk/pkg/config/testdata/agent.json b/go/adk/pkg/config/testdata/agent.json index a55c9b1ffd..3de59c06f2 100644 --- a/go/adk/pkg/config/testdata/agent.json +++ b/go/adk/pkg/config/testdata/agent.json @@ -17,7 +17,7 @@ "tools" ] }, - "skill_tools": { + "builtin_tools": { "bash_venv_path": "/opt/venv" }, "telemetry": { diff --git a/go/adk/pkg/config/testdata/runtime.json b/go/adk/pkg/config/testdata/runtime.json index 20f95871f6..df16c1def4 100644 --- a/go/adk/pkg/config/testdata/runtime.json +++ b/go/adk/pkg/config/testdata/runtime.json @@ -17,9 +17,9 @@ "tools" ] }, - "skill_tools": { + "builtin_tools": { "bash_venv_path": "/opt/venv", - "file_search_tools": false, + "disable_file_search": false, "disable_bash": true }, "telemetry": { diff --git a/go/adk/pkg/tools/builtin.go b/go/adk/pkg/tools/builtin.go new file mode 100644 index 0000000000..9a0d7c0a38 --- /dev/null +++ b/go/adk/pkg/tools/builtin.go @@ -0,0 +1,330 @@ +package tools + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "time" + + adkagent "google.golang.org/adk/v2/agent" + "google.golang.org/adk/v2/tool" + "google.golang.org/adk/v2/tool/functiontool" +) + +const ( + readFileDescription = `Reads a file from the filesystem with line numbers. + +Usage: +- Provide a path to the file (absolute or relative to your working directory) +- Returns content with line numbers (format: LINE_NUMBER|CONTENT) +- Optional offset and limit parameters; reads default to 2000 lines +- Results are capped at 64 KiB; use offset to continue a truncated read +- Lines longer than 2000 characters are truncated +- Always read a file before editing it +- Paths are relative to /data/workspace unless absolute` + + writeFileDescription = `Writes content to a file on the filesystem. + +Usage: +- Provide a path (absolute or relative to working directory) and content to write +- Overwrites existing files +- Creates parent directories if needed +- For existing files, read them first using read_file +- Prefer editing existing files over writing new ones +- You can write to any writable location in the sandbox` + + editFileDescription = `Performs exact string replacements in files. + +Usage: +- You must read the file first using read_file +- Provide path (absolute or relative to working directory) +- When editing, preserve exact indentation from the file content +- Do NOT include line number prefixes in old_string or new_string +- old_string must be unique unless replace_all=true +- Use replace_all to rename variables/strings throughout the file +- old_string and new_string must be different +- Paths are relative to /data/workspace unless absolute` + + listFilesDescription = `Lists files and directories at a given path. + +Usage: +- Provide a path (absolute or relative to your working directory); defaults to the working directory +- Results are capped at 64 KiB; narrow the path for large directories +- Directories are listed with a trailing "/"; files are followed by their size in bytes +- Paths are relative to /data/workspace unless absolute` + + grepFileDescription = `Searches for a regular expression pattern in a file or directory. + +Usage: +- Provide a pattern and a path (absolute or relative to your working directory) +- Set recursive=true to search all files under a directory path +- Recursion does not follow symlinked subdirectories; target a symlink directly to search it +- Set ignore_case=true for case-insensitive matching +- Returns matching lines as path:line_number:content, capped at 64 KiB +- Paths are relative to /data/workspace unless absolute` + + bashDescription = `Execute a bash command inside the agent sandbox. + +- Working directory: /data/workspace. Files there survive suspend/resume and checkpoints. +- Each call starts a new shell; use cd inside a command or absolute paths. +- Default timeout: 60 seconds. Set timeout_seconds up to 600 for longer commands. +- Returns combined stdout and stderr, capped at 64 KiB with a truncation marker. +- Redirect large output to a file if you need to inspect it in full. +- Child processes are stopped when the call finishes or is canceled.` + + // Mention search tools only when they are registered. + fileSearchToolsBashHint = "\nAlso available: list_files and grep_file, for exploring the filesystem without a full shell command." +) + +type bashInput struct { + Command string `json:"command"` + Description string `json:"description,omitempty"` + TimeoutSeconds int `json:"timeout_seconds,omitempty"` +} + +type readFileInput struct { + FilePath string `json:"file_path"` + Offset int `json:"offset,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type writeFileInput struct { + FilePath string `json:"file_path"` + Content string `json:"content"` +} + +type editFileInput struct { + FilePath string `json:"file_path"` + OldString string `json:"old_string"` + NewString string `json:"new_string"` + ReplaceAll bool `json:"replace_all,omitempty"` +} + +type listFilesInput struct { + Path string `json:"path,omitempty"` +} + +type grepFileInput struct { + Pattern string `json:"pattern"` + Path string `json:"path"` + Recursive bool `json:"recursive,omitempty"` + IgnoreCase bool `json:"ignore_case,omitempty"` +} + +// BuiltinToolsConfig configures tools in a Substrate Actor. Workspace defaults to +// the Actor's durable workspace; isolation is provided by the sandbox. +type BuiltinToolsConfig struct { + Workspace string + SkillsDirectory string + BashVenvPath string + DisableFileSearch bool + DisableBash bool +} + +// NewBuiltinTools creates filesystem and shell tools independently of skills. +func NewBuiltinTools(cfg BuiltinToolsConfig) ([]tool.Tool, error) { + workspace := cfg.Workspace + if workspace == "" { + workspace = "/data/workspace" + } + workspace, err := filepath.Abs(workspace) + if err != nil { + return nil, fmt.Errorf("failed to resolve workspace: %w", err) + } + if cfg.SkillsDirectory != "" { + cfg.SkillsDirectory, err = filepath.Abs(cfg.SkillsDirectory) + if err != nil { + return nil, fmt.Errorf("failed to resolve skills directory: %w", err) + } + } + // Create lazily on invocation so tool discovery does not touch the filesystem. + prepareWorkspace := func() error { return os.MkdirAll(workspace, 0755) } + + readFileTool, err := functiontool.New(functiontool.Config{ + Name: "read_file", + Description: readFileDescription, + }, func(ctx adkagent.Context, in readFileInput) (string, error) { + if err := prepareWorkspace(); err != nil { + return "Error: " + err.Error(), nil + } + path, err := resolveToolPath(workspace, in.FilePath) + if err != nil { + return fmt.Sprintf("Error reading file %s: %v", strings.TrimSpace(in.FilePath), err), nil + } + + content, err := readFileContent(ctx, path, in.Offset, in.Limit) + if err != nil { + return fmt.Sprintf("Error reading file %s: %v", strings.TrimSpace(in.FilePath), err), nil + } + return content, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create read_file tool: %w", err) + } + + writeFileTool, err := functiontool.New(functiontool.Config{ + Name: "write_file", + Description: writeFileDescription, + }, func(ctx adkagent.Context, in writeFileInput) (string, error) { + if err := prepareWorkspace(); err != nil { + return "Error: " + err.Error(), nil + } + path, err := resolveToolPath(workspace, in.FilePath) + if err != nil { + return fmt.Sprintf("Error writing file %s: %v", strings.TrimSpace(in.FilePath), err), nil + } + + if err := writeFileContent(path, in.Content); err != nil { + return fmt.Sprintf("Error writing file %s: %v", strings.TrimSpace(in.FilePath), err), nil + } + return fmt.Sprintf("Successfully wrote file: %s", path), nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create write_file tool: %w", err) + } + + editFileTool, err := functiontool.New(functiontool.Config{ + Name: "edit_file", + Description: editFileDescription, + }, func(ctx adkagent.Context, in editFileInput) (string, error) { + if err := prepareWorkspace(); err != nil { + return "Error: " + err.Error(), nil + } + path, err := resolveToolPath(workspace, in.FilePath) + if err != nil { + return fmt.Sprintf("Error editing file %s: %v", strings.TrimSpace(in.FilePath), err), nil + } + + if err := editFileContent(path, in.OldString, in.NewString, in.ReplaceAll); err != nil { + return fmt.Sprintf("Error editing file %s: %v", strings.TrimSpace(in.FilePath), err), nil + } + return fmt.Sprintf("Successfully edited file: %s", path), nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create edit_file tool: %w", err) + } + + tools := []tool.Tool{readFileTool, writeFileTool, editFileTool} + + // File search tools are available unless explicitly disabled. + if !cfg.DisableFileSearch { + listFilesTool, err := functiontool.New(functiontool.Config{ + Name: "list_files", + Description: listFilesDescription, + }, func(ctx adkagent.Context, in listFilesInput) (string, error) { + requestedPath := in.Path + if strings.TrimSpace(requestedPath) == "" { + requestedPath = "." + } + + if err := prepareWorkspace(); err != nil { + return "Error: " + err.Error(), nil + } + path, err := resolveToolPath(workspace, requestedPath) + if err != nil { + return fmt.Sprintf("Error listing %s: %v", requestedPath, err), nil + } + + content, err := listDirContent(ctx, path) + if err != nil { + return fmt.Sprintf("Error listing %s: %v", requestedPath, err), nil + } + return content, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create list_files tool: %w", err) + } + + grepFileTool, err := functiontool.New(functiontool.Config{ + Name: "grep_file", + Description: grepFileDescription, + }, func(ctx adkagent.Context, in grepFileInput) (string, error) { + if strings.TrimSpace(in.Pattern) == "" { + return "Error: No pattern provided", nil + } + if strings.TrimSpace(in.Path) == "" { + return "Error: No file path provided", nil + } + + if err := prepareWorkspace(); err != nil { + return "Error: " + err.Error(), nil + } + path, err := resolveToolPath(workspace, in.Path) + if err != nil { + return fmt.Sprintf("Error searching %s: %v", strings.TrimSpace(in.Path), err), nil + } + + // Go regex matching is linear-time; use the invocation cancellation. + content, err := grepContent(ctx, path, in.Pattern, in.Recursive, in.IgnoreCase) + if err != nil { + return fmt.Sprintf("Error searching %s: %v", strings.TrimSpace(in.Path), err), nil + } + return content, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create grep_file tool: %w", err) + } + + tools = append(tools, listFilesTool, grepFileTool) + } + + if cfg.DisableBash { + return tools, nil + } + + desc := bashDescription + if cfg.SkillsDirectory != "" { + desc += "\nSkills directory: " + cfg.SkillsDirectory + } + if !cfg.DisableFileSearch { + desc += fileSearchToolsBashHint + } + bashTool, err := functiontool.New(functiontool.Config{ + Name: "bash", + Description: desc, + }, func(ctx adkagent.Context, in bashInput) (string, error) { + command := strings.TrimSpace(in.Command) + if command == "" { + return "Error: No command provided", nil + } + + timeout := defaultCommandTimeout + if in.TimeoutSeconds < 0 || in.TimeoutSeconds > 600 { + return "Error: timeout_seconds must be between 0 and 600 (0 uses the 60-second default)", nil + } + if in.TimeoutSeconds > 0 { + timeout = time.Duration(in.TimeoutSeconds) * time.Second + } + if err := prepareWorkspace(); err != nil { + return "Error: " + err.Error(), nil + } + result, err := executeCommand(ctx, command, commandConfig{ + directory: workspace, skillsDirectory: cfg.SkillsDirectory, + bashVenvPath: cfg.BashVenvPath, timeout: timeout, + }) + if err != nil { + return fmt.Sprintf("Error: %v\n%s", err, result), nil + } + + return result, nil + }) + if err != nil { + return nil, fmt.Errorf("failed to create bash tool: %w", err) + } + tools = append(tools, bashTool) + + return tools, nil +} + +// resolveToolPath applies ordinary filesystem semantics inside the sandbox. +func resolveToolPath(workspace, requestedPath string) (string, error) { + requestedPath = strings.TrimSpace(requestedPath) + if requestedPath == "" { + return "", fmt.Errorf("no file path provided") + } + if filepath.IsAbs(requestedPath) { + return filepath.Clean(requestedPath), nil + } + return filepath.Join(workspace, requestedPath), nil +} diff --git a/go/adk/pkg/tools/skills_test.go b/go/adk/pkg/tools/builtin_test.go similarity index 60% rename from go/adk/pkg/tools/skills_test.go rename to go/adk/pkg/tools/builtin_test.go index 0362cd81e4..1359a43521 100644 --- a/go/adk/pkg/tools/skills_test.go +++ b/go/adk/pkg/tools/builtin_test.go @@ -50,116 +50,23 @@ func runTool(t *testing.T, tl tool.Tool, ctx adkagent.Context, args map[string]a return text } -// TestResolvePathContainment pins the sandbox boundary each resolver -// enforces. The three differ along exactly two axes -- whether the skills -// directory is an allowed root, and whether a not-yet-existing leaf is -// tolerated -- and those differences are the whole security contract: -// -// session dir skills dir missing leaf -// resolveReadPath allow allow reject -// resolveEditPath allow REJECT reject -// resolveWritePath allow REJECT allow -// -// resolveEditPath in particular had no direct coverage before this, so a -// refactor could have silently granted it the skills root -- letting an -// agent edit read-only skill files -- with nothing failing. -func TestResolvePathContainment(t *testing.T) { - resolvers := map[string]struct { - fn func(sessionID, skillsDirectory, requestedPath string) (string, error) - allowsSkillsDir bool - allowsNewLeaf bool - // deniedContains is the boundary each policy names when it rejects a - // path, kept here so the wording stays tied to the resolver it - // describes rather than drifting into a generic message. - deniedContains string - }{ - "read": {resolveReadPath, true, false, "outside the allowed roots"}, - "edit": {resolveEditPath, false, false, "outside the writable session directory"}, - "write": {resolveWritePath, false, true, "outside the writable session directory"}, - } - - for name, r := range resolvers { - t.Run(name, func(t *testing.T) { - t.Setenv("TMPDIR", t.TempDir()) - skillsDir := t.TempDir() - if err := os.WriteFile(filepath.Join(skillsDir, "script.py"), []byte("print('ok')\n"), 0644); err != nil { - t.Fatalf("failed to write skill file: %v", err) +func TestResolveToolPath(t *testing.T) { + workspace := t.TempDir() + for _, tt := range []struct{ name, input, want string }{ + {"relative", "notes.txt", filepath.Join(workspace, "notes.txt")}, + {"absolute", "/etc/hosts", "/etc/hosts"}, + {"parent", "../sibling", filepath.Join(workspace, "../sibling")}, + } { + t.Run(tt.name, func(t *testing.T) { + got, err := resolveToolPath(workspace, tt.input) + if err != nil || got != tt.want { + t.Fatalf("got %q, %v; want %q", got, err, tt.want) } - sessionID := fmt.Sprintf("%s-%s", t.Name(), name) - - // Seed a real file in the session dir so the "existing leaf" - // cases have something to resolve to. - sessionPath, err := GetSessionPath(sessionID, skillsDir) - if err != nil { - t.Fatalf("GetSessionPath() error = %v", err) - } - if err := os.WriteFile(filepath.Join(sessionPath, "notes.txt"), []byte("hi\n"), 0644); err != nil { - t.Fatalf("failed to seed session file: %v", err) - } - - t.Run("allows a path inside the session directory", func(t *testing.T) { - if _, err := r.fn(sessionID, skillsDir, "notes.txt"); err != nil { - t.Errorf("expected session-dir path to be allowed, got %v", err) - } - }) - - t.Run("rejects a path outside every root", func(t *testing.T) { - _, err := r.fn(sessionID, skillsDir, "/etc/passwd") - if err == nil { - t.Fatal("expected a path outside the allowed roots to be rejected") - } - if !strings.Contains(err.Error(), r.deniedContains) { - t.Errorf("expected the error to name %q, got %v", r.deniedContains, err) - } - }) - - t.Run("skills directory", func(t *testing.T) { - resolved, err := r.fn(sessionID, skillsDir, "skills/script.py") - if r.allowsSkillsDir { - if err != nil { - t.Fatalf("expected the skills dir to be readable, got %v", err) - } - // The skills dir is reached through a symlink in the - // session dir, so a resolver that returned the unresolved - // path would still look "allowed" -- assert it resolved - // through to the real file. - want, evalErr := filepath.EvalSymlinks(filepath.Join(skillsDir, "script.py")) - if evalErr != nil { - t.Fatalf("EvalSymlinks() error = %v", evalErr) - } - if resolved != want { - t.Errorf("resolved to %q, want %q", resolved, want) - } - return - } - if err == nil { - t.Fatal("expected the read-only skills dir to be rejected for mutation") - } - if !strings.Contains(err.Error(), r.deniedContains) { - t.Errorf("expected the error to name %q, got %v", r.deniedContains, err) - } - }) - - t.Run("not-yet-existing leaf", func(t *testing.T) { - _, err := r.fn(sessionID, skillsDir, "brand-new-file.txt") - if r.allowsNewLeaf && err != nil { - t.Errorf("expected a new file in the session dir to be allowed, got %v", err) - } - if !r.allowsNewLeaf && err == nil { - t.Error("expected a nonexistent path to be rejected") - } - }) - - t.Run("rejects traversal escaping the session directory", func(t *testing.T) { - if _, err := r.fn(sessionID, skillsDir, "../../../etc/passwd"); err == nil { - t.Error("expected ../ traversal out of the session dir to be rejected") - } - }) }) } } -func TestNewSkillExecutionTools_ReturnsExpectedToolSet(t *testing.T) { +func TestNewBuiltinTools_ReturnsExpectedToolSet(t *testing.T) { skillsDir := t.TempDir() skillDir := filepath.Join(skillsDir, "demo") if err := os.MkdirAll(skillDir, 0755); err != nil { @@ -173,9 +80,9 @@ description: Demo skill. t.Fatalf("failed to write skill metadata: %v", err) } - tools, err := NewSkillExecutionTools(skillsDir, true, false) + tools, err := NewBuiltinTools(BuiltinToolsConfig{Workspace: t.TempDir(), SkillsDirectory: skillsDir}) if err != nil { - t.Fatalf("NewSkillExecutionTools() error = %v", err) + t.Fatalf("NewBuiltinTools() error = %v", err) } got := map[string]bool{} @@ -190,12 +97,12 @@ description: Demo skill. } } -func TestNewSkillExecutionTools_OmitsListFilesAndGrepFileByDefault(t *testing.T) { +func TestNewBuiltinTools_DisableFileSearchOmitsListFilesAndGrepFile(t *testing.T) { skillsDir := t.TempDir() - tools, err := NewSkillExecutionTools(skillsDir, false, false) + tools, err := NewBuiltinTools(BuiltinToolsConfig{Workspace: t.TempDir(), SkillsDirectory: skillsDir, DisableFileSearch: true}) if err != nil { - t.Fatalf("NewSkillExecutionTools() error = %v, want nil (list_files/grep_file should be omitted, not fatal)", err) + t.Fatalf("NewBuiltinTools() error = %v, want nil (list_files/grep_file should be omitted, not fatal)", err) } got := map[string]bool{} @@ -216,10 +123,10 @@ func TestNewSkillExecutionTools_OmitsListFilesAndGrepFileByDefault(t *testing.T) } } -func TestNewSkillExecutionTools_DisableBashPreservesFileTools(t *testing.T) { +func TestNewBuiltinTools_DisableBashPreservesFileTools(t *testing.T) { for _, fileSearchEnabled := range []bool{false, true} { t.Run(fmt.Sprintf("file_search=%t", fileSearchEnabled), func(t *testing.T) { - tools, err := NewSkillExecutionTools(t.TempDir(), fileSearchEnabled, true) + tools, err := NewBuiltinTools(BuiltinToolsConfig{Workspace: t.TempDir(), DisableFileSearch: !fileSearchEnabled, DisableBash: true}) if err != nil { t.Fatal(err) } @@ -244,7 +151,7 @@ func TestNewSkillExecutionTools_DisableBashPreservesFileTools(t *testing.T) { } } -func TestNewSkillExecutionTools_BashDescriptionMentionsFileSearchToolsOnlyWhenEnabled(t *testing.T) { +func TestNewBuiltinTools_BashDescriptionMentionsFileSearchToolsOnlyWhenEnabled(t *testing.T) { skillsDir := t.TempDir() findBash := func(t *testing.T, tools []tool.Tool) tool.Tool { @@ -258,10 +165,10 @@ func TestNewSkillExecutionTools_BashDescriptionMentionsFileSearchToolsOnlyWhenEn return nil } - t.Run("disabled by default", func(t *testing.T) { - tools, err := NewSkillExecutionTools(skillsDir, false, false) + t.Run("explicitly disabled", func(t *testing.T) { + tools, err := NewBuiltinTools(BuiltinToolsConfig{Workspace: t.TempDir(), SkillsDirectory: skillsDir, DisableFileSearch: true}) if err != nil { - t.Fatalf("NewSkillExecutionTools() error = %v", err) + t.Fatalf("NewBuiltinTools() error = %v", err) } desc := findBash(t, tools).Description() if strings.Contains(desc, "list_files") || strings.Contains(desc, "grep_file") { @@ -270,9 +177,9 @@ func TestNewSkillExecutionTools_BashDescriptionMentionsFileSearchToolsOnlyWhenEn }) t.Run("mentioned when enabled", func(t *testing.T) { - tools, err := NewSkillExecutionTools(skillsDir, true, false) + tools, err := NewBuiltinTools(BuiltinToolsConfig{Workspace: t.TempDir(), SkillsDirectory: skillsDir}) if err != nil { - t.Fatalf("NewSkillExecutionTools() error = %v", err) + t.Fatalf("NewBuiltinTools() error = %v", err) } desc := findBash(t, tools).Description() if !strings.Contains(desc, "list_files and grep_file") { @@ -283,16 +190,17 @@ func TestNewSkillExecutionTools_BashDescriptionMentionsFileSearchToolsOnlyWhenEn // TestListFilesAndGrepFileTools_RunThroughADK invokes the real functiontool.Run() // path (the same one the ADK flow engine uses to execute a model's tool call), -// rather than calling ListDirContent/GrepContent directly, to verify the -// closures in NewSkillExecutionTools correctly wire path resolution and +// rather than calling listDirContent/grepContent directly, to verify the +// closures in NewBuiltinTools correctly wire path resolution and // argument parsing end-to-end. func TestListFilesAndGrepFileTools_RunThroughADK(t *testing.T) { t.Setenv("TMPDIR", t.TempDir()) skillsDir := t.TempDir() + sessionPath := t.TempDir() - tools, err := NewSkillExecutionTools(skillsDir, true, false) + tools, err := NewBuiltinTools(BuiltinToolsConfig{Workspace: sessionPath, SkillsDirectory: skillsDir}) if err != nil { - t.Fatalf("NewSkillExecutionTools() error = %v", err) + t.Fatalf("NewBuiltinTools() error = %v", err) } var listFilesTool, grepFileTool, readFileTool, bashTool tool.Tool @@ -313,10 +221,6 @@ func TestListFilesAndGrepFileTools_RunThroughADK(t *testing.T) { } sessionID := fmt.Sprintf("%s-session", t.Name()) - sessionPath, err := GetSessionPath(sessionID, skillsDir) - if err != nil { - t.Fatalf("GetSessionPath() error = %v", err) - } if err := os.WriteFile(filepath.Join(sessionPath, "notes.txt"), []byte("hello kagent\nsecond line\n"), 0644); err != nil { t.Fatalf("failed to seed session file: %v", err) } @@ -324,7 +228,7 @@ func TestListFilesAndGrepFileTools_RunThroughADK(t *testing.T) { t.Fatalf("failed to create session subdir: %v", err) } - ctx := &fakeToolContext{sessionID: sessionID} + ctx := &fakeToolContext{StrictContextMock: adkagent.NewStrictContextMock(t.Context()), sessionID: sessionID} t.Run("list_files defaults to the working directory", func(t *testing.T) { result := runTool(t, listFilesTool, ctx, map[string]any{}) @@ -353,15 +257,19 @@ func TestListFilesAndGrepFileTools_RunThroughADK(t *testing.T) { } }) - t.Run("list_files rejects paths outside the session/skills roots", func(t *testing.T) { - result := runTool(t, listFilesTool, ctx, map[string]any{"path": "/etc"}) - if !strings.Contains(result, "outside the allowed roots") { - t.Errorf("list_files result = %q, want an outside-allowed-roots error", result) + t.Run("list_files accepts absolute sandbox paths", func(t *testing.T) { + absolute := t.TempDir() + if err := os.WriteFile(filepath.Join(absolute, "absolute.txt"), []byte("ok"), 0600); err != nil { + t.Fatal(err) + } + result := runTool(t, listFilesTool, ctx, map[string]any{"path": absolute}) + if !strings.Contains(result, "absolute.txt") { + t.Fatal(result) } }) // Exercised here, through the registered tool rather than against - // ReadFileContent directly, because the failure this guards was invisible + // readFileContent directly, because the failure this guards was invisible // at the function level: read_file and grep_file scanned with different // buffer limits, so a file grep_file handled fine made read_file fail // outright. Driving the same tree through both tools is what makes that @@ -421,3 +329,57 @@ func TestListFilesAndGrepFileTools_RunThroughADK(t *testing.T) { } }) } + +// Exercise file and shell calls in a workspace that can be reopened after restore. +func TestBuiltinToolsWithoutSkills(t *testing.T) { + t.Setenv("TMPDIR", t.TempDir()) + workspace := t.TempDir() + builtinTools, err := NewBuiltinTools(BuiltinToolsConfig{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + byName := make(map[string]tool.Tool) + for _, tl := range builtinTools { + byName[tl.Name()] = tl + } + ctx := &fakeToolContext{StrictContextMock: adkagent.NewStrictContextMock(t.Context()), sessionID: "no-skills"} + if got := runTool(t, byName["write_file"], ctx, map[string]any{"file_path": "notes.txt", "content": "hello"}); strings.Contains(got, "Error") { + t.Fatal(got) + } + if got := runTool(t, byName["read_file"], ctx, map[string]any{"file_path": "notes.txt"}); !strings.Contains(got, "hello") { + t.Fatal(got) + } + if got := runTool(t, byName["edit_file"], ctx, map[string]any{"file_path": "notes.txt", "old_string": "hello", "new_string": "updated"}); strings.Contains(got, "Error") { + t.Fatal(got) + } + if got := runTool(t, byName["bash"], ctx, map[string]any{"command": "cat notes.txt"}); got != "updated" { + t.Fatal(got) + } + if got := runTool(t, byName["list_files"], ctx, map[string]any{}); !strings.Contains(got, "notes.txt") { + t.Fatal(got) + } + if got := runTool(t, byName["grep_file"], ctx, map[string]any{"path": "notes.txt", "pattern": "updated"}); !strings.Contains(got, "updated") { + t.Fatal(got) + } + // Reconstruct the tools over the same durable directory, as after Actor replacement. + restored, err := NewBuiltinTools(BuiltinToolsConfig{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + for _, tl := range restored { + if tl.Name() == "read_file" { + got := runTool(t, tl, ctx, map[string]any{"file_path": "notes.txt"}) + if !strings.Contains(got, "updated") { + t.Fatal(got) + } + } + } + outside := filepath.Join(t.TempDir(), "outside.txt") + got := runTool(t, byName["write_file"], ctx, map[string]any{"file_path": outside, "content": "absolute"}) + if strings.Contains(got, "Error") { + t.Fatal(got) + } + if got := runTool(t, byName["read_file"], ctx, map[string]any{"file_path": outside}); !strings.Contains(got, "absolute") { + t.Fatal(got) + } +} diff --git a/go/adk/pkg/tools/command.go b/go/adk/pkg/tools/command.go new file mode 100644 index 0000000000..ae498069b2 --- /dev/null +++ b/go/adk/pkg/tools/command.go @@ -0,0 +1,135 @@ +package tools + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "os" + "os/exec" + "path/filepath" + "slices" + "strings" + "syscall" + "time" +) + +const ( + defaultCommandTimeout = 60 * time.Second + maxCommandOutput = 64 * 1024 + commandTruncationMarker = "\n[output truncated at 65536 bytes; redirect output to a file for the full result]" +) + +type commandConfig struct { + directory string + skillsDirectory string + bashVenvPath string + timeout time.Duration +} + +// commandOutput keeps draining the pipe after the capture limit. Stopping reads +// would block a verbose child and turn output truncation into a command timeout. +// os/exec uses one copying goroutine when stdout and stderr share this writer. +var _ io.Writer = (*commandOutput)(nil) + +type commandOutput struct { + buffer bytes.Buffer + truncated bool +} + +func (o *commandOutput) Write(p []byte) (int, error) { + n := len(p) + keep := min(n, maxCommandOutput-o.buffer.Len()) + _, err := o.buffer.Write(p[:keep]) + o.truncated = o.truncated || keep < n + return n, err +} + +func (o *commandOutput) text() string { + output := strings.TrimSpace(strings.ToValidUTF8(o.buffer.String(), "�")) + if o.truncated { + output += commandTruncationMarker + } + return output +} + +// commandEnvironment preserves the Actor environment, including Substrate's +// egress SDK placeholders. The sandbox owns isolation; this is not a secret filter. +func commandEnvironment(base []string, cfg commandConfig) []string { + value := func(key string) string { + for _, variable := range slices.Backward(base) { + k, v, _ := strings.Cut(variable, "=") + if k == key { + return v + } + } + return "" + } + env := append([]string(nil), base...) + pythonPath := []string{cfg.directory} + if cfg.skillsDirectory != "" { + pythonPath = append(pythonPath, cfg.skillsDirectory) + } + if existing := value("PYTHONPATH"); existing != "" { + pythonPath = append(pythonPath, existing) + } + env = append(env, "PYTHONPATH="+strings.Join(pythonPath, string(os.PathListSeparator))) + if cfg.bashVenvPath != "" { + env = append(env, "PATH="+filepath.Join(cfg.bashVenvPath, "bin")+string(os.PathListSeparator)+value("PATH"), "VIRTUAL_ENV="+cfg.bashVenvPath) + } + return env +} + +func killCommandGroup(cmd *exec.Cmd) error { + if cmd.Process == nil { + return os.ErrProcessDone + } + err := syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + if errors.Is(err, syscall.ESRCH) { + return os.ErrProcessDone + } + return err +} + +// executeCommand owns a command and its descendants for the duration of one tool +// call. Background jobs are stopped on return; durable files live in the workspace. +func executeCommand(ctx context.Context, command string, cfg commandConfig) (output string, resultErr error) { + timeout := cfg.timeout + if timeout == 0 { + timeout = defaultCommandTimeout + } + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + cmd := exec.CommandContext(ctx, "bash", "-c", command) + cmd.Dir = cfg.directory + cmd.Env = commandEnvironment(os.Environ(), cfg) + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { return killCommandGroup(cmd) } + // Descendants can keep output pipes open after bash exits. Bound that wait, + // then stop the remaining process group in the deferred cleanup. + cmd.WaitDelay = time.Second + defer func() { + if err := killCommandGroup(cmd); err != nil && !errors.Is(err, os.ErrProcessDone) { + resultErr = errors.Join(resultErr, fmt.Errorf("failed to stop command processes: %w", err)) + } + }() + var captured commandOutput + cmd.Stdout, cmd.Stderr = &captured, &captured + err := cmd.Run() + output = captured.text() + if ctx.Err() != nil { + return output, fmt.Errorf("command interrupted: %w", ctx.Err()) + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + return output, fmt.Errorf("command failed with exit code %d: %w", exitErr.ExitCode(), err) + } + if err != nil && !errors.Is(err, exec.ErrWaitDelay) { + return output, fmt.Errorf("failed to execute command: %w", err) + } + if output == "" { + output = "Command completed successfully." + } + return output, nil +} diff --git a/go/adk/pkg/tools/command_test.go b/go/adk/pkg/tools/command_test.go new file mode 100644 index 0000000000..3da6923bc0 --- /dev/null +++ b/go/adk/pkg/tools/command_test.go @@ -0,0 +1,213 @@ +package tools + +import ( + "context" + "errors" + "os" + "path/filepath" + "slices" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCommandDiagnostics(t *testing.T) { + for _, tc := range []struct { + name, command string + fail bool + }{ + {"warning", "printf 'out\\n'; printf 'WARNING: diagnostic\\n' >&2", false}, + {"failure", "printf 'out\\n'; printf 'WARNING: diagnostic\\n' >&2; exit 7", true}, + } { + t.Run(tc.name, func(t *testing.T) { + output, err := executeCommand(t.Context(), tc.command, commandConfig{directory: t.TempDir()}) + require.Contains(t, output, "out") + require.Contains(t, output, "WARNING: diagnostic") + if tc.fail { + require.ErrorContains(t, err, "exit code 7") + } else { + require.NoError(t, err) + } + }) + } +} + +func TestCommandOutputIsBoundedAndDrained(t *testing.T) { + directory := t.TempDir() + output, err := executeCommand(t.Context(), "head -c 1048576 /dev/zero; echo finished > finished", commandConfig{directory: directory}) + require.NoError(t, err) + require.True(t, strings.Contains(output, "[output truncated"), "missing truncation marker; bytes=%d", len(output)) + require.LessOrEqual(t, len(output), maxCommandOutput+len(commandTruncationMarker)) + content, err := os.ReadFile(filepath.Join(directory, "finished")) + require.NoError(t, err) + require.Equal(t, "finished\n", string(content)) +} + +func TestCommandTimeoutAndCancellationStopDescendants(t *testing.T) { + for _, cancelCall := range []bool{false, true} { + t.Run(map[bool]string{false: "timeout", true: "cancel"}[cancelCall], func(t *testing.T) { + directory := t.TempDir() + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + cfg := commandConfig{directory: directory, timeout: 50 * time.Millisecond} + if cancelCall { + cfg.timeout = time.Minute + timer := time.AfterFunc(50*time.Millisecond, cancel) + defer timer.Stop() + } + started := time.Now() + output, err := executeCommand(ctx, "echo started; (sleep 0.3; echo leaked > leaked) & wait", cfg) + require.Error(t, err) + require.Contains(t, output, "started") + if cancelCall { + require.ErrorIs(t, err, context.Canceled) + } else { + require.ErrorIs(t, err, context.DeadlineExceeded) + } + require.Less(t, time.Since(started), time.Second) + time.Sleep(350 * time.Millisecond) + _, statErr := os.Stat(filepath.Join(directory, "leaked")) + require.ErrorIs(t, statErr, os.ErrNotExist) + }) + } +} + +func TestCommandDoesNotWaitForBackgroundPipe(t *testing.T) { + started := time.Now() + output, err := executeCommand(t.Context(), "sleep 60 & echo done", commandConfig{directory: t.TempDir()}) + // WaitDelay can report that a child held an output pipe open; it must never hang. + require.True(t, err == nil || strings.Contains(err.Error(), "WaitDelay"), "%v", err) + require.Contains(t, output, "done") + require.Less(t, time.Since(started), 3*time.Second) +} + +func TestCommandEnvironment(t *testing.T) { + cfg := commandConfig{directory: "/data/workspace", skillsDirectory: "/skills/demo", bashVenvPath: "/opt/tools"} + env := commandEnvironment([]string{"PATH=/usr/bin", "PYTHONPATH=/existing", "OPENAI_API_KEY=egress-placeholder"}, cfg) + require.True(t, slices.Contains(env, "OPENAI_API_KEY=egress-placeholder")) + require.Contains(t, env, "PYTHONPATH=/data/workspace:/skills/demo:/existing") + require.Contains(t, env, "PATH=/opt/tools/bin:/usr/bin") + require.Contains(t, env, "VIRTUAL_ENV=/opt/tools") +} + +func TestCommandHonorsVenvAndSkillImports(t *testing.T) { + root := t.TempDir() + venv := filepath.Join(root, "venv") + require.NoError(t, os.MkdirAll(filepath.Join(venv, "bin"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(venv, "bin", "kagent-test"), []byte("#!/bin/sh\nprintf venv"), 0755)) + output, err := executeCommand(t.Context(), "kagent-test; printf '\\n%s\\n%s' \"$VIRTUAL_ENV\" \"$PYTHONPATH\"", commandConfig{directory: root, bashVenvPath: venv, skillsDirectory: "/skills"}) + require.NoError(t, err) + require.Contains(t, output, "venv\n"+venv) + require.Contains(t, output, root+":/skills") +} + +func TestCommandErrorRetainsCause(t *testing.T) { + _, err := executeCommand(t.Context(), "true", commandConfig{directory: filepath.Join(t.TempDir(), "missing")}) + require.True(t, errors.Is(err, os.ErrNotExist), "%v", err) +} + +func TestExecuteCommand(t *testing.T) { + tmpDir := createTempDir(t) + defer os.RemoveAll(tmpDir) + + ctx := context.Background() + + tests := []struct { + name string + command string + workingDir string + wantErr bool + checkFn func(t *testing.T, result string) + }{ + { + name: "simple echo command", + command: "echo 'hello world'", + workingDir: tmpDir, + checkFn: func(t *testing.T, result string) { + if !strings.Contains(result, "hello world") { + t.Errorf("Expected 'hello world' in result, got %q", result) + } + }, + }, + { + name: "command with output", + command: "echo -n 'test'", + workingDir: tmpDir, + checkFn: func(t *testing.T, result string) { + if result != "test" { + t.Errorf("Expected 'test', got %q", result) + } + }, + }, + { + name: "command that creates file", + command: "echo 'content' > test.txt", + workingDir: tmpDir, + checkFn: func(t *testing.T, result string) { + // Check if file was created + filePath := filepath.Join(tmpDir, "test.txt") + content, err := os.ReadFile(filePath) + if err != nil { + t.Fatalf("Failed to read created file: %v", err) + } + if !strings.Contains(string(content), "content") { + t.Errorf("Expected 'content' in file, got %q", string(content)) + } + }, + }, + { + name: "failing command", + command: "false", + workingDir: tmpDir, + wantErr: true, + }, + { + name: "command with stderr", + command: "echo 'error' >&2 && echo 'output'", + workingDir: tmpDir, + checkFn: func(t *testing.T, result string) { + // Should include both stdout and stderr + if !strings.Contains(result, "output") { + t.Error("Expected 'output' in result") + } + // stderr should be included (non-WARNING stderr is appended) + if !strings.Contains(result, "error") { + t.Error("Expected 'error' (from stderr) in result") + } + }, + }, + { + name: "empty output command", + command: "true", + workingDir: tmpDir, + checkFn: func(t *testing.T, result string) { + // Empty output should return success message + if result == "" { + t.Error("Expected success message for empty output") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := executeCommand(ctx, tt.command, commandConfig{directory: tt.workingDir}) + if tt.wantErr { + if err == nil { + t.Error("Expected error, got nil") + } + return + } + + if err != nil { + t.Fatalf("ExecuteCommand() error = %v", err) + } + + if tt.checkFn != nil { + tt.checkFn(t, result) + } + }) + } +} diff --git a/go/adk/pkg/tools/files.go b/go/adk/pkg/tools/files.go new file mode 100644 index 0000000000..b5e7a607e4 --- /dev/null +++ b/go/adk/pkg/tools/files.go @@ -0,0 +1,199 @@ +package tools + +import ( + "bufio" + "context" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" +) + +const ( + maxLineRunes = 2000 + maxLineBytes = 1024 * 1024 + maxFileOutputBytes = 64 * 1024 + defaultReadLines = 2000 + fileTruncationMarker = "[output truncated; use read_file offset/limit or a narrower search]" +) + +// appendResultLine preserves complete lines within the result budget. +func appendResultLine(result *strings.Builder, line string) bool { + if result.Len()+len(line)+1 > maxFileOutputBytes { + result.WriteString(fileTruncationMarker) + return false + } + result.WriteString(line) + result.WriteByte('\n') + return true +} + +// scanFileLines rejects special files and bounds line buffers before visiting. +func scanFileLines(ctx context.Context, path string, visit func(lineNum int, line string) bool) error { + if err := ctx.Err(); err != nil { + return err + } + info, err := os.Stat(path) + if err != nil { + return fmt.Errorf("failed to stat %q: %w", path, err) + } + if !info.Mode().IsRegular() { + return fmt.Errorf("%q is not a regular file", path) + } + + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open %q: %w", path, err) + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Buffer(nil, maxLineBytes) + for lineNum := 1; scanner.Scan(); lineNum++ { + if err := ctx.Err(); err != nil { + return err + } + if !visit(lineNum, scanner.Text()) { + return nil + } + } + if err := scanner.Err(); err != nil { + return fmt.Errorf("failed to read %q: %w", path, err) + } + return nil +} + +// truncateRunes truncates by code point without splitting UTF-8 sequences. +func truncateRunes(s string, maxRunes int) string { + count := 0 + for i := range s { + if count == maxRunes { + return s[:i] + "..." + } + count++ + } + return s +} + +// readFileContent returns a bounded page with source line numbers. +func readFileContent(ctx context.Context, path string, offset, limit int) (string, error) { + var result strings.Builder + start := max(offset, 1) + lineLimit := limit + if lineLimit <= 0 { + lineLimit = defaultReadLines + } + count := 0 + err := scanFileLines(ctx, path, func(lineNum int, line string) bool { + if lineNum < start { + return true + } + count++ + if !appendResultLine(&result, fmt.Sprintf("%6d|%s", lineNum, truncateRunes(line, maxLineRunes))) { + return false + } + if count == lineLimit { + if limit <= 0 { + fmt.Fprintf(&result, "[read limited to %d lines; continue with offset=%d]", lineLimit, lineNum+1) + } + return false + } + return true + }) + if err != nil { + return "", err + } + if result.Len() == 0 { + return "File is empty.", nil + } + return strings.TrimSuffix(result.String(), "\n"), nil +} + +// writeFileContent writes content to a file. +func writeFileContent(path string, content string) error { + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + return os.WriteFile(path, []byte(content), 0644) +} + +// editFileContent performs an exact string replacement in a file. +func editFileContent(path string, oldString, newString string, replaceAll bool) error { + if oldString == "" { + return fmt.Errorf("old_string cannot be empty") + } + if oldString == newString { + return fmt.Errorf("old_string and new_string must be different") + } + + content, err := os.ReadFile(path) + if err != nil { + return err + } + + contentStr := string(content) + if !strings.Contains(contentStr, oldString) { + return fmt.Errorf("old_string not found in %s", path) + } + + count := strings.Count(contentStr, oldString) + if !replaceAll && count > 1 { + return fmt.Errorf("old_string appears %d times in %s. Provide more context or set replace_all=true", count, path) + } + + var newContent string + if replaceAll { + newContent = strings.ReplaceAll(contentStr, oldString, newString) + } else { + newContent = strings.Replace(contentStr, oldString, newString, 1) + } + + return os.WriteFile(path, []byte(newContent), 0644) +} + +// listDirContent streams directory entries without loading the whole listing. +func listDirContent(ctx context.Context, path string) (string, error) { + directory, err := os.Open(path) + if err != nil { + return "", fmt.Errorf("failed to open directory %q: %w", path, err) + } + defer directory.Close() + var result strings.Builder + for { + if err := ctx.Err(); err != nil { + return "", err + } + entries, readErr := directory.ReadDir(128) + if readErr != nil && !errors.Is(readErr, io.EOF) { + return "", fmt.Errorf("failed to read directory %q: %w", path, readErr) + } + for _, entry := range entries { + line := entry.Name() + info, statErr := entry.Info() + if entry.Type()&fs.ModeSymlink != 0 { + info, statErr = os.Stat(filepath.Join(path, entry.Name())) + } + if statErr == nil { + if info.IsDir() { + line += "/" + } else { + line += fmt.Sprintf("\t%d", info.Size()) + } + } + if !appendResultLine(&result, line) { + return result.String(), nil + } + } + if errors.Is(readErr, io.EOF) { + break + } + } + if result.Len() == 0 { + return "Directory is empty.", nil + } + return strings.TrimSuffix(result.String(), "\n"), nil +} diff --git a/go/adk/pkg/tools/shell_test.go b/go/adk/pkg/tools/files_test.go similarity index 70% rename from go/adk/pkg/tools/shell_test.go rename to go/adk/pkg/tools/files_test.go index 2c42ae4829..94b8dbd268 100644 --- a/go/adk/pkg/tools/shell_test.go +++ b/go/adk/pkg/tools/files_test.go @@ -1,7 +1,6 @@ package tools import ( - "context" "fmt" "os" "path/filepath" @@ -20,23 +19,6 @@ func createTempDir(t *testing.T) string { return tmpDir } -func TestGetSessionPath(t *testing.T) { - t.Setenv("TMPDIR", t.TempDir()) - skillsDir := t.TempDir() - - sessionPath, err := GetSessionPath("test-session", skillsDir) - if err != nil { - t.Fatalf("GetSessionPath() error = %v", err) - } - target, err := os.Readlink(filepath.Join(sessionPath, "skills")) - if err != nil { - t.Fatalf("Readlink() error = %v", err) - } - if target != skillsDir { - t.Fatalf("skills link = %q, want %q", target, skillsDir) - } -} - func TestReadFileContent(t *testing.T) { tmpDir := createTempDir(t) defer os.RemoveAll(tmpDir) @@ -147,7 +129,7 @@ func TestReadFileContent(t *testing.T) { } } - result, err := ReadFileContent(tt.path, tt.offset, tt.limit) + result, err := readFileContent(t.Context(), tt.path, tt.offset, tt.limit) if tt.wantErr { if err == nil { t.Error("Expected error, got nil") @@ -156,7 +138,7 @@ func TestReadFileContent(t *testing.T) { } if err != nil { - t.Fatalf("ReadFileContent() error = %v", err) + t.Fatalf("readFileContent(t.Context(), ) error = %v", err) } // Check line number format (skip for empty file message) @@ -193,9 +175,9 @@ func TestReadFileContent_LongLineIsTruncatedNotFatal(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - result, err := ReadFileContent(path, 0, 0) + result, err := readFileContent(t.Context(), path, 0, 0) if err != nil { - t.Fatalf("ReadFileContent() error = %v, want the long line truncated instead", err) + t.Fatalf("readFileContent(t.Context(), ) error = %v, want the long line truncated instead", err) } lines := strings.Split(result, "\n") @@ -228,7 +210,7 @@ func TestReadFileContent_LineOverMaxBytesErrors(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - if _, err := ReadFileContent(path, 0, 0); err == nil { + if _, err := readFileContent(t.Context(), path, 0, 0); err == nil { t.Fatal("expected a line over maxLineBytes to error, got nil") } } @@ -248,14 +230,14 @@ func TestReadFileContent_FIFOReturnsErrorInsteadOfHanging(t *testing.T) { done := make(chan struct{}) var err error go func() { - _, err = ReadFileContent(fifoPath, 0, 0) + _, err = readFileContent(t.Context(), fifoPath, 0, 0) close(done) }() select { case <-done: case <-time.After(5 * time.Second): - t.Fatal("ReadFileContent hung opening a FIFO instead of erroring") + t.Fatal("readFileContent hung opening a FIFO instead of erroring") } if err == nil { @@ -312,9 +294,9 @@ func TestWriteFileContent(t *testing.T) { filePath := filepath.Join(tmpDir, "subdir", "test.txt") content := "test content\nline 2" - err := WriteFileContent(filePath, content) + err := writeFileContent(filePath, content) if err != nil { - t.Fatalf("WriteFileContent() error = %v", err) + t.Fatalf("writeFileContent() error = %v", err) } // Verify file was created @@ -348,8 +330,8 @@ func TestEditFileContent(t *testing.T) { }{ { name: "single replacement", - oldString: "old text", - newString: "new text", + oldString: "old text\nline 3", + newString: "new text\nline 3", replaceAll: false, checkFn: func(t *testing.T, content string) { count := strings.Count(content, "new text") @@ -362,6 +344,8 @@ func TestEditFileContent(t *testing.T) { } }, }, + {name: "repeated long string is ambiguous", oldString: "old text", newString: "new text", wantErr: true}, + {name: "empty old string", oldString: "", newString: "insert", replaceAll: true, wantErr: true}, { name: "replace all", oldString: "old text", @@ -408,7 +392,7 @@ func TestEditFileContent(t *testing.T) { t.Fatalf("Failed to reset file: %v", err) } - err := EditFileContent(filePath, tt.oldString, tt.newString, tt.replaceAll) + err := editFileContent(filePath, tt.oldString, tt.newString, tt.replaceAll) if tt.wantErr { if err == nil { t.Error("Expected error, got nil") @@ -417,7 +401,7 @@ func TestEditFileContent(t *testing.T) { } if err != nil { - t.Fatalf("EditFileContent() error = %v", err) + t.Fatalf("editFileContent() error = %v", err) } // Read and verify content @@ -445,9 +429,9 @@ func TestListDirContent(t *testing.T) { } t.Run("lists files and directories", func(t *testing.T) { - result, err := ListDirContent(tmpDir) + result, err := listDirContent(t.Context(), tmpDir) if err != nil { - t.Fatalf("ListDirContent() error = %v", err) + t.Fatalf("listDirContent(t.Context(), ) error = %v", err) } if !strings.Contains(result, "a-subdir/") { t.Errorf("expected directory entry with trailing slash, got %q", result) @@ -459,9 +443,9 @@ func TestListDirContent(t *testing.T) { t.Run("empty directory", func(t *testing.T) { emptyDir := filepath.Join(tmpDir, "a-subdir") - result, err := ListDirContent(emptyDir) + result, err := listDirContent(t.Context(), emptyDir) if err != nil { - t.Fatalf("ListDirContent() error = %v", err) + t.Fatalf("listDirContent(t.Context(), ) error = %v", err) } if result != "Directory is empty." { t.Errorf("expected empty directory message, got %q", result) @@ -469,7 +453,7 @@ func TestListDirContent(t *testing.T) { }) t.Run("nonexistent path", func(t *testing.T) { - if _, err := ListDirContent(filepath.Join(tmpDir, "does-not-exist")); err == nil { + if _, err := listDirContent(t.Context(), filepath.Join(tmpDir, "does-not-exist")); err == nil { t.Fatal("expected error for nonexistent path") } }) @@ -482,9 +466,9 @@ func TestListDirContent(t *testing.T) { } defer os.Remove(linkPath) - result, err := ListDirContent(tmpDir) + result, err := listDirContent(t.Context(), tmpDir) if err != nil { - t.Fatalf("ListDirContent() error = %v", err) + t.Fatalf("listDirContent(t.Context(), ) error = %v", err) } if !strings.Contains(result, "dir-link/") { t.Errorf("expected symlinked directory to be listed with a trailing slash, got %q", result) @@ -511,9 +495,9 @@ func TestListDirContent(t *testing.T) { t.Skipf("symlinks not supported: %v", err) } - result, err := ListDirContent(linkDir) + result, err := listDirContent(t.Context(), linkDir) if err != nil { - t.Fatalf("ListDirContent() error = %v", err) + t.Fatalf("listDirContent(t.Context(), ) error = %v", err) } if !strings.Contains(result, fmt.Sprintf("file-link\t%d", len(contents))) { t.Errorf("expected symlinked file to report the target's size (%d), got %q", len(contents), result) @@ -532,9 +516,9 @@ func TestListDirContent(t *testing.T) { t.Skipf("symlinks not supported: %v", err) } - result, err := ListDirContent(linkDir) + result, err := listDirContent(t.Context(), linkDir) if err != nil { - t.Fatalf("ListDirContent() error = %v", err) + t.Fatalf("listDirContent(t.Context(), ) error = %v", err) } // Bare name, no size and no trailing slash -- matching Python's // list_dir_content, whose stat() raises on a dangling link. @@ -546,159 +530,3 @@ func TestListDirContent(t *testing.T) { } }) } - -func TestExecuteCommand(t *testing.T) { - tmpDir := createTempDir(t) - defer os.RemoveAll(tmpDir) - - ctx := context.Background() - executor := NewCommandExecutor() - - tests := []struct { - name string - command string - workingDir string - wantErr bool - checkFn func(t *testing.T, result string) - }{ - { - name: "simple echo command", - command: "echo 'hello world'", - workingDir: tmpDir, - checkFn: func(t *testing.T, result string) { - if !strings.Contains(result, "hello world") { - t.Errorf("Expected 'hello world' in result, got %q", result) - } - }, - }, - { - name: "command with output", - command: "echo -n 'test'", - workingDir: tmpDir, - checkFn: func(t *testing.T, result string) { - if result != "test" { - t.Errorf("Expected 'test', got %q", result) - } - }, - }, - { - name: "command that creates file", - command: "echo 'content' > test.txt", - workingDir: tmpDir, - checkFn: func(t *testing.T, result string) { - // Check if file was created - filePath := filepath.Join(tmpDir, "test.txt") - content, err := os.ReadFile(filePath) - if err != nil { - t.Fatalf("Failed to read created file: %v", err) - } - if !strings.Contains(string(content), "content") { - t.Errorf("Expected 'content' in file, got %q", string(content)) - } - }, - }, - { - name: "failing command", - command: "false", - workingDir: tmpDir, - wantErr: true, - }, - { - name: "command with stderr", - command: "echo 'error' >&2 && echo 'output'", - workingDir: tmpDir, - checkFn: func(t *testing.T, result string) { - // Should include both stdout and stderr - if !strings.Contains(result, "output") { - t.Error("Expected 'output' in result") - } - // stderr should be included (non-WARNING stderr is appended) - if !strings.Contains(result, "error") { - t.Error("Expected 'error' (from stderr) in result") - } - }, - }, - { - name: "empty output command", - command: "true", - workingDir: tmpDir, - checkFn: func(t *testing.T, result string) { - // Empty output should return success message - if result == "" { - t.Error("Expected success message for empty output") - } - }, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - result, err := executor.ExecuteCommand(ctx, tt.command, tt.workingDir) - if tt.wantErr { - if err == nil { - t.Error("Expected error, got nil") - } - return - } - - if err != nil { - t.Fatalf("ExecuteCommand() error = %v", err) - } - - if tt.checkFn != nil { - tt.checkFn(t, result) - } - }) - } -} - -func TestExecuteCommand_Timeout(t *testing.T) { - // Skip this test if running in CI or if test timeout is too short - // This test requires at least 35 seconds to run properly - if testing.Short() { - t.Skip("Skipping timeout test in short mode") - } - - tmpDir := createTempDir(t) - defer os.RemoveAll(tmpDir) - - ctx := context.Background() - executor := NewCommandExecutor() - - // Test timeout for long-running command - // The timeout is 30 seconds for non-python commands - // Use a command that will definitely exceed the timeout - // Use sleep 31 to ensure it exceeds 30s timeout but completes faster for testing - command := "sleep 31" // This should timeout after 30 seconds - - start := time.Now() - result, err := executor.ExecuteCommand(ctx, command, tmpDir) - elapsed := time.Since(start) - - // When a command times out, ExecuteCommand should return an error - if err == nil { - // If no error, the command completed (shouldn't happen with sleep 31) - // This could happen if the test environment is very slow or timeout isn't working - t.Errorf("Expected timeout error for sleep 31, but command completed with result: %q (elapsed: %v)", result, elapsed) - return - } - - // Verify the error is a timeout error - if !strings.Contains(err.Error(), "timed out") { - t.Errorf("Expected timeout error, got: %v (elapsed: %v)", err, elapsed) - return - } - - // Verify it actually timed out (should be around 30 seconds, not 31+) - if elapsed < 25*time.Second { - t.Errorf("Command should have taken ~30 seconds to timeout, but only took %v", elapsed) - } - if elapsed > 35*time.Second { - t.Logf("Warning: Timeout took longer than expected (%v), but test passed", elapsed) - } - - // Result should be empty when there's an error - if result != "" { - t.Logf("Note: Got non-empty result on timeout: %q", result) - } -} diff --git a/go/adk/pkg/tools/grep.go b/go/adk/pkg/tools/grep.go index 10f864559a..8cc8e3111f 100644 --- a/go/adk/pkg/tools/grep.go +++ b/go/adk/pkg/tools/grep.go @@ -10,47 +10,23 @@ import ( "strings" ) -// WithinRoot reports whether resolved (an already symlink-resolved path) is -// root itself or nested under it. Callers are responsible for resolving -// symlinks on both arguments first; this is a pure path-containment check. -// -// It lives here with classifyWalkEntry, its main caller, but is also the -// containment test behind resolveSandboxedPath in skills.go. -func WithinRoot(resolved, root string) bool { - resolved = filepath.Clean(resolved) - root = filepath.Clean(root) - return resolved == root || strings.HasPrefix(resolved, root+string(filepath.Separator)) +// withinRoot checks containment after callers have resolved symlinks. +// This bounds search scope; Substrate owns Actor isolation. +func withinRoot(resolved, root string) bool { + relative, err := filepath.Rel(root, resolved) + return err == nil && filepath.IsLocal(relative) } type walkEntryAction int const ( - // walkEntryGrep: a regular, in-bounds file that should be grepped. walkEntryGrep walkEntryAction = iota - // walkEntrySkip: silently excluded by policy, not a read failure -- a - // directory, a symlinked directory, a non-regular file (FIFO/socket/ - // device), or a symlink whose target escapes the search root. walkEntrySkip - // walkEntryUnreadable: a genuine read/stat failure on this entry. walkEntryUnreadable ) -// classifyWalkEntry decides how GrepContent's WalkDir callback should treat -// p, given the resolved search root. It never opens p for reading -- the -// caller is responsible for that once this returns walkEntryGrep, and must -// read the returned resolved path rather than p itself: p is the symlink -// as walked, and re-resolving or reopening it separately from this check -// would reintroduce a TOCTOU window where the symlink's target could change -// between the check and the read. -// -// This narrows that window but doesn't eliminate it: resolved is still a -// path string, so the later os.Open on it is a fresh lookup, not an -// operation on a captured file handle. A path component of resolved could -// still be swapped out between this check and that open. Closing that -// residual race fully would need platform-specific work (e.g. Linux's -// openat2 with RESOLVE_NO_SYMLINKS), which isn't done anywhere else in this -// file either -- this function only guarantees "reads what was just -// verified", not "reads atomically with no concurrent tampering". +// classifyWalkEntry skips special files and symlinks outside the requested +// search tree. It does not impose an additional sandbox boundary. func classifyWalkEntry(root, p string, d fs.DirEntry) (walkEntryAction, string) { if d.IsDir() { return walkEntrySkip, "" @@ -75,7 +51,7 @@ func classifyWalkEntry(root, p string, d fs.DirEntry) (walkEntryAction, string) // connected), and grep has no business reading them. return walkEntrySkip, "" } - if !WithinRoot(resolved, root) { + if !withinRoot(resolved, root) { // The symlink-resolved target escapes the root being searched, so // a symlink can't be used to read files outside the requested // directory. @@ -84,15 +60,11 @@ func classifyWalkEntry(root, p string, d fs.DirEntry) (walkEntryAction, string) return walkEntryGrep, resolved } -// GrepContent searches path for lines matching a regular expression pattern. +// grepContent searches path for lines matching a regular expression pattern. // If path is a directory, recursive must be true to search its files. // -// A recursive search walks the whole tree under path, which is unbounded from -// this function's point of view, so ctx is honored between entries and lets a -// caller abort one. Note Go needs no equivalent of the Python runtime's regex -// timeout: this uses RE2, which is linear-time and cannot backtrack -// catastrophically, so it is the walk rather than the match that can run long. -func GrepContent(ctx context.Context, path, pattern string, recursive, ignoreCase bool) (string, error) { +// Search results and per-line buffers are bounded; cancellation is checked per line. +func grepContent(ctx context.Context, path, pattern string, recursive, ignoreCase bool) (string, error) { expr := pattern if ignoreCase { expr = "(?i)" + expr @@ -109,10 +81,14 @@ func GrepContent(ctx context.Context, path, pattern string, recursive, ignoreCas var result strings.Builder var skipped int + truncated := false grepFile := func(filePath string) error { - return scanFileLines(filePath, func(lineNum int, line string) bool { + return scanFileLines(ctx, filePath, func(lineNum int, line string) bool { if re.MatchString(line) { - fmt.Fprintf(&result, "%s:%d:%s\n", filePath, lineNum, truncateRunes(line, maxLineRunes)) + if !appendResultLine(&result, fmt.Sprintf("%s:%d:%s", filePath, lineNum, truncateRunes(line, maxLineRunes))) { + truncated = true + return false + } } return true }) @@ -122,21 +98,13 @@ func GrepContent(ctx context.Context, path, pattern string, recursive, ignoreCas if !recursive { return "", fmt.Errorf("%q is a directory; set recursive=true to search directories", path) } - // Reuse the outer err (rather than := , which would shadow it in this - // block) so a WalkDir failure below is actually observed by the - // err != nil check after this if/else. var root string root, err = filepath.EvalSymlinks(path) if err != nil { return "", fmt.Errorf("failed to resolve %q: %w", path, err) } - // Walk the resolved root, not path: filepath.WalkDir uses Lstat on - // its root argument, so if path itself were an unresolved directory - // symlink, WalkDir would see a non-directory at the root and never - // descend into it at all. + // WalkDir does not descend through an unresolved symlink root. err = filepath.WalkDir(root, func(p string, d fs.DirEntry, walkErr error) error { - // Checked per entry rather than per line: a single file's scan is - // bounded by its size, but the number of entries is not. if ctxErr := ctx.Err(); ctxErr != nil { return ctxErr } @@ -164,8 +132,14 @@ func GrepContent(ctx context.Context, path, pattern string, recursive, ignoreCas // exceeding the scan buffer, etc.) shouldn't abort matches // already found elsewhere in the tree. if grepErr := grepFile(resolved); grepErr != nil { + if ctx.Err() != nil { + return ctx.Err() + } skipped++ } + if truncated { + return fs.SkipAll + } } return nil }) diff --git a/go/adk/pkg/tools/grep_test.go b/go/adk/pkg/tools/grep_test.go index e42c7dcf0e..5ebda1d7d4 100644 --- a/go/adk/pkg/tools/grep_test.go +++ b/go/adk/pkg/tools/grep_test.go @@ -18,7 +18,7 @@ import ( // classifier fails here with the specific case that broke. // // It is the deliberate mirror of Python's -// test_classify_walk_entry_covers_each_outcome (kagent-skills, shell.py): +// test_classify_walk_entry_covers_each_outcome (kagent-tools, shell.py): // same cases, same order, same expected outcomes. The two runtimes must sort // a tree identically, and reading these two tests side by side is how that // stays true. Prefer changing both, or neither. @@ -139,9 +139,9 @@ func TestGrepContent(t *testing.T) { } t.Run("matches within a single file", func(t *testing.T) { - result, err := GrepContent(context.Background(), filepath.Join(tmpDir, "a.txt"), "hello", false, false) + result, err := grepContent(context.Background(), filepath.Join(tmpDir, "a.txt"), "hello", false, false) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if !strings.Contains(result, "a.txt:1:hello world") { t.Errorf("expected match with path:line:content, got %q", result) @@ -149,9 +149,9 @@ func TestGrepContent(t *testing.T) { }) t.Run("no matches", func(t *testing.T) { - result, err := GrepContent(context.Background(), filepath.Join(tmpDir, "a.txt"), "nope", false, false) + result, err := grepContent(context.Background(), filepath.Join(tmpDir, "a.txt"), "nope", false, false) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if result != "no matches found" { t.Errorf("expected no matches message, got %q", result) @@ -159,9 +159,9 @@ func TestGrepContent(t *testing.T) { }) t.Run("ignore case", func(t *testing.T) { - result, err := GrepContent(context.Background(), filepath.Join(tmpDir, "a.txt"), "foo", false, true) + result, err := grepContent(context.Background(), filepath.Join(tmpDir, "a.txt"), "foo", false, true) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if !strings.Contains(result, "FOO bar") { t.Errorf("expected case-insensitive match, got %q", result) @@ -169,15 +169,15 @@ func TestGrepContent(t *testing.T) { }) t.Run("directory requires recursive", func(t *testing.T) { - if _, err := GrepContent(context.Background(), tmpDir, "foo", false, false); err == nil { + if _, err := grepContent(context.Background(), tmpDir, "foo", false, false); err == nil { t.Fatal("expected error when searching a directory without recursive=true") } }) t.Run("recursive searches subdirectories", func(t *testing.T) { - result, err := GrepContent(context.Background(), tmpDir, "foo", true, true) + result, err := grepContent(context.Background(), tmpDir, "foo", true, true) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if !strings.Contains(result, "b.txt:1:another foo line") { t.Errorf("expected match from subdirectory, got %q", result) @@ -185,7 +185,7 @@ func TestGrepContent(t *testing.T) { }) t.Run("invalid pattern", func(t *testing.T) { - if _, err := GrepContent(context.Background(), filepath.Join(tmpDir, "a.txt"), "(", false, false); err == nil { + if _, err := grepContent(context.Background(), filepath.Join(tmpDir, "a.txt"), "(", false, false); err == nil { t.Fatal("expected error for invalid regex pattern") } }) @@ -204,9 +204,9 @@ func TestGrepContent(t *testing.T) { } defer os.Remove(linkPath) - result, err := GrepContent(context.Background(), tmpDir, "foo", true, true) + result, err := grepContent(context.Background(), tmpDir, "foo", true, true) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if strings.Contains(result, "top secret") { t.Errorf("expected symlinked file outside root to be skipped, got %q", result) @@ -224,9 +224,9 @@ func TestGrepContent(t *testing.T) { } defer os.Remove(linkPath) - result, err := GrepContent(context.Background(), subDir, "foo via symlink", true, false) + result, err := grepContent(context.Background(), subDir, "foo via symlink", true, false) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } // The match must be reported against the resolved target path // (real_target.txt), not the walked symlink path (file_link.txt): @@ -271,9 +271,9 @@ func TestGrepContent(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - result, err := GrepContent(context.Background(), walkDir, "foo", true, false) + result, err := grepContent(context.Background(), walkDir, "foo", true, false) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if !strings.Contains(result, "aaa_first.txt:1:foo one") { t.Errorf("expected match before the symlink, got %q", result) @@ -299,12 +299,12 @@ func TestGrepContent(t *testing.T) { // Pass the unresolved symlink directly, as a caller that doesn't // pre-resolve its path would. - result, err := GrepContent(context.Background(), linkRoot, "foo", true, false) + result, err := grepContent(context.Background(), linkRoot, "foo", true, false) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if !strings.Contains(result, "match.txt:1:foo inside") { - t.Errorf("expected GrepContent to resolve a symlinked root and recurse into it, got %q", result) + t.Errorf("expected grepContent to resolve a symlinked root and recurse into it, got %q", result) } }) @@ -327,18 +327,18 @@ func TestGrepContent(t *testing.T) { var result string var err error go func() { - result, err = GrepContent(context.Background(), fifoDir, "foo", true, false) + result, err = grepContent(context.Background(), fifoDir, "foo", true, false) close(done) }() select { case <-done: case <-time.After(5 * time.Second): - t.Fatal("GrepContent hung on a FIFO instead of skipping it") + t.Fatal("grepContent hung on a FIFO instead of skipping it") } if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if !strings.Contains(result, "aaa_before.txt:1:foo before") { t.Errorf("expected match before the FIFO, got %q", result) @@ -359,14 +359,14 @@ func TestGrepContent(t *testing.T) { done := make(chan struct{}) var err error go func() { - _, err = GrepContent(context.Background(), fifoPath, "foo", false, false) + _, err = grepContent(context.Background(), fifoPath, "foo", false, false) close(done) }() select { case <-done: case <-time.After(5 * time.Second): - t.Fatal("GrepContent hung opening a FIFO directly instead of erroring") + t.Fatal("grepContent hung opening a FIFO directly instead of erroring") } if err == nil { @@ -402,9 +402,9 @@ func TestGrepContent(t *testing.T) { } defer os.Chmod(noPermSub, 0755) - result, err := GrepContent(context.Background(), walkDir, "foo", true, false) + result, err := grepContent(context.Background(), walkDir, "foo", true, false) if err != nil { - t.Fatalf("GrepContent() error = %v, expected the unreadable subdirectory to be skipped rather than aborting the whole search", err) + t.Fatalf("grepContent() error = %v, expected the unreadable subdirectory to be skipped rather than aborting the whole search", err) } if !strings.Contains(result, "match.txt:1:foo readable") { t.Errorf("expected match from the readable sibling directory to survive an unreadable subdirectory elsewhere in the tree, got %q", result) @@ -431,9 +431,9 @@ func TestGrepContent(t *testing.T) { } defer os.Chmod(noPermSub, 0755) - result, err := GrepContent(context.Background(), walkDir, "foo", true, false) + result, err := grepContent(context.Background(), walkDir, "foo", true, false) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if !strings.Contains(result, "no matches found") || !strings.Contains(result, "could not be read") { t.Errorf("expected an annotated no-matches message noting unreadable entries, got %q", result) @@ -468,9 +468,9 @@ func TestGrepContent(t *testing.T) { } defer os.Chmod(noPermSub, 0755) - result, err := GrepContent(context.Background(), walkDir, "foo", true, false) + result, err := grepContent(context.Background(), walkDir, "foo", true, false) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if !strings.Contains(result, "match.txt:1:foo readable") { t.Errorf("expected the real match to still be reported, got %q", result) @@ -495,7 +495,7 @@ func TestGrepContent(t *testing.T) { } defer os.Chmod(walkDir, 0755) - result, err := GrepContent(context.Background(), walkDir, "foo", true, false) + result, err := grepContent(context.Background(), walkDir, "foo", true, false) if err == nil { t.Fatalf("expected an error when the search root itself is unreadable, got a result instead: %q", result) } @@ -510,9 +510,9 @@ func TestGrepContent(t *testing.T) { t.Fatalf("Failed to write test file: %v", err) } - result, err := GrepContent(context.Background(), filepath.Join(tmpDir, "long.txt"), "foo", false, false) + result, err := grepContent(context.Background(), filepath.Join(tmpDir, "long.txt"), "foo", false, false) if err != nil { - t.Fatalf("GrepContent() error = %v", err) + t.Fatalf("grepContent() error = %v", err) } if !strings.HasSuffix(result, "...") { t.Errorf("expected truncated line to end with '...', got %q", result) @@ -538,7 +538,7 @@ func TestGrepContent(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() // already cancelled before the walk starts - _, err := GrepContent(ctx, walkDir, "foo", true, false) + _, err := grepContent(ctx, walkDir, "foo", true, false) if err == nil { t.Fatal("expected a cancelled context to abort the search, got no error") } @@ -547,10 +547,7 @@ func TestGrepContent(t *testing.T) { } }) - t.Run("a non-recursive search is unaffected by context", func(t *testing.T) { - // Single-file reads are bounded by file size, so they intentionally - // don't consult ctx -- pinning that so the cancellation check isn't - // later "helpfully" moved somewhere it would break normal reads. + t.Run("a single-file search honors cancellation", func(t *testing.T) { tmpDir := createTempDir(t) defer os.RemoveAll(tmpDir) if err := os.WriteFile(filepath.Join(tmpDir, "a.txt"), []byte("foo\n"), 0644); err != nil { @@ -560,12 +557,9 @@ func TestGrepContent(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - result, err := GrepContent(ctx, filepath.Join(tmpDir, "a.txt"), "foo", false, false) - if err != nil { - t.Fatalf("GrepContent() error = %v", err) - } - if !strings.Contains(result, "a.txt:1:foo") { - t.Errorf("expected the single-file match, got %q", result) + _, err := grepContent(ctx, filepath.Join(tmpDir, "a.txt"), "foo", false, false) + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected cancellation, got %v", err) } }) } diff --git a/go/adk/pkg/tools/shell.go b/go/adk/pkg/tools/shell.go deleted file mode 100644 index 4456ee7b4a..0000000000 --- a/go/adk/pkg/tools/shell.go +++ /dev/null @@ -1,312 +0,0 @@ -package tools - -import ( - "bufio" - "bytes" - "context" - "fmt" - "io/fs" - "os" - "os/exec" - "path/filepath" - "strings" - "time" -) - -type CommandExecutor struct{} - -// maxLineRunes is the longest line read_file and grep_file will emit before -// truncating. Counted in runes, not bytes -- see truncateRunes. -const maxLineRunes = 2000 - -// maxLineBytes caps how much of a single line scanFileLines will buffer. -// Lines are emitted truncated to maxLineRunes, but grep has to see the whole -// line to match against it, so the read limit is far larger than the emit -// limit. A line longer than this makes its file unreadable, which callers -// surface as an error (read_file) or as a skip count (grep_file). -const maxLineBytes = 1024 * 1024 - -// scanFileLines opens path and hands each line to visit, stopping early if -// visit returns false. Line numbers are 1-based. -// -// This is the single reader behind read_file and grep_file. Both need the -// same three guarantees -- reject non-regular files, cap how much of a line -// is buffered, and surface failures wrapped -- and keeping them in one place -// is what stops the two from drifting apart: an earlier revision set the -// scanner buffer only in the grep path, which left read_file failing outright -// on any file with a line over bufio's 64KB default. -func scanFileLines(path string, visit func(lineNum int, line string) bool) error { - // Stat before opening: os.Open on a FIFO with no writer connected blocks - // indefinitely, and nothing on these paths imposes a timeout. Doing it - // here rather than at each call site means no future caller can omit it. - info, err := os.Stat(path) - if err != nil { - return fmt.Errorf("failed to stat %q: %w", path, err) - } - if !info.Mode().IsRegular() { - return fmt.Errorf("%q is not a regular file", path) - } - - file, err := os.Open(path) - if err != nil { - return fmt.Errorf("failed to open %q: %w", path, err) - } - defer file.Close() - - scanner := bufio.NewScanner(file) - // nil initial buffer, not a fixed 64KB one: bufio grows from 4KB by - // doubling, so a long line still reaches maxLineBytes, but the common - // case -- a recursive grep over a tree of small files -- stops paying - // 64KB of allocation per file. Measured at ~15x less allocated per file. - scanner.Buffer(nil, maxLineBytes) - for lineNum := 1; scanner.Scan(); lineNum++ { - if !visit(lineNum, scanner.Text()) { - return nil - } - } - if err := scanner.Err(); err != nil { - return fmt.Errorf("failed to read %q: %w", path, err) - } - return nil -} - -// truncateRunes shortens s to at most maxRunes code points, appending "..." -// when it truncates. -// -// Slicing a Go string directly (s[:n]) cuts on a byte boundary, which can -// split a multi-byte UTF-8 sequence and leave a partial code point that -// renders as U+FFFD. It also makes the limit mean bytes, so the same line -// would truncate at a different point than in the Python runtime (whose str -// slicing is per-code-point) and than the tool descriptions promise, which -// say "characters". -func truncateRunes(s string, maxRunes int) string { - count := 0 - // Ranging a string yields one iteration per rune, with i the byte index - // where that rune starts -- so at the maxRunes'th rune, s[:i] holds - // exactly maxRunes complete runes. - for i := range s { - if count == maxRunes { - return s[:i] + "..." - } - count++ - } - return s -} - -// GetSessionPath creates the working directory used by skill execution tools. -func GetSessionPath(sessionID, skillsDirectory string) (string, error) { - if sessionID == "" { - return "", fmt.Errorf("sessionID cannot be empty") - } - - basePath := filepath.Join(os.TempDir(), "kagent") - sessionPath := filepath.Clean(filepath.Join(basePath, sessionID)) - if !strings.HasPrefix(sessionPath, filepath.Clean(basePath)+string(filepath.Separator)) { - return "", fmt.Errorf("invalid sessionID: path traversal detected") - } - - if err := os.MkdirAll(filepath.Join(sessionPath, "uploads"), 0755); err != nil { - return "", fmt.Errorf("failed to create uploads directory: %w", err) - } - if err := os.MkdirAll(filepath.Join(sessionPath, "outputs"), 0755); err != nil { - return "", fmt.Errorf("failed to create outputs directory: %w", err) - } - - absSkillsDir, err := filepath.Abs(skillsDirectory) - if err != nil { - absSkillsDir = skillsDirectory - } - skillsLink := filepath.Join(sessionPath, "skills") - if target, err := os.Readlink(skillsLink); err == nil { - if !filepath.IsAbs(target) { - target = filepath.Join(filepath.Dir(skillsLink), target) - } - if filepath.Clean(target) == filepath.Clean(absSkillsDir) { - return sessionPath, nil - } - } - _ = os.Remove(skillsLink) - _ = os.Symlink(absSkillsDir, skillsLink) - return sessionPath, nil -} - -// ReadFileContent reads a file with line numbers. -func ReadFileContent(path string, offset, limit int) (string, error) { - var result strings.Builder - start := max(offset, 1) - count := 0 - - err := scanFileLines(path, func(lineNum int, line string) bool { - if lineNum < start { - return true - } - fmt.Fprintf(&result, "%6d|%s\n", lineNum, truncateRunes(line, maxLineRunes)) - count++ - return limit <= 0 || count < limit - }) - if err != nil { - return "", err - } - - if result.Len() == 0 { - return "File is empty.", nil - } - - return strings.TrimSuffix(result.String(), "\n"), nil -} - -// WriteFileContent writes content to a file. -func WriteFileContent(path string, content string) error { - dir := filepath.Dir(path) - if err := os.MkdirAll(dir, 0755); err != nil { - return err - } - return os.WriteFile(path, []byte(content), 0644) -} - -// EditFileContent performs an exact string replacement in a file. -func EditFileContent(path string, oldString, newString string, replaceAll bool) error { - if oldString == newString { - return fmt.Errorf("old_string and new_string must be different") - } - - content, err := os.ReadFile(path) - if err != nil { - return err - } - - contentStr := string(content) - if !strings.Contains(contentStr, oldString) { - return fmt.Errorf("old_string not found in %s", path) - } - - count := strings.Count(contentStr, oldString) - // If there are multiple occurrences and replaceAll is false, we need to check - // if the old_string is ambiguous (very short or appears in many contexts) - // For now, we'll allow single replacement even with multiple occurrences - // as the test "single_replacement" expects this behavior - // But we'll error if it's clearly ambiguous (like single character or very short word) - if !replaceAll && count > 1 { - // Only error for very short/ambiguous strings (less than 4 chars) - // This allows "old text" (9 chars) to work but "line" (4 chars) to error - if len(strings.TrimSpace(oldString)) < 5 { - return fmt.Errorf("old_string appears %d times in %s. Provide more context or set replace_all=true", count, path) - } - } - - var newContent string - if replaceAll { - newContent = strings.ReplaceAll(contentStr, oldString, newString) - } else { - // Replace only the first occurrence - newContent = strings.Replace(contentStr, oldString, newString, 1) - } - - return os.WriteFile(path, []byte(newContent), 0644) -} - -// ListDirContent lists the entries of a directory, one per line. Directories -// are suffixed with "/"; files are followed by their size in bytes. -func ListDirContent(path string) (string, error) { - entries, err := os.ReadDir(path) - if err != nil { - return "", fmt.Errorf("failed to read directory %q: %w", path, err) - } - - if len(entries) == 0 { - return "Directory is empty.", nil - } - - var result strings.Builder - for _, entry := range entries { - if entry.IsDir() { - fmt.Fprintf(&result, "%s/\n", entry.Name()) - continue - } - - // entry.IsDir() and entry.Info() both describe the entry itself - // (Lstat-like), so for a symlink they report the link rather than - // its target -- the target's type is lost, and Info().Size() is the - // length of the stored target path, not the file's size. os.Stat - // follows the link, so use it for both, matching Python's pathlib - // Path.is_dir()/Path.stat(), which follow symlinks by default. - if entry.Type()&fs.ModeSymlink != 0 { - target, statErr := os.Stat(filepath.Join(path, entry.Name())) - switch { - case statErr != nil: - // Broken link: there is no target to size. Print the bare - // name, as Python does when its stat() raises here. - fmt.Fprintf(&result, "%s\n", entry.Name()) - case target.IsDir(): - fmt.Fprintf(&result, "%s/\n", entry.Name()) - default: - fmt.Fprintf(&result, "%s\t%d\n", entry.Name(), target.Size()) - } - continue - } - - info, err := entry.Info() - if err != nil { - fmt.Fprintf(&result, "%s\n", entry.Name()) - continue - } - fmt.Fprintf(&result, "%s\t%d\n", entry.Name(), info.Size()) - } - - return strings.TrimSuffix(result.String(), "\n"), nil -} - -func NewCommandExecutor() *CommandExecutor { - return &CommandExecutor{} -} - -// ExecuteCommand executes a shell command. -func (e *CommandExecutor) ExecuteCommand(ctx context.Context, command string, workingDir string) (string, error) { - timeout := 30 * time.Second - if strings.Contains(command, "python") { - timeout = 60 * time.Second - } - - ctx, cancel := context.WithTimeout(ctx, timeout) - defer cancel() - - cmd := exec.CommandContext(ctx, "bash", "-c", command) - cmd.Dir = workingDir - - var stdout, stderr bytes.Buffer - cmd.Stdout = &stdout - cmd.Stderr = &stderr - - err := cmd.Run() - if ctx.Err() == context.DeadlineExceeded { - return "", fmt.Errorf("command timed out after %v", timeout) - } - - stdoutStr := stdout.String() - stderrStr := stderr.String() - - if err != nil { - exitCode := -1 - if exitError, ok := err.(*exec.ExitError); ok { - exitCode = exitError.ExitCode() - } - errorMsg := fmt.Sprintf("Command failed with exit code %d", exitCode) - if stderrStr != "" { - errorMsg += ":\n" + stderrStr - } else if stdoutStr != "" { - errorMsg += ":\n" + stdoutStr - } - return "", fmt.Errorf("%s", errorMsg) - } - - output := stdoutStr - if stderrStr != "" && !strings.Contains(strings.ToUpper(stderrStr), "WARNING") { - output += "\n" + stderrStr - } - - res := strings.TrimSpace(output) - if res == "" { - return "Command completed successfully.", nil - } - return res, nil -} diff --git a/go/adk/pkg/tools/skills.go b/go/adk/pkg/tools/skills.go deleted file mode 100644 index 1277c6c5f3..0000000000 --- a/go/adk/pkg/tools/skills.go +++ /dev/null @@ -1,439 +0,0 @@ -package tools - -import ( - "fmt" - "os" - "path/filepath" - "strings" - - adkagent "google.golang.org/adk/v2/agent" - "google.golang.org/adk/v2/tool" - "google.golang.org/adk/v2/tool/functiontool" -) - -const ( - readFileDescription = `Reads a file from the filesystem with line numbers. - -Usage: -- Provide a path to the file (absolute or relative to your working directory) -- Returns content with line numbers (format: LINE_NUMBER|CONTENT) -- Optional offset and limit parameters for reading specific line ranges -- Lines longer than 2000 characters are truncated -- Always read a file before editing it -- You can read from skills/ directory, uploads/, outputs/, or any file in your session` - - writeFileDescription = `Writes content to a file on the filesystem. - -Usage: -- Provide a path (absolute or relative to working directory) and content to write -- Overwrites existing files -- Creates parent directories if needed -- For existing files, read them first using read_file -- Prefer editing existing files over writing new ones -- You can write to your working directory, outputs/, or any writable location -- Note: skills/ directory is read-only` - - editFileDescription = `Performs exact string replacements in files. - -Usage: -- You must read the file first using read_file -- Provide path (absolute or relative to working directory) -- When editing, preserve exact indentation from the file content -- Do NOT include line number prefixes in old_string or new_string -- old_string must be unique unless replace_all=true -- Use replace_all to rename variables/strings throughout the file -- old_string and new_string must be different -- Note: skills/ directory is read-only` - - listFilesDescription = `Lists files and directories at a given path. - -Usage: -- Provide a path (absolute or relative to your working directory); defaults to the working directory -- Directories are listed with a trailing "/"; files are followed by their size in bytes -- You can list skills/ directory, uploads/, outputs/, or any directory in your session` - - grepFileDescription = `Searches for a regular expression pattern in a file or directory. - -Usage: -- Provide a pattern and a path (absolute or relative to your working directory) -- Set recursive=true to search all files under a directory path -- Recursion does not follow symlinked subdirectories (e.g. skills/ is a symlink) - - point path directly at skills/ to search inside it -- Set ignore_case=true for case-insensitive matching -- Returns matching lines as path:line_number:content -- You can search the skills/ directory, uploads/, outputs/, or any file/directory in your session` - - bashDescription = `Execute bash commands in the skills environment with sandbox protection. - -Working Directory & Structure: -- Commands run in a temporary session directory: /tmp/kagent/{session_id}/ -- /skills -> All skills are available here (read-only). -- Your current working directory and /skills are added to PYTHONPATH. - -Python Imports (CRITICAL): -- To import from a skill, use the name of the skill. - Example: from skills_name.module import function -- If the skills name contains a dash '-', you need to use importlib to import it. - Example: - import importlib - skill_module = importlib.import_module('skill-name.module') - -For file operations: -- Use read_file, write_file, and edit_file for interacting with the filesystem. - -Timeouts: -- python scripts: 60s -- other commands: 30s` - - // fileSearchToolsBashHint is appended to bashDescription only when - // list_files/grep_file are enabled, so bash's own description doesn't - // point the model at tools that aren't registered. Appended as a - // trailing paragraph rather than interpolated into bashDescription, so - // the long, free-form prose above stays a plain string -- not a format - // template where a stray '%' added later could silently corrupt output. - fileSearchToolsBashHint = "\nAlso available: list_files and grep_file, for exploring the filesystem without a full shell command." -) - -type bashInput struct { - Command string `json:"command"` - Description string `json:"description,omitempty"` -} - -type readFileInput struct { - FilePath string `json:"file_path"` - Offset int `json:"offset,omitempty"` - Limit int `json:"limit,omitempty"` -} - -type writeFileInput struct { - FilePath string `json:"file_path"` - Content string `json:"content"` -} - -type editFileInput struct { - FilePath string `json:"file_path"` - OldString string `json:"old_string"` - NewString string `json:"new_string"` - ReplaceAll bool `json:"replace_all,omitempty"` -} - -type listFilesInput struct { - Path string `json:"path,omitempty"` -} - -type grepFileInput struct { - Pattern string `json:"pattern"` - Path string `json:"path"` - Recursive bool `json:"recursive,omitempty"` - IgnoreCase bool `json:"ignore_case,omitempty"` -} - -// NewSkillExecutionTools creates the filesystem and shell tools used to execute -// skills. Skill discovery and loading are provided by Go ADK's skilltoolset. -func NewSkillExecutionTools(skillsDirectory string, fileSearchEnabled, disableBash bool) ([]tool.Tool, error) { - skillsDirectory = strings.TrimSpace(skillsDirectory) - if skillsDirectory == "" { - return nil, nil - } - - absSkillsDir, err := filepath.Abs(skillsDirectory) - if err != nil { - return nil, fmt.Errorf("failed to resolve skills directory %q: %w", skillsDirectory, err) - } - if _, err := os.Stat(absSkillsDir); err != nil { - return nil, fmt.Errorf("failed to access skills directory %q: %w", absSkillsDir, err) - } - - readFileTool, err := functiontool.New(functiontool.Config{ - Name: "read_file", - Description: readFileDescription, - }, func(ctx adkagent.Context, in readFileInput) (string, error) { - path, err := resolveReadPath(ctx.SessionID(), absSkillsDir, in.FilePath) - if err != nil { - return fmt.Sprintf("Error reading file %s: %v", strings.TrimSpace(in.FilePath), err), nil - } - - content, err := ReadFileContent(path, in.Offset, in.Limit) - if err != nil { - return fmt.Sprintf("Error reading file %s: %v", strings.TrimSpace(in.FilePath), err), nil - } - return content, nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create read_file tool: %w", err) - } - - writeFileTool, err := functiontool.New(functiontool.Config{ - Name: "write_file", - Description: writeFileDescription, - }, func(ctx adkagent.Context, in writeFileInput) (string, error) { - path, err := resolveWritePath(ctx.SessionID(), absSkillsDir, in.FilePath) - if err != nil { - return fmt.Sprintf("Error writing file %s: %v", strings.TrimSpace(in.FilePath), err), nil - } - - if err := WriteFileContent(path, in.Content); err != nil { - return fmt.Sprintf("Error writing file %s: %v", strings.TrimSpace(in.FilePath), err), nil - } - return fmt.Sprintf("Successfully wrote file: %s", path), nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create write_file tool: %w", err) - } - - editFileTool, err := functiontool.New(functiontool.Config{ - Name: "edit_file", - Description: editFileDescription, - }, func(ctx adkagent.Context, in editFileInput) (string, error) { - path, err := resolveEditPath(ctx.SessionID(), absSkillsDir, in.FilePath) - if err != nil { - return fmt.Sprintf("Error editing file %s: %v", strings.TrimSpace(in.FilePath), err), nil - } - - if err := EditFileContent(path, in.OldString, in.NewString, in.ReplaceAll); err != nil { - return fmt.Sprintf("Error editing file %s: %v", strings.TrimSpace(in.FilePath), err), nil - } - return fmt.Sprintf("Successfully edited file: %s", path), nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create edit_file tool: %w", err) - } - - tools := []tool.Tool{readFileTool, writeFileTool, editFileTool} - - // list_files/grep_file are opt-in: they give an agent broad filesystem - // visibility, so deployments enable them deliberately. - if fileSearchEnabled { - listFilesTool, err := functiontool.New(functiontool.Config{ - Name: "list_files", - Description: listFilesDescription, - }, func(ctx adkagent.Context, in listFilesInput) (string, error) { - requestedPath := in.Path - if strings.TrimSpace(requestedPath) == "" { - requestedPath = "." - } - - path, err := resolveReadPath(ctx.SessionID(), absSkillsDir, requestedPath) - if err != nil { - return fmt.Sprintf("Error listing %s: %v", requestedPath, err), nil - } - - content, err := ListDirContent(path) - if err != nil { - return fmt.Sprintf("Error listing %s: %v", requestedPath, err), nil - } - return content, nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create list_files tool: %w", err) - } - - grepFileTool, err := functiontool.New(functiontool.Config{ - Name: "grep_file", - Description: grepFileDescription, - }, func(ctx adkagent.Context, in grepFileInput) (string, error) { - if strings.TrimSpace(in.Pattern) == "" { - return "Error: No pattern provided", nil - } - if strings.TrimSpace(in.Path) == "" { - return "Error: No file path provided", nil - } - - path, err := resolveReadPath(ctx.SessionID(), absSkillsDir, in.Path) - if err != nil { - return fmt.Sprintf("Error searching %s: %v", strings.TrimSpace(in.Path), err), nil - } - - // ctx is the ADK tool context, which embeds context.Context, so a - // recursive search is abortable by whatever deadline or - // cancellation the caller already set -- and the walk, not the - // match, is the part that can run long here. - // - // No fixed deadline is added on top of that. The Python runtime - // caps grep at 30s, but that bound exists for a hazard Go doesn't - // have: Python's `re` backtracks, so an adversarial pattern can - // hang on a single line, while RE2 is linear-time. Since only the - // walk is unbounded, and its cost scales with the tree the - // deployment itself provisioned, a fixed cap here would break - // large legitimate searches without closing a distinct risk. - content, err := GrepContent(ctx, path, in.Pattern, in.Recursive, in.IgnoreCase) - if err != nil { - return fmt.Sprintf("Error searching %s: %v", strings.TrimSpace(in.Path), err), nil - } - return content, nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create grep_file tool: %w", err) - } - - tools = append(tools, listFilesTool, grepFileTool) - } - - if disableBash { - return tools, nil - } - - commandExecutor := NewCommandExecutor() - desc := bashDescription - if fileSearchEnabled { - desc += fileSearchToolsBashHint - } - bashTool, err := functiontool.New(functiontool.Config{ - Name: "bash", - Description: desc, - }, func(ctx adkagent.Context, in bashInput) (string, error) { - command := strings.TrimSpace(in.Command) - if command == "" { - return "Error: No command provided", nil - } - - sessionPath, err := GetSessionPath(ctx.SessionID(), absSkillsDir) - if err != nil { - return fmt.Sprintf("Error executing command %q: %v", command, err), nil - } - - result, err := commandExecutor.ExecuteCommand(ctx, command, sessionPath) - if err != nil { - return fmt.Sprintf("Error executing command %q: %v", command, err), nil - } - return result, nil - }) - if err != nil { - return nil, fmt.Errorf("failed to create bash tool: %w", err) - } - tools = append(tools, bashTool) - - return tools, nil -} - -// pathPolicy is the sandbox contract for one class of filesystem access. -// The three resolvers below are the same code differing only in these -// fields, so they are expressed as data rather than as flags threaded -// through a shared function. -type pathPolicy struct { - // resolve maps the requested leaf to a real path. EvalSymlinks requires - // it to already exist; resolvePathWithExistingParents tolerates a - // not-yet-created leaf, which only writes need. - resolve func(string) (string, error) - // allowSkillsRoot lets the read-only skills directory count as an allowed - // root. Reads may reach it; edits and writes must not, or an agent could - // modify the skills it was given. - allowSkillsRoot bool - // denied names the boundary in the error an out-of-bounds path gets. It - // is its own field rather than being derived from allowSkillsRoot: the - // two happen to correlate today, but a resolver that denied the skills - // root for a reason other than writability would otherwise be described - // wrongly. - denied string -} - -// TestResolvePathContainment pins this matrix for all three policies. -var ( - readPolicy = pathPolicy{filepath.EvalSymlinks, true, "the allowed roots"} - editPolicy = pathPolicy{filepath.EvalSymlinks, false, "the writable session directory"} - writePolicy = pathPolicy{resolvePathWithExistingParents, false, "the writable session directory"} -) - -// resolveSandboxedPath maps a requested path onto the session directory, -// resolves it under policy, and then requires the result to land inside an -// allowed root -- the check that keeps an agent inside its sandbox. -func resolveSandboxedPath(sessionID, skillsDirectory, requestedPath string, policy pathPolicy) (string, error) { - sessionPath, err := GetSessionPath(sessionID, skillsDirectory) - if err != nil { - return "", err - } - - candidate, err := resolveRequestedPath(sessionPath, requestedPath) - if err != nil { - return "", err - } - - resolvedCandidate, err := policy.resolve(candidate) - if err != nil { - return "", err - } - - sessionRoot, err := filepath.EvalSymlinks(sessionPath) - if err != nil { - return "", err - } - roots := []string{sessionRoot} - - if policy.allowSkillsRoot { - // Resolved eagerly rather than only when the session root misses, so - // an unresolvable skills directory still surfaces as an error the way - // it did before these three were merged into one function. - skillsRoot, err := filepath.EvalSymlinks(skillsDirectory) - if err != nil { - return "", err - } - roots = append(roots, skillsRoot) - } - - for _, root := range roots { - if WithinRoot(resolvedCandidate, root) { - return resolvedCandidate, nil - } - } - - return "", fmt.Errorf("path %q is outside %s", requestedPath, policy.denied) -} - -func resolveReadPath(sessionID, skillsDirectory, requestedPath string) (string, error) { - return resolveSandboxedPath(sessionID, skillsDirectory, requestedPath, readPolicy) -} - -func resolveEditPath(sessionID, skillsDirectory, requestedPath string) (string, error) { - return resolveSandboxedPath(sessionID, skillsDirectory, requestedPath, editPolicy) -} - -func resolveWritePath(sessionID, skillsDirectory, requestedPath string) (string, error) { - return resolveSandboxedPath(sessionID, skillsDirectory, requestedPath, writePolicy) -} - -func resolveRequestedPath(basePath, requestedPath string) (string, error) { - requestedPath = strings.TrimSpace(requestedPath) - if requestedPath == "" { - return "", fmt.Errorf("no file path provided") - } - - candidate := requestedPath - if !filepath.IsAbs(candidate) { - candidate = filepath.Join(basePath, candidate) - } - return filepath.Abs(candidate) -} - -func resolvePathWithExistingParents(path string) (string, error) { - absPath, err := filepath.Abs(path) - if err != nil { - return "", err - } - - current := absPath - for { - if _, err := os.Lstat(current); err == nil { - resolvedBase, err := filepath.EvalSymlinks(current) - if err != nil { - return "", err - } - if current == absPath { - return filepath.Clean(resolvedBase), nil - } - - relativeSuffix, err := filepath.Rel(current, absPath) - if err != nil { - return "", err - } - return filepath.Clean(filepath.Join(resolvedBase, relativeSuffix)), nil - } else if !os.IsNotExist(err) { - return "", err - } - - parent := filepath.Dir(current) - if parent == current { - return "", fmt.Errorf("failed to resolve path %q", path) - } - current = parent - } -} diff --git a/go/api/gen/kagent/config/v1/adk.pb.go b/go/api/gen/kagent/config/v1/adk.pb.go index 7ad4aeb612..447e112e62 100644 --- a/go/api/gen/kagent/config/v1/adk.pb.go +++ b/go/api/gen/kagent/config/v1/adk.pb.go @@ -2809,7 +2809,7 @@ type ADKRuntimeConfig struct { MaxContentLength *int64 `protobuf:"varint,8,opt,name=max_content_length,json=maxContentLength,proto3,oneof" json:"max_content_length,omitempty"` TokenPropagation *TokenPropagationConfig `protobuf:"bytes,9,opt,name=token_propagation,json=tokenPropagation,proto3" json:"token_propagation,omitempty"` Telemetry *TelemetryConfig `protobuf:"bytes,10,opt,name=telemetry,proto3" json:"telemetry,omitempty"` - SkillTools *SkillToolsConfig `protobuf:"bytes,11,opt,name=skill_tools,json=skillTools,proto3" json:"skill_tools,omitempty"` + BuiltinTools *BuiltinToolsConfig `protobuf:"bytes,11,opt,name=builtin_tools,json=builtinTools,proto3" json:"builtin_tools,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -2914,9 +2914,9 @@ func (x *ADKRuntimeConfig) GetTelemetry() *TelemetryConfig { return nil } -func (x *ADKRuntimeConfig) GetSkillTools() *SkillToolsConfig { +func (x *ADKRuntimeConfig) GetBuiltinTools() *BuiltinToolsConfig { if x != nil { - return x.SkillTools + return x.BuiltinTools } return nil } @@ -3240,7 +3240,7 @@ const file_kagent_config_v1_adk_proto_rawDesc = "" + "\x03key\x18\x03 \x01(\tR\x03key\x12\x1d\n" + "\n" + "version_id\x18\x04 \x01(\tR\tversionId\x12\x16\n" + - "\x06region\x18\x05 \x01(\tR\x06region\"\xfd\x06\n" + + "\x06region\x18\x05 \x01(\tR\x06region\"\x83\a\n" + "\x10ADKRuntimeConfig\x12J\n" + "\x11control_plane_url\x18\x01 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x162\x11^https?://[^/?#]+\x88\x01\x01R\x0fcontrolPlaneUrl\x12?\n" + "\vgateway_url\x18\x02 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x162\x11^https?://[^/?#]+\x88\x01\x01R\n" + @@ -3254,9 +3254,8 @@ const file_kagent_config_v1_adk_proto_rawDesc = "" + "\x11token_propagation\x18\t \x01(\v2(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12\xb7\x02\n" + "\ttelemetry\x18\n" + " \x01(\v2!.kagent.config.v1.TelemetryConfigB\xf5\x01\xbaH\xf1\x01\xba\x01\xed\x01\n" + - "\"runtime.telemetry.service_identity\x12bruntime telemetry resource_attributes must not override compiled service.name or service.namespace\x1ac!('service.name' in this.resource_attributes) && !('service.namespace' in this.resource_attributes)R\ttelemetry\x12C\n" + - "\vskill_tools\x18\v \x01(\v2\".kagent.config.v1.SkillToolsConfigR\n" + - "skillToolsB\a\n" + + "\"runtime.telemetry.service_identity\x12bruntime telemetry resource_attributes must not override compiled service.name or service.namespace\x1ac!('service.name' in this.resource_attributes) && !('service.namespace' in this.resource_attributes)R\ttelemetry\x12I\n" + + "\rbuiltin_tools\x18\v \x01(\v2$.kagent.config.v1.BuiltinToolsConfigR\fbuiltinToolsB\a\n" + "\x05_portB\x15\n" + "\x13_max_content_lengthBCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3" @@ -3319,7 +3318,7 @@ var file_kagent_config_v1_adk_proto_goTypes = []any{ (*durationpb.Duration)(nil), // 42: google.protobuf.Duration (*TokenPropagationConfig)(nil), // 43: kagent.config.v1.TokenPropagationConfig (*TelemetryConfig)(nil), // 44: kagent.config.v1.TelemetryConfig - (*SkillToolsConfig)(nil), // 45: kagent.config.v1.SkillToolsConfig + (*BuiltinToolsConfig)(nil), // 45: kagent.config.v1.BuiltinToolsConfig } var file_kagent_config_v1_adk_proto_depIdxs = []int32{ 35, // 0: kagent.config.v1.ADKConfig.runtime:type_name -> kagent.config.v1.ADKRuntimeConfig @@ -3378,7 +3377,7 @@ var file_kagent_config_v1_adk_proto_depIdxs = []int32{ 34, // 53: kagent.config.v1.ADKPluginSource.s3:type_name -> kagent.config.v1.ADKPluginS3Source 43, // 54: kagent.config.v1.ADKRuntimeConfig.token_propagation:type_name -> kagent.config.v1.TokenPropagationConfig 44, // 55: kagent.config.v1.ADKRuntimeConfig.telemetry:type_name -> kagent.config.v1.TelemetryConfig - 45, // 56: kagent.config.v1.ADKRuntimeConfig.skill_tools:type_name -> kagent.config.v1.SkillToolsConfig + 45, // 56: kagent.config.v1.ADKRuntimeConfig.builtin_tools:type_name -> kagent.config.v1.BuiltinToolsConfig 57, // [57:57] is the sub-list for method output_type 57, // [57:57] is the sub-list for method input_type 57, // [57:57] is the sub-list for extension type_name diff --git a/go/api/gen/kagent/config/v1/config.pb.go b/go/api/gen/kagent/config/v1/config.pb.go index 6b687158eb..9b60a3e92b 100644 --- a/go/api/gen/kagent/config/v1/config.pb.go +++ b/go/api/gen/kagent/config/v1/config.pb.go @@ -1711,7 +1711,7 @@ type RuntimeDefaultsConfig struct { Telemetry *TelemetryConfig `protobuf:"bytes,4,opt,name=telemetry,proto3" json:"telemetry,omitempty"` MaxContentLength *int64 `protobuf:"varint,5,opt,name=max_content_length,json=maxContentLength,proto3,oneof" json:"max_content_length,omitempty"` TokenPropagation *TokenPropagationConfig `protobuf:"bytes,6,opt,name=token_propagation,json=tokenPropagation,proto3" json:"token_propagation,omitempty"` - SkillTools *SkillToolsConfig `protobuf:"bytes,7,opt,name=skill_tools,json=skillTools,proto3" json:"skill_tools,omitempty"` + BuiltinTools *BuiltinToolsConfig `protobuf:"bytes,7,opt,name=builtin_tools,json=builtinTools,proto3" json:"builtin_tools,omitempty"` // Per-agent runtime limits, in Kubernetes quantity notation. Cpu *string `protobuf:"bytes,8,opt,name=cpu,proto3,oneof" json:"cpu,omitempty"` Memory *string `protobuf:"bytes,9,opt,name=memory,proto3,oneof" json:"memory,omitempty"` @@ -1791,9 +1791,9 @@ func (x *RuntimeDefaultsConfig) GetTokenPropagation() *TokenPropagationConfig { return nil } -func (x *RuntimeDefaultsConfig) GetSkillTools() *SkillToolsConfig { +func (x *RuntimeDefaultsConfig) GetBuiltinTools() *BuiltinToolsConfig { if x != nil { - return x.SkillTools + return x.BuiltinTools } return nil } @@ -1812,30 +1812,30 @@ func (x *RuntimeDefaultsConfig) GetMemory() string { return "" } -// SkillToolsConfig configures the built-in tools available with local skills. -type SkillToolsConfig struct { - state protoimpl.MessageState `protogen:"open.v1"` - FileSearchTools bool `protobuf:"varint,1,opt,name=file_search_tools,json=fileSearchTools,proto3" json:"file_search_tools,omitempty"` - BashVenvPath string `protobuf:"bytes,2,opt,name=bash_venv_path,json=bashVenvPath,proto3" json:"bash_venv_path,omitempty"` - DisableBash bool `protobuf:"varint,3,opt,name=disable_bash,json=disableBash,proto3" json:"disable_bash,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache +// BuiltinToolsConfig configures the built-in filesystem and shell tools, independently of skills. +type BuiltinToolsConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + DisableFileSearch bool `protobuf:"varint,1,opt,name=disable_file_search,json=disableFileSearch,proto3" json:"disable_file_search,omitempty"` + BashVenvPath string `protobuf:"bytes,2,opt,name=bash_venv_path,json=bashVenvPath,proto3" json:"bash_venv_path,omitempty"` + DisableBash bool `protobuf:"varint,3,opt,name=disable_bash,json=disableBash,proto3" json:"disable_bash,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } -func (x *SkillToolsConfig) Reset() { - *x = SkillToolsConfig{} +func (x *BuiltinToolsConfig) Reset() { + *x = BuiltinToolsConfig{} mi := &file_kagent_config_v1_config_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } -func (x *SkillToolsConfig) String() string { +func (x *BuiltinToolsConfig) String() string { return protoimpl.X.MessageStringOf(x) } -func (*SkillToolsConfig) ProtoMessage() {} +func (*BuiltinToolsConfig) ProtoMessage() {} -func (x *SkillToolsConfig) ProtoReflect() protoreflect.Message { +func (x *BuiltinToolsConfig) ProtoReflect() protoreflect.Message { mi := &file_kagent_config_v1_config_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) @@ -1847,26 +1847,26 @@ func (x *SkillToolsConfig) ProtoReflect() protoreflect.Message { return mi.MessageOf(x) } -// Deprecated: Use SkillToolsConfig.ProtoReflect.Descriptor instead. -func (*SkillToolsConfig) Descriptor() ([]byte, []int) { +// Deprecated: Use BuiltinToolsConfig.ProtoReflect.Descriptor instead. +func (*BuiltinToolsConfig) Descriptor() ([]byte, []int) { return file_kagent_config_v1_config_proto_rawDescGZIP(), []int{25} } -func (x *SkillToolsConfig) GetFileSearchTools() bool { +func (x *BuiltinToolsConfig) GetDisableFileSearch() bool { if x != nil { - return x.FileSearchTools + return x.DisableFileSearch } return false } -func (x *SkillToolsConfig) GetBashVenvPath() string { +func (x *BuiltinToolsConfig) GetBashVenvPath() string { if x != nil { return x.BashVenvPath } return "" } -func (x *SkillToolsConfig) GetDisableBash() bool { +func (x *BuiltinToolsConfig) GetDisableBash() bool { if x != nil { return x.DisableBash } @@ -2138,7 +2138,7 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\bendpoint\x18\x01 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01R\bendpoint\x12;\n" + "\bprotocol\x18\x02 \x01(\tB\x1a\xbaH\x17r\x15R\x04grpcR\rhttp/protobufH\x00R\bprotocol\x88\x01\x01\x12G\n" + "\atimeout\x18\x03 \x01(\v2\x19.google.protobuf.DurationB\x12\xbaH\x0f\xaa\x01\f\"\x04\b\x80\xa3\x052\x04\x10\xc0\x84=R\atimeoutB\v\n" + - "\t_protocol\"\x94\a\n" + + "\t_protocol\"\x9a\a\n" + "\x15RuntimeDefaultsConfig\x12L\n" + "\x11control_plane_url\x18\x01 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01H\x00R\x0fcontrolPlaneUrl\x88\x01\x01\x12A\n" + "\vgateway_url\x18\x02 \x01(\tB\x1b\xbaH\x18r\x162\x11^https?://[^/?#]+\x88\x01\x01H\x01R\n" + @@ -2147,18 +2147,17 @@ const file_kagent_config_v1_config_proto_rawDesc = "" + "\ttelemetry\x18\x04 \x01(\v2!.kagent.config.v1.TelemetryConfigB\xfe\x01\xbaH\xfa\x01\xba\x01\xf6\x01\n" + "+runtime_defaults.telemetry.service_identity\x12bruntime telemetry resource_attributes must not override compiled service.name or service.namespace\x1ac!('service.name' in this.resource_attributes) && !('service.namespace' in this.resource_attributes)R\ttelemetry\x12:\n" + "\x12max_content_length\x18\x05 \x01(\x03B\a\xbaH\x04\"\x02(\x00H\x02R\x10maxContentLength\x88\x01\x01\x12U\n" + - "\x11token_propagation\x18\x06 \x01(\v2(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12C\n" + - "\vskill_tools\x18\a \x01(\v2\".kagent.config.v1.SkillToolsConfigR\n" + - "skillTools\x12\x1e\n" + + "\x11token_propagation\x18\x06 \x01(\v2(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12I\n" + + "\rbuiltin_tools\x18\a \x01(\v2$.kagent.config.v1.BuiltinToolsConfigR\fbuiltinTools\x12\x1e\n" + "\x03cpu\x18\b \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x03R\x03cpu\x88\x01\x01\x12$\n" + "\x06memory\x18\t \x01(\tB\a\xbaH\x04r\x02\x10\x01H\x04R\x06memory\x88\x01\x01B\x14\n" + "\x12_control_plane_urlB\x0e\n" + "\f_gateway_urlB\x15\n" + "\x13_max_content_lengthB\x06\n" + "\x04_cpuB\t\n" + - "\a_memory\"\x87\x01\n" + - "\x10SkillToolsConfig\x12*\n" + - "\x11file_search_tools\x18\x01 \x01(\bR\x0ffileSearchTools\x12$\n" + + "\a_memory\"\x8d\x01\n" + + "\x12BuiltinToolsConfig\x12.\n" + + "\x13disable_file_search\x18\x01 \x01(\bR\x11disableFileSearch\x12$\n" + "\x0ebash_venv_path\x18\x02 \x01(\tR\fbashVenvPath\x12!\n" + "\fdisable_bash\x18\x03 \x01(\bR\vdisableBash\"\x8e\x02\n" + "\x16TokenPropagationConfig\x12\x18\n" + @@ -2207,7 +2206,7 @@ var file_kagent_config_v1_config_proto_goTypes = []any{ (*TelemetryConfig)(nil), // 22: kagent.config.v1.TelemetryConfig (*OTLPConfig)(nil), // 23: kagent.config.v1.OTLPConfig (*RuntimeDefaultsConfig)(nil), // 24: kagent.config.v1.RuntimeDefaultsConfig - (*SkillToolsConfig)(nil), // 25: kagent.config.v1.SkillToolsConfig + (*BuiltinToolsConfig)(nil), // 25: kagent.config.v1.BuiltinToolsConfig (*TokenPropagationConfig)(nil), // 26: kagent.config.v1.TokenPropagationConfig nil, // 27: kagent.config.v1.TelemetryConfig.ResourceAttributesEntry (*durationpb.Duration)(nil), // 28: google.protobuf.Duration @@ -2262,7 +2261,7 @@ var file_kagent_config_v1_config_proto_depIdxs = []int32{ 3, // 46: kagent.config.v1.RuntimeDefaultsConfig.logging:type_name -> kagent.config.v1.LoggingConfig 22, // 47: kagent.config.v1.RuntimeDefaultsConfig.telemetry:type_name -> kagent.config.v1.TelemetryConfig 26, // 48: kagent.config.v1.RuntimeDefaultsConfig.token_propagation:type_name -> kagent.config.v1.TokenPropagationConfig - 25, // 49: kagent.config.v1.RuntimeDefaultsConfig.skill_tools:type_name -> kagent.config.v1.SkillToolsConfig + 25, // 49: kagent.config.v1.RuntimeDefaultsConfig.builtin_tools:type_name -> kagent.config.v1.BuiltinToolsConfig 50, // [50:50] is the sub-list for method output_type 50, // [50:50] is the sub-list for method input_type 50, // [50:50] is the sub-list for extension type_name diff --git a/go/core/internal/translator/settings.go b/go/core/internal/translator/settings.go index ef3209ce55..78f6a7053e 100644 --- a/go/core/internal/translator/settings.go +++ b/go/core/internal/translator/settings.go @@ -37,7 +37,7 @@ func RuntimeConfig(cfg *configv1.RuntimeDefaultsConfig, name, namespace string) Name: name, Namespace: namespace, LogLevel: cfg.GetLogging().GetLevel(), Port: new(uint32(80)), // Preserve optional presence: zero disables the runtime's request limit. MaxContentLength: cfg.MaxContentLength, - SkillTools: proto.CloneOf(cfg.GetSkillTools()), + BuiltinTools: proto.CloneOf(cfg.GetBuiltinTools()), TokenPropagation: proto.CloneOf(cfg.GetTokenPropagation()), Telemetry: proto.CloneOf(cfg.GetTelemetry()), } diff --git a/helm/kagent/tests/controller-configmap_test.yaml b/helm/kagent/tests/controller-configmap_test.yaml index c5c54818dc..ce14b8075e 100644 --- a/helm/kagent/tests/controller-configmap_test.yaml +++ b/helm/kagent/tests/controller-configmap_test.yaml @@ -3,14 +3,18 @@ templates: - controller-configmap.yaml - controller-deployment.yaml tests: - - it: forwards the Go ADK bash setting to runtime defaults + - it: forwards the built-in bash setting to runtime defaults template: controller-configmap.yaml set: - config.runtimeDefaults.skillTools.disableBash: true + config.runtimeDefaults.builtinTools.disableBash: true + config.runtimeDefaults.builtinTools.disableFileSearch: true asserts: - matchRegex: path: data["controller.yaml"] pattern: 'disableBash: true' + - matchRegex: + path: data["controller.yaml"] + pattern: 'disableFileSearch: true' - it: uses full service DNS names for runtime egress template: controller-configmap.yaml diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 78bb4a7acb..05fccbbfea 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -198,8 +198,8 @@ config: # Limits for each agent runtime; Substrate requires both. cpu: "1" memory: 1Gi - skillTools: - fileSearchTools: false + builtinTools: + disableFileSearch: false bashVenvPath: "" disableBash: false logging: diff --git a/proto/kagent/config/v1/adk.proto b/proto/kagent/config/v1/adk.proto index 6605a016f3..8727a4cbb8 100644 --- a/proto/kagent/config/v1/adk.proto +++ b/proto/kagent/config/v1/adk.proto @@ -426,5 +426,5 @@ message ADKRuntimeConfig { message: "runtime telemetry resource_attributes must not override compiled service.name or service.namespace" expression: "!('service.name' in this.resource_attributes) && !('service.namespace' in this.resource_attributes)" }]; - SkillToolsConfig skill_tools = 11; + BuiltinToolsConfig builtin_tools = 11; } diff --git a/proto/kagent/config/v1/config.proto b/proto/kagent/config/v1/config.proto index 3536142d2f..10fb118f9d 100644 --- a/proto/kagent/config/v1/config.proto +++ b/proto/kagent/config/v1/config.proto @@ -346,15 +346,15 @@ message RuntimeDefaultsConfig { }]; optional int64 max_content_length = 5 [(buf.validate.field).int64.gte = 0]; TokenPropagationConfig token_propagation = 6; - SkillToolsConfig skill_tools = 7; + BuiltinToolsConfig builtin_tools = 7; // Per-agent runtime limits, in Kubernetes quantity notation. optional string cpu = 8 [(buf.validate.field).string.min_len = 1]; optional string memory = 9 [(buf.validate.field).string.min_len = 1]; } -// SkillToolsConfig configures the built-in tools available with local skills. -message SkillToolsConfig { - bool file_search_tools = 1; +// BuiltinToolsConfig configures the built-in filesystem and shell tools, independently of skills. +message BuiltinToolsConfig { + bool disable_file_search = 1; string bash_venv_path = 2; bool disable_bash = 3; } diff --git a/python/Dockerfile b/python/Dockerfile index a36aee005a..cc6b72fc83 100644 --- a/python/Dockerfile +++ b/python/Dockerfile @@ -22,7 +22,7 @@ COPY pyproject.toml .python-version uv.lock README.md ./ COPY packages/kagent-adk packages/kagent-adk COPY packages/kagent-core packages/kagent-core COPY packages/kagent-proto packages/kagent-proto -COPY packages/kagent-skills packages/kagent-skills +COPY packages/kagent-tools packages/kagent-tools COPY packages/agentsts-adk packages/agentsts-adk COPY packages/agentsts-core packages/agentsts-core RUN --mount=type=cache,target=/root/.cache/uv \ diff --git a/python/packages/kagent-adk/pyproject.toml b/python/packages/kagent-adk/pyproject.toml index c3c3a178c7..601bb19c65 100644 --- a/python/packages/kagent-adk/pyproject.toml +++ b/python/packages/kagent-adk/pyproject.toml @@ -13,7 +13,7 @@ dependencies = [ "agentsts-core>=0.1.0", "kagent-core>=0.2.0", "kagent-proto>=0.1.0", - "kagent-skills>=0.1.0", + "kagent-tools>=0.1.0", "aiofiles>=24.1.0", "anyio>=4.14.2", "typer>=0.27.0", @@ -49,7 +49,7 @@ dependencies = [ [tool.uv.sources] kagent-core = {workspace = true} kagent-proto = {workspace = true} -kagent-skills = {workspace = true} +kagent-tools = {workspace = true} agentsts-adk = {workspace = true} agentsts-core = {workspace = true} diff --git a/python/packages/kagent-adk/src/kagent/adk/artifacts/__init__.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/__init__.py deleted file mode 100644 index f8cc4a093c..0000000000 --- a/python/packages/kagent-adk/src/kagent/adk/artifacts/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -from .artifacts_toolset import ArtifactsToolset -from .return_artifacts_tool import ReturnArtifactsTool -from .session_path import clear_session_cache, get_session_path, initialize_session_path -from .stage_artifacts_tool import StageArtifactsTool - -__all__ = [ - "ArtifactsToolset", - "ReturnArtifactsTool", - "StageArtifactsTool", - "get_session_path", - "initialize_session_path", - "clear_session_cache", -] diff --git a/python/packages/kagent-adk/src/kagent/adk/artifacts/artifacts_toolset.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/artifacts_toolset.py deleted file mode 100644 index 38cf3ecb29..0000000000 --- a/python/packages/kagent-adk/src/kagent/adk/artifacts/artifacts_toolset.py +++ /dev/null @@ -1,56 +0,0 @@ -from __future__ import annotations - -import logging -from typing import List, Optional - -try: - from typing_extensions import override -except ImportError: - from typing import override - -from google.adk.agents.readonly_context import ReadonlyContext -from google.adk.tools import BaseTool -from google.adk.tools.base_toolset import BaseToolset - -from .return_artifacts_tool import ReturnArtifactsTool -from .stage_artifacts_tool import StageArtifactsTool - -logger = logging.getLogger("kagent_adk." + __name__) - - -class ArtifactsToolset(BaseToolset): - """Toolset for managing artifact upload and download workflows. - - This toolset provides tools for the complete artifact lifecycle: - 1. StageArtifactsTool - Download artifacts from artifact service to working directory - 2. ReturnArtifactsTool - Upload generated files from working directory to artifact service - - Artifacts enable file-based interactions: - - Users upload files via frontend → stored as artifacts - - StageArtifactsTool copies them to working directory for processing - - Processing tools (bash, skills, etc.) work with files on disk - - ReturnArtifactsTool saves generated outputs back as artifacts - - Users download results via frontend - - This toolset is independent of skills and can be used with any processing workflow. - """ - - def __init__(self): - """Initialize the artifacts toolset.""" - super().__init__() - - # Create artifact lifecycle tools - self.stage_artifacts_tool = StageArtifactsTool() - self.return_artifacts_tool = ReturnArtifactsTool() - - @override - async def get_tools(self, readonly_context: Optional[ReadonlyContext] = None) -> List[BaseTool]: - """Get both artifact tools. - - Returns: - List containing StageArtifactsTool and ReturnArtifactsTool. - """ - return [ - self.stage_artifacts_tool, - self.return_artifacts_tool, - ] diff --git a/python/packages/kagent-adk/src/kagent/adk/artifacts/return_artifacts_tool.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/return_artifacts_tool.py deleted file mode 100644 index 24dadbd4f7..0000000000 --- a/python/packages/kagent-adk/src/kagent/adk/artifacts/return_artifacts_tool.py +++ /dev/null @@ -1,160 +0,0 @@ -"""Tool for returning generated files from working directory to artifact service.""" - -from __future__ import annotations - -import logging -import mimetypes -from pathlib import Path -from typing import Any, Dict, List - -from google.adk.tools import BaseTool, ToolContext -from google.genai import types -from typing_extensions import override - -from .session_path import get_session_path -from .stage_artifacts_tool import MAX_ARTIFACT_SIZE_BYTES - -logger = logging.getLogger("kagent_adk." + __name__) - - -class ReturnArtifactsTool(BaseTool): - """Save generated files from working directory to artifact service for user download. - - This tool enables users to download outputs generated during processing. - Files are saved to the artifact service where they can be retrieved by the frontend. - """ - - def __init__(self): - super().__init__( - name="return_artifacts", - description=( - "Save generated files from the working directory to the artifact service, " - "making them available for user download.\n\n" - "WORKFLOW:\n" - "1. Generate output files in the 'outputs/' directory\n" - "2. Use this tool to save those files to the artifact service\n" - "3. Users can then download the files via the frontend\n\n" - "USAGE EXAMPLE:\n" - "- bash('python scripts/analyze.py > outputs/report.txt')\n" - "- return_artifacts(file_paths=['outputs/report.txt'])\n" - " Returns: 'Saved 1 file(s): report.txt (v0, 15.2 KB)'\n\n" - "PARAMETERS:\n" - "- file_paths: List of relative paths from working directory (required)\n" - "- artifact_names: Optional custom names for artifacts (default: use filename)\n\n" - "BEST PRACTICES:\n" - "- Generate outputs in 'outputs/' directory for clarity\n" - "- Use descriptive filenames (they become artifact names)\n" - "- Return all outputs at once for efficiency" - ), - ) - - def _get_declaration(self) -> types.FunctionDeclaration | None: - return types.FunctionDeclaration( - name=self.name, - description=self.description, - parameters=types.Schema( - type=types.Type.OBJECT, - properties={ - "file_paths": types.Schema( - type=types.Type.ARRAY, - description=( - "List of relative file paths from the working directory to save as artifacts. " - "Example: ['outputs/report.pdf', 'outputs/data.csv']. " - "Files must exist in the working directory and be within size limits." - ), - items=types.Schema(type=types.Type.STRING), - ), - "artifact_names": types.Schema( - type=types.Type.ARRAY, - description=( - "Optional custom names for the artifacts. " - "If not provided, the filename will be used. " - "Must match the length of file_paths if provided." - ), - items=types.Schema(type=types.Type.STRING), - ), - }, - required=["file_paths"], - ), - ) - - @override - async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str: - file_paths: List[str] = args.get("file_paths", []) - artifact_names: List[str] = args.get("artifact_names", []) - - if not file_paths: - return "Error: No file paths provided." - - if artifact_names and len(artifact_names) != len(file_paths): - return "Error: artifact_names length must match file_paths length." - - if not tool_context._invocation_context.artifact_service: - return "Error: Artifact service is not available in this context." - - try: - working_dir = get_session_path(session_id=tool_context.session.id) - - saved_artifacts = [] - for idx, rel_path in enumerate(file_paths): - file_path = (working_dir / rel_path).resolve() - - # Security: Ensure file is within working directory - if not file_path.is_relative_to(working_dir): - logger.warning(f"Skipping file outside working directory: {rel_path}") - continue - - # Check file exists - if not file_path.exists(): - logger.warning(f"File not found: {rel_path}") - continue - - # Check file size - file_size = file_path.stat().st_size - if file_size > MAX_ARTIFACT_SIZE_BYTES: - size_mb = file_size / (1024 * 1024) - logger.warning(f"File too large: {rel_path} ({size_mb:.1f} MB)") - continue - - # Determine artifact name - artifact_name = artifact_names[idx] if artifact_names else file_path.name - - # Read file data and detect MIME type - file_data = file_path.read_bytes() - mime_type = self._detect_mime_type(file_path) - - # Create artifact Part - artifact_part = types.Part.from_bytes(data=file_data, mime_type=mime_type) - - # Save to artifact service - version = await tool_context.save_artifact( - filename=artifact_name, - artifact=artifact_part, - ) - - size_kb = file_size / 1024 - saved_artifacts.append(f"{artifact_name} (v{version}, {size_kb:.1f} KB)") - logger.info(f"Saved artifact: {artifact_name} v{version} ({size_kb:.1f} KB)") - - if not saved_artifacts: - return "No valid files were saved as artifacts." - - return f"Saved {len(saved_artifacts)} file(s) for download:\n" + "\n".join( - f" • {artifact}" for artifact in saved_artifacts - ) - - except Exception as e: - logger.error("Error returning artifacts: %s", e, exc_info=True) - return f"An error occurred while returning artifacts: {e}" - - def _detect_mime_type(self, file_path: Path) -> str: - """Detect MIME type from file extension. - - Args: - file_path: Path to the file - - Returns: - MIME type string, defaults to 'application/octet-stream' if unknown - """ - mime_type, _ = mimetypes.guess_type(str(file_path)) - return mime_type or "application/octet-stream" diff --git a/python/packages/kagent-adk/src/kagent/adk/artifacts/session_path.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/session_path.py deleted file mode 100644 index 55e0263cea..0000000000 --- a/python/packages/kagent-adk/src/kagent/adk/artifacts/session_path.py +++ /dev/null @@ -1,106 +0,0 @@ -import logging -import tempfile -from pathlib import Path - -logger = logging.getLogger("kagent_adk." + __name__) - -# Cache of initialized session paths to avoid re-creating symlinks -_session_path_cache: dict[str, Path] = {} - - -def initialize_session_path(session_id: str, skills_directory: str) -> Path: - """Initialize a session's working directory with skills symlink. - - This is called by SkillsPlugin.before_agent_callback() to ensure the session - is set up before any tools run. Creates the directory structure and symlink - to the skills directory. - - Directory structure: - /tmp/kagent/{session_id}/ - ├── skills/ -> symlink to skills_directory (read-only shared skills) - ├── uploads/ -> staged user files (temporary) - └── outputs/ -> generated files for return - - Args: - session_id: The unique ID of the current session. - skills_directory: Path to the shared skills directory. - - Returns: - The resolved path to the session's root directory. - """ - # Return cached path if already initialized - if session_id in _session_path_cache: - return _session_path_cache[session_id] - - # Initialize new session path - base_path = Path(tempfile.gettempdir()) / "kagent" - session_path = base_path / session_id - - # Create working directories - (session_path / "uploads").mkdir(parents=True, exist_ok=True) - (session_path / "outputs").mkdir(parents=True, exist_ok=True) - - # Create symlink to skills directory - skills_mount = Path(skills_directory) - skills_link = session_path / "skills" - if skills_mount.exists() and not skills_link.exists(): - try: - skills_link.symlink_to(skills_mount) - logger.debug(f"Created symlink: {skills_link} -> {skills_mount}") - except FileExistsError: - # Symlink already exists (race condition from concurrent session setup) - pass - except Exception as e: - # Log but don't fail - skills can still be accessed via absolute path - logger.warning(f"Failed to create skills symlink for session {session_id}: {e}") - - # Cache and return - resolved_path = session_path.resolve() - _session_path_cache[session_id] = resolved_path - return resolved_path - - -def get_session_path(session_id: str) -> Path: - """Get the working directory path for a session. - - This function retrieves the cached session path that was initialized by - SkillsPlugin. If the session hasn't been initialized (plugin not used), - it falls back to auto-initialization with default /skills directory. - - Tools should call this function to get their working directory. The session - must be initialized by SkillsPlugin before tools run, which happens automatically - via the before_agent_callback() hook. - - Args: - session_id: The unique ID of the current session. - - Returns: - The resolved path to the session's root directory. - - Note: - If session is not initialized, automatically initializes with /skills. - For custom skills directories, ensure SkillsPlugin is installed. - """ - # Return cached path if already initialized - if session_id in _session_path_cache: - return _session_path_cache[session_id] - - # Fallback: auto-initialize with default /skills - logger.warning( - f"Session {session_id} not initialized by SkillsPlugin. " - f"Auto-initializing with default /skills. " - f"Install SkillsPlugin for custom skills directories." - ) - return initialize_session_path(session_id, "/skills") - - -def clear_session_cache(session_id: str | None = None) -> None: - """Clear cached session path(s). - - Args: - session_id: Specific session to clear. If None, clears all cached sessions. - """ - if session_id: - _session_path_cache.pop(session_id, None) - else: - _session_path_cache.clear() diff --git a/python/packages/kagent-adk/src/kagent/adk/artifacts/stage_artifacts_tool.py b/python/packages/kagent-adk/src/kagent/adk/artifacts/stage_artifacts_tool.py deleted file mode 100644 index de46dbd085..0000000000 --- a/python/packages/kagent-adk/src/kagent/adk/artifacts/stage_artifacts_tool.py +++ /dev/null @@ -1,170 +0,0 @@ -from __future__ import annotations - -import logging -import mimetypes -from pathlib import Path -from typing import Any, List - -from google.adk.tools import BaseTool, ToolContext -from google.genai import types -from typing_extensions import override - -from .session_path import get_session_path - -logger = logging.getLogger("kagent_adk." + __name__) - -# Maximum file size for staging (100 MB) -MAX_ARTIFACT_SIZE_BYTES = 100 * 1024 * 1024 - - -class StageArtifactsTool(BaseTool): - """A tool to stage artifacts from the artifact service to the local filesystem. - - This tool enables working with user-uploaded files by staging them from the - artifact store to a local working directory where they can be accessed by - scripts, commands, and other tools. - - Workflow: - 1. Stage: Copy artifacts from artifact store to local 'uploads/' directory - 2. Access: Use the staged files in commands, scripts, or other processing - """ - - def __init__(self): - super().__init__( - name="stage_artifacts", - description=( - "Stage artifacts from the artifact store to a local filesystem path, " - "making them available for processing by tools and scripts.\n\n" - "WORKFLOW:\n" - "1. When a user uploads a file, it's stored as an artifact with a name\n" - "2. Use this tool to copy the artifact to your local 'uploads/' directory\n" - "3. Then reference the staged file path in commands or scripts\n\n" - "USAGE EXAMPLE:\n" - "- stage_artifacts(artifact_names=['data.csv'])\n" - " Returns: 'Successfully staged 1 file(s): uploads/data.csv (1.2 MB)'\n" - "- Then use: bash('python scripts/process.py uploads/data.csv')\n\n" - "PARAMETERS:\n" - "- artifact_names: List of artifact names to stage (required)\n" - "- destination_path: Target directory within session (default: 'uploads/')\n\n" - "BEST PRACTICES:\n" - "- Always stage artifacts before using them\n" - "- Use default 'uploads/' destination for consistency\n" - "- Stage all artifacts at the start of your workflow\n" - "- Check returned paths to confirm successful staging" - ), - ) - - def _get_declaration(self) -> types.FunctionDeclaration | None: - return types.FunctionDeclaration( - name=self.name, - description=self.description, - parameters=types.Schema( - type=types.Type.OBJECT, - properties={ - "artifact_names": types.Schema( - type=types.Type.ARRAY, - description=( - "List of artifact names to stage. These are artifact identifiers " - "provided by the system when files are uploaded. " - "The tool will copy each artifact from the artifact store to the destination directory." - ), - items=types.Schema(type=types.Type.STRING), - ), - "destination_path": types.Schema( - type=types.Type.STRING, - description=( - "Relative path within the session directory to save the files. " - "Default is 'uploads/' where user-uploaded files are conventionally stored. " - "Path must be within the session directory for security. " - "Useful for organizing different types of artifacts (e.g., 'uploads/input/', 'uploads/processed/')." - ), - default="uploads/", - ), - }, - required=["artifact_names"], - ), - ) - - @override - async def run_async(self, *, args: dict[str, Any], tool_context: ToolContext) -> str: - artifact_names: List[str] = args.get("artifact_names", []) - destination_path_str: str = args.get("destination_path", "uploads/") - - if not tool_context._invocation_context.artifact_service: - return "Error: Artifact service is not available in this context." - - try: - staging_root = get_session_path(session_id=tool_context.session.id) - destination_dir = (staging_root / destination_path_str).resolve() - - # Security: Ensure the destination is within the staging path - if staging_root not in destination_dir.parents and destination_dir != staging_root: - return f"Error: Invalid destination path '{destination_path_str}'." - - destination_dir.mkdir(parents=True, exist_ok=True) - - staged_files = [] - for name in artifact_names: - artifact = await tool_context.load_artifact(name) - if artifact is None or artifact.inline_data is None: - logger.warning('Artifact "%s" not found or has no data, skipping', name) - continue - - # Check file size - data_size = len(artifact.inline_data.data) - if data_size > MAX_ARTIFACT_SIZE_BYTES: - size_mb = data_size / (1024 * 1024) - logger.warning(f'Artifact "{name}" exceeds size limit: {size_mb:.1f} MB') - continue - - # Use artifact name as filename (frontend should provide meaningful names) - # If name has no extension, try to infer from MIME type - filename = self._ensure_proper_extension(name, artifact.inline_data.mime_type) - output_file = destination_dir / filename - - # Write file to disk - output_file.write_bytes(artifact.inline_data.data) - - relative_path = output_file.relative_to(staging_root) - size_kb = data_size / 1024 - staged_files.append(f"{relative_path} ({size_kb:.1f} KB)") - - logger.info(f"Staged artifact: {name} -> {relative_path} ({size_kb:.1f} KB)") - - if not staged_files: - return "No valid artifacts were staged." - - return f"Successfully staged {len(staged_files)} file(s):\n" + "\n".join( - f" • {file}" for file in staged_files - ) - - except Exception as e: - logger.error("Error staging artifacts: %s", e, exc_info=True) - return f"An error occurred while staging artifacts: {e}" - - def _ensure_proper_extension(self, filename: str, mime_type: str) -> str: - """Ensure filename has proper extension based on MIME type. - - If filename already has an extension, keep it. - If not, add extension based on MIME type. - - Args: - filename: Original filename from artifact - mime_type: MIME type of the file - - Returns: - Filename with proper extension - """ - if not filename or not mime_type: - return filename - - # If filename already has an extension, use it - if Path(filename).suffix: - return filename - - # Try to infer extension from MIME type - extension = mimetypes.guess_extension(mime_type) - if extension: - return f"{filename}{extension}" - - return filename diff --git a/python/packages/kagent-adk/src/kagent/adk/cli.py b/python/packages/kagent-adk/src/kagent/adk/cli.py index a8ee1ca9dc..51eb4a09ca 100644 --- a/python/packages/kagent-adk/src/kagent/adk/cli.py +++ b/python/packages/kagent-adk/src/kagent/adk/cli.py @@ -18,7 +18,7 @@ from kagent.core.telemetry import conv from . import Config, KAgentApp -from .tools import add_skills_tool_to_agent +from .tools import add_builtin_tools_to_agent from .types import create_agent, parse_config logger = logging.getLogger(__name__) @@ -36,15 +36,23 @@ def create_sts_integration(settings: TokenPropagationConfig) -> Optional[ADKToke return None -def maybe_add_skills_with_config(root_agent: BaseAgent, config: Optional[Config] = None): - if config and config.agent.skills_directory: - runtime = config.runtime - add_skills_tool_to_agent( - config.agent.skills_directory, - root_agent, - file_search_tools=runtime.skill_tools.file_search_tools, - bash_venv_path=runtime.skill_tools.bash_venv_path, +def add_tools_with_config(root_agent: BaseAgent, config: Optional[Config] = None): + runtime = config.runtime if config is not None else RuntimeConfig() + + def add_tools(agent: BaseAgent, agent_config=None): + settings = runtime.builtin_tools + add_builtin_tools_to_agent( + agent_config.skills_directory if agent_config and agent_config.skills_directory else None, + agent, + disable_file_search=settings.disable_file_search, + bash_venv_path=settings.bash_venv_path, + disable_bash=settings.disable_bash, ) + child_configs = {child.name: child for child in agent_config.sub_agents} if agent_config else {} + for child in agent.sub_agents: + add_tools(child, child_configs.get(child.name)) + + add_tools(root_agent, config.agent if config is not None else None) def parse_agent_card(data: dict) -> AgentCard: @@ -87,7 +95,7 @@ def static( def root_agent_factory() -> BaseAgent: root_agent = create_agent(config.agent, app_cfg.name, sts_integration, runtime.token_propagation.enabled) - maybe_add_skills_with_config(root_agent, config) + add_tools_with_config(root_agent, config) return root_agent @@ -175,7 +183,7 @@ def root_agent_factory() -> BaseAgent: if sts_integration: add_to_agent(sts_integration, root_agent) - maybe_add_skills_with_config(root_agent, config) + add_tools_with_config(root_agent, config) return root_agent @@ -237,7 +245,7 @@ async def test_agent(config: Config, agent_card: AgentCard, task: str): def root_agent_factory() -> BaseAgent: root_agent = create_agent(config.agent, app_cfg.name, sts_integration, runtime.token_propagation.enabled) - maybe_add_skills_with_config(root_agent, config) + add_tools_with_config(root_agent, config) return root_agent app = KAgentApp(root_agent_factory, agent_card, app_cfg.api_url, app_cfg.app_name, plugins=plugins, config=config) diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/README.md b/python/packages/kagent-adk/src/kagent/adk/tools/README.md index 26cd318e77..fe6d42379b 100644 --- a/python/packages/kagent-adk/src/kagent/adk/tools/README.md +++ b/python/packages/kagent-adk/src/kagent/adk/tools/README.md @@ -1,221 +1,43 @@ -# ADK Skills - -Filesystem-based skills with progressive disclosure and two-tool architecture for domain expertise. - ---- - -## Quick Start - -### Recommended: Plugin-Based (Multi-Agent Apps) - -```python -from kagent.adk.skills import SkillsPlugin - -# Plugin automatically initializes sessions and registers all skills tools -app = App(root_agent=agent, plugins=[SkillsPlugin(skills_directory="./skills")]) -``` - -**Benefits:** - -- ✅ Session paths initialized before any tool runs -- ✅ Automatic tool registration on all agents -- ✅ Handles custom skills directories correctly -- ✅ No tool call order dependencies - -### Alternative: Direct Tool Usage - -```python -from kagent.adk.skills import SkillsTool -from kagent.adk.tools import BashTool, ReadFileTool, WriteFileTool, EditFileTool, ListFilesTool, GrepFileTool - -agent = Agent( - tools=[ - SkillsTool(skills_directory="./skills"), - BashTool(skills_directory="./skills"), - ReadFileTool(skills_directory="./skills"), - WriteFileTool(), - EditFileTool(), - ListFilesTool(skills_directory="./skills"), - GrepFileTool(skills_directory="./skills"), - ] -) -``` - -**Note:** Without SkillsPlugin, sessions auto-initialize with `/skills` directory. For custom skills paths, use the plugin. - ---- - -## Session Initialization - -Skills uses a **plugin-based initialization pattern** to ensure session working directories are set up before any tools run. - -### How It Works - -```text -App Starts - ↓ -SkillsPlugin initialized with skills_directory - ↓ -First Agent Turn - ↓ -before_agent_callback() hook fires - ↓ -Session path initialized with skills symlink - ↓ -Tools registered on agent - ↓ -Tools execute (session already initialized) -``` - -**Key Points:** - -- `SkillsPlugin.before_agent_callback()` fires **before any tool invocation** -- Creates `/tmp/kagent/{session_id}/` with `skills/` symlink -- All tools use `get_session_path(session_id)` which returns cached path -- **No tool call order dependencies** - session always ready - -**Without Plugin:** - -- Tools auto-initialize session with default `/skills` on first call -- Works fine if skills are at `/skills` location -- For custom paths, use SkillsPlugin - ---- - -## Architecture - -### Skill Structure - -```text -skills/ -├── data-analysis/ -│ ├── SKILL.md # Metadata (YAML frontmatter) + instructions -│ └── scripts/ # Python scripts, configs, etc. -│ └── analyze.py -``` - -**SKILL.md Example:** - -```markdown ---- -name: data-analysis -description: Analyze CSV/Excel files ---- - -# Data Analysis - -...instructions for the agent... -``` - -### Tool Workflow - -**Three Phases:** - -1. **Discovery** - Agent sees available skills in SkillsTool description -2. **Loading** - Agent calls `skills(command='data-analysis')` → gets full SKILL.md -3. **Execution** - Agent uses BashTool + file tools to run scripts per instructions - -| Tool | Purpose | Example | -| -------------- | ---------------------------- | ----------------------------------------------------- | -| **SkillsTool** | Load skill instructions | `skills(command='data-analysis')` | -| **BashTool** | Execute commands | `bash("cd skills/data-analysis && python script.py")` | -| **ReadFile** | Read files with line numbers | `read_file("skills/data-analysis/config.json")` | -| **WriteFile** | Create/overwrite files | `write_file("outputs/report.pdf", data)` | -| **EditFile** | Precise string replacements | `edit_file("script.py", old="x", new="y")` | -| **ListFiles** | List a directory's contents | `list_files("skills/data-analysis")` | -| **GrepFile** | Search files by pattern | `grep_file("skills", pattern="analyze", recursive=True)` | - -### Working Directory Structure - -Each session gets an isolated working directory with symlinked skills: - -```text -/tmp/kagent/{session_id}/ -├── skills/ → symlink to /skills (read-only, shared across sessions) -├── uploads/ → staged user files (writable) -├── outputs/ → generated files for download (writable) -└── *.py → temporary scripts (writable) -``` - -**Path Resolution:** - -- Relative paths resolve from working directory: `skills/data-analysis/script.py` -- Absolute paths work too: `/tmp/kagent/{session_id}/outputs/report.pdf` -- Skills symlink enables natural relative references while maintaining security - ---- - -## Artifact Handling - -User uploads and downloads are managed through artifact tools: - -```python -# 1. Stage uploaded file from artifact service -stage_artifacts(artifact_names=["sales_data.csv"]) -# → Writes to: uploads/sales_data.csv - -# 2. Agent processes file -bash("python skills/data-analysis/scripts/analyze.py uploads/sales_data.csv") -# → Script writes: outputs/report.pdf - -# 3. Return generated file -return_artifacts(file_paths=["outputs/report.pdf"]) -# → Saves to artifact service for user download -``` - -**Flow:** User Upload → Artifact Service → `uploads/` → Processing → `outputs/` → Artifact Service → User Download - ---- - -## Security - -**Read-only skills directory:** - -- Skills at `/skills` are read-only (enforced by sandbox) -- Symlink at `skills/` inherits read-only permissions -- Agents cannot modify skill code or instructions - -**File tools:** - -- Path traversal protection (no `..`) -- Session isolation (each session has separate working directory) -- File size limits (100 MB max) -- `grep_file` skips symlinked entries that resolve outside the directory being searched, so a symlink can't be used to read files outside the session's working directory -- `recursive=True` does not descend into symlinked subdirectories (this is standard Go/Python stdlib walk behavior, not something this tool adds) — since `skills/` is itself a symlink, a recursive search from the working directory root will not find matches inside it; pass `skills/...` as the path directly to search skill contents -- `list_files`/`grep_file` are disabled by default — they give an agent broad filesystem visibility, so they're opt-in. Set `runtime.skill_tools.file_search_tools` to `true` in the runtime JSON config, or `config.runtimeDefaults.skillTools.fileSearchTools` in Helm values for managed runtimes (`read_file`/`write_file`/`edit_file`/`bash` are unaffected and always available in this Python runtime) - -**Bash tool:** - -- Sandboxed execution via Anthropic Sandbox Runtime -- Command timeouts (30s default, 120s for pip install) -- Working directory restrictions - ---- - -## Example Agent Flow - -```python -# User asks: "Analyze my sales data" - -# 1. Agent discovers available skills -# → SkillsTool description lists: data-analysis, pdf-processing, etc. - -# 2. Agent loads skill instructions -agent: skills(command="data-analysis") -# → Returns full SKILL.md with detailed instructions - -# 3. Agent stages uploaded file -agent: stage_artifacts(artifact_names=["sales_data.csv"]) -# → File available at: uploads/sales_data.csv - -# 4. Agent reads skill script to understand it -agent: read_file("skills/data-analysis/scripts/analyze.py") - -# 5. Agent executes analysis -agent: bash("cd skills/data-analysis && python scripts/analyze.py ../../uploads/sales_data.csv") -# → Script generates: outputs/analysis_report.pdf - -# 6. Agent returns result -agent: return_artifacts(file_paths=["outputs/analysis_report.pdf"]) -# → User can download report.pdf -``` +# Built-in file and shell tools + +ADK agents get `read_file`, `write_file`, `edit_file`, `list_files`, `grep_file`, +and `bash` independently of skills. Set `runtime.builtin_tools.disable_file_search` +or `disable_bash` to omit the corresponding tools. + +The default working directory is `/data/workspace`. It belongs to the Actor, +is shared by its root agent and subagents, and survives VOLUMES snapshots and resume. +Go and Python use the same workspace layout; subdirectories are created as needed. +Paths follow normal filesystem semantics; Substrate is the security boundary. +Skills are discovered separately and use their configured absolute paths. + +Each bash call starts a fresh shell, inherits the Actor environment, and returns +combined stdout and stderr. It defaults to 60 seconds; `timeout_seconds` can select +up to 600 seconds. Output capture is capped at 64 KiB and marked when truncated. +Redirect large output to a file to inspect the complete result. On failure, the +exit code and captured output are retained. Child processes are stopped on return, +timeout, or cancellation. Use workspace files for durable work, not background jobs. + +`bash_venv_path` prepends that environment's `bin` directory to `PATH` and sets +`VIRTUAL_ENV`. The workspace and optional skills directory are added to `PYTHONPATH`. +Shell helpers do not log command payloads or output. + +Python's regular expression engine can backtrack indefinitely. Its `grep_file` +keeps a worker-process timeout for this reason; Go uses its linear-time regexp +engine. Recursive searches do not follow symlinked subdirectories. + +File reads default to 2000 lines. Read, list, and grep results are capped at +64 KiB; use read offsets or narrower searches for additional content. Edits +require a nonempty `old_string`, which must be unique unless `replace_all=true`. + +## Skills and artifacts + +Go and Python ADK use their upstream skills toolsets. Python exposes +`list_skills`, `load_skill`, and `load_skill_resource`; scripts run through the +common bash tool. A small prompt adapter directs scripts to bash when available +and describes the execution limit when it is absent. Skill discovery, loading, +and resource access remain upstream implementations. Skills are registered only +from each agent's configured `skills_directory`. Subagents do not implicitly load `/skills`. + +The unused custom Python skills bundle and artifact staging/return tools have +been removed. Python retains ADK's upstream artifact service for native A2A +integration. The workspace does not precreate `uploads/` or `outputs/`. diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py b/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py index cee479c00a..f9c654b813 100644 --- a/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py +++ b/python/packages/kagent-adk/src/kagent/adk/tools/__init__.py @@ -1,17 +1,13 @@ from .bash_tool import BashTool +from .builtin_tools import add_builtin_tools_to_agent from .file_tools import EditFileTool, GrepFileTool, ListFilesTool, ReadFileTool, WriteFileTool -from .skill_tool import SkillsTool -from .skills_plugin import add_skills_tool_to_agent -from .skills_toolset import SkillsToolset __all__ = [ - "SkillsTool", - "SkillsToolset", "BashTool", "EditFileTool", "ReadFileTool", "WriteFileTool", "ListFilesTool", "GrepFileTool", - "add_skills_tool_to_agent", + "add_builtin_tools_to_agent", ] diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/bash_tool.py b/python/packages/kagent-adk/src/kagent/adk/tools/bash_tool.py index 79d746717b..d06fe6d347 100644 --- a/python/packages/kagent-adk/src/kagent/adk/tools/bash_tool.py +++ b/python/packages/kagent-adk/src/kagent/adk/tools/bash_tool.py @@ -1,4 +1,4 @@ -"""Simplified bash tool for executing shell commands in skills context.""" +"""Bash tool for a Substrate Actor.""" from __future__ import annotations @@ -8,7 +8,7 @@ from google.adk.tools import BaseTool, ToolContext from google.genai import types -from kagent.skills import execute_command, get_bash_description, get_session_path +from kagent.tools import execute_command, get_bash_description, get_workspace_path logger = logging.getLogger("kagent_adk." + __name__) @@ -16,15 +16,21 @@ class BashTool(BaseTool): """Execute bash commands in the agent runtime.""" - def __init__(self, skills_directory: str | Path, *, file_search_tools: bool = False, bash_venv_path: str = ""): + def __init__( + self, skills_directory: str | Path | None, *, file_search_tools: bool = True, bash_venv_path: str = "" + ): super().__init__( name="bash", - description=get_bash_description(file_search_tools), + description=get_bash_description(file_search_tools) + + ( + f"\nSkills directory: {Path(skills_directory).resolve()}. " + "Execute skill scripts using their absolute paths." + if skills_directory + else "" + ), ) self.bash_venv_path = bash_venv_path - self.skills_directory = Path(skills_directory).resolve() - if not self.skills_directory.exists(): - raise ValueError(f"Skills directory does not exist: {self.skills_directory}") + self.skills_directory = Path(skills_directory).resolve() if skills_directory else None def _get_declaration(self) -> types.FunctionDeclaration: return types.FunctionDeclaration( @@ -37,6 +43,10 @@ def _get_declaration(self) -> types.FunctionDeclaration: type=types.Type.STRING, description="Bash command to execute. Use && to chain commands.", ), + "timeout_seconds": types.Schema( + type=types.Type.INTEGER, + description="Timeout in seconds, 1 to 600; omitted or 0 uses 60 seconds.", + ), "description": types.Schema( type=types.Type.STRING, description="Clear, concise description of what this command does (5-10 words)", @@ -49,22 +59,21 @@ def _get_declaration(self) -> types.FunctionDeclaration: async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str: """Execute a bash command.""" command = args.get("command", "").strip() - description = args.get("description", "") if not command: return "Error: No command provided" try: - working_dir = get_session_path(session_id=tool_context.session.id) + working_dir = get_workspace_path() result = await execute_command( command, working_dir, self.skills_directory, bash_venv_path=self.bash_venv_path, + timeout_seconds=args.get("timeout_seconds", 0), ) - logger.info(f"Executed bash command: {command}, description: {description}") return result except Exception as e: - error_msg = f"Error executing command '{command}': {e}" + error_msg = f"Error executing command: {e}" logger.error(error_msg) return error_msg diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/builtin_tools.py b/python/packages/kagent-adk/src/kagent/adk/tools/builtin_tools.py new file mode 100644 index 0000000000..7a56c07cb2 --- /dev/null +++ b/python/packages/kagent-adk/src/kagent/adk/tools/builtin_tools.py @@ -0,0 +1,106 @@ +from __future__ import annotations + +import logging +from pathlib import Path + +from google.adk.agents import BaseAgent, LlmAgent +from google.adk.models.llm_request import LlmRequest +from google.adk.skills import Skill, load_skills_from_dir +from google.adk.tools import ToolContext +from google.adk.tools.skill_toolset import SkillToolset + +from .bash_tool import BashTool +from .file_tools import EditFileTool, GrepFileTool, ListFilesTool, ReadFileTool, WriteFileTool + +logger = logging.getLogger("kagent_adk." + __name__) + + +class _SandboxSkillToolset(SkillToolset): + """Use upstream skill tools with guidance for the shared sandbox bash tool.""" + + def __init__(self, skills: list[Skill], directory: Path, bash_available: bool): + super().__init__(skills=skills, tool_filter=["list_skills", "load_skill", "load_skill_resource"]) + self._guidance = ( + "Use list_skills to discover local skills, then load_skill to read a skill's " + "instructions before following them. Use load_skill_resource to read bundled " + "references, assets, and scripts. Loading a skill does not complete the task; " + "continue using the tools its instructions require. " + ) + if bash_available: + self._guidance += ( + f"Run bundled scripts with the bash tool using absolute paths under {directory}//scripts/." + ) + else: + self._guidance += ( + "The bash tool is unavailable. Do not execute bundled scripts; " + "you may inspect them with load_skill_resource." + ) + + async def process_llm_request(self, *, tool_context: ToolContext, llm_request: LlmRequest) -> None: + # Upstream's default prompt assumes its own script executor. We only expose + # its discovery/loading tools; execution belongs to the shared bash tool. + llm_request.append_instructions([self._guidance]) + + +def add_builtin_tools_to_agent( + skills_directory: str | Path | None, + agent: BaseAgent, + *, + disable_file_search: bool = False, + bash_venv_path: str = "", + disable_bash: bool = False, +) -> None: + """Add built-in tools and any discovered local skills to an LLM agent.""" + + if not isinstance(agent, LlmAgent): + return + + skills_directory = Path(skills_directory).resolve() if skills_directory else None + existing_tool_names = {getattr(t, "name", getattr(t, "__name__", None)) for t in agent.tools} + + if ( + skills_directory is not None + and skills_directory.is_dir() + and not any(isinstance(tool, SkillToolset) for tool in agent.tools) + ): + skills = load_skills_from_dir(skills_directory) + if skills: + # Skills describe behavior; scripts execute through the common bash tool. + agent.tools.append( + _SandboxSkillToolset( + skills, skills_directory, bash_available="bash" in existing_tool_names or not disable_bash + ) + ) + + # Add BashTool if not already present + if not disable_bash and "bash" not in existing_tool_names: + agent.tools.append( + BashTool(skills_directory, file_search_tools=not disable_file_search, bash_venv_path=bash_venv_path) + ) + logger.debug(f"Added bash tool to agent: {agent.name}") + + if "read_file" not in existing_tool_names: + agent.tools.append(ReadFileTool()) + logger.debug(f"Added read file tool to agent: {agent.name}") + + if "write_file" not in existing_tool_names: + agent.tools.append(WriteFileTool()) + logger.debug(f"Added write file tool to agent: {agent.name}") + + if "edit_file" not in existing_tool_names: + agent.tools.append(EditFileTool()) + logger.debug(f"Added edit file tool to agent: {agent.name}") + + # File search tools are available unless explicitly disabled. + if not disable_file_search: + if "list_files" not in existing_tool_names: + agent.tools.append(ListFilesTool()) + logger.debug(f"Added list files tool to agent: {agent.name}") + + if "grep_file" not in existing_tool_names: + agent.tools.append(GrepFileTool()) + logger.debug(f"Added grep file tool to agent: {agent.name}") + else: + logger.debug( + f"Omitting list_files/grep_file tools for agent: {agent.name} (runtime.builtin_tools.disable_file_search enabled)" + ) diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py b/python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py index 7b5a9f1ff6..3cbfeab810 100644 --- a/python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py +++ b/python/packages/kagent-adk/src/kagent/adk/tools/file_tools.py @@ -17,13 +17,13 @@ from google.adk.tools import BaseTool, ToolContext from google.genai import types -from kagent.skills import ( +from kagent.tools import ( edit_file_content, get_edit_file_description, get_grep_file_description, get_list_files_description, get_read_file_description, - get_session_path, + get_workspace_path, get_write_file_description, grep_content, list_dir_content, @@ -89,7 +89,7 @@ async def _run_with_deadline(call: Callable[[], str], timeout_seconds: float) -> "spawn" rather than the Linux default "fork": this server is threaded, and forking a threaded process can deadlock a child that inherits a lock held by a thread that does not exist in it. Callers pass a callable defined in - kagent.skills, so the spawned child imports only that light module, never + kagent.tools, so the spawned child imports only that light module, never this one's google.adk dependency chain. """ loop = asyncio.get_running_loop() @@ -109,30 +109,20 @@ async def _run_with_deadline(call: Callable[[], str], timeout_seconds: float) -> executor.shutdown(wait=False, cancel_futures=True) -def _resolve_working_path(tool_context: ToolContext, path_str: str) -> tuple[Path, Path]: - """Resolve path_str relative to the session's working directory. - - Returns (resolved_path, working_dir); callers use working_dir to build - their allowed_root argument. - """ - working_dir = get_session_path(session_id=tool_context.session.id) +def _resolve_working_path(path_str: str) -> Path: + """Resolve ordinary sandbox paths relative to the durable workspace.""" path = Path(path_str) - if not path.is_absolute(): - path = working_dir / path - return path.resolve(), working_dir + return (get_workspace_path() / path).resolve() if not path.is_absolute() else path.resolve() class ReadFileTool(BaseTool): """Read files with line numbers for precise editing.""" - def __init__(self, skills_directory: str | Path): + def __init__(self): super().__init__( name="read_file", description=get_read_file_description(), ) - self.skills_directory = Path(skills_directory).resolve() - if not self.skills_directory.exists(): - raise ValueError(f"Skills directory does not exist: {self.skills_directory}") def _get_declaration(self) -> types.FunctionDeclaration: return types.FunctionDeclaration( @@ -168,9 +158,9 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> return "Error: No file path provided" try: - path, working_dir = _resolve_working_path(tool_context, file_path_str) + path = _resolve_working_path(file_path_str) - return read_file_content(path, offset, limit, allowed_root=[working_dir, Path(self.skills_directory)]) + return read_file_content(path, offset, limit) except (FileNotFoundError, IsADirectoryError, PermissionError, IOError) as e: return f"Error reading file {file_path_str}: {e}" @@ -213,9 +203,9 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> return "Error: No file path provided" try: - path, working_dir = _resolve_working_path(tool_context, file_path_str) + path = _resolve_working_path(file_path_str) - return write_file_content(path, content, allowed_root=working_dir) + return write_file_content(path, content) except (PermissionError, IOError) as e: error_msg = f"Error writing file {file_path_str}: {e}" logger.error(error_msg) @@ -225,14 +215,11 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> class ListFilesTool(BaseTool): """List files and directories at a given path.""" - def __init__(self, skills_directory: str | Path): + def __init__(self): super().__init__( name="list_files", description=get_list_files_description(), ) - self.skills_directory = Path(skills_directory).resolve() - if not self.skills_directory.exists(): - raise ValueError(f"Skills directory does not exist: {self.skills_directory}") def _get_declaration(self) -> types.FunctionDeclaration: return types.FunctionDeclaration( @@ -254,9 +241,9 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> path_str = args.get("path", "").strip() or "." try: - path, working_dir = _resolve_working_path(tool_context, path_str) + path = _resolve_working_path(path_str) - return list_dir_content(path, allowed_root=[working_dir, Path(self.skills_directory)]) + return list_dir_content(path) except (FileNotFoundError, NotADirectoryError, PermissionError, IOError) as e: return f"Error listing {path_str}: {e}" @@ -271,14 +258,11 @@ class GrepFileTool(BaseTool): # _run_with_deadline for why that is required rather than merely tidy. _TIMEOUT_SECONDS = 30 - def __init__(self, skills_directory: str | Path): + def __init__(self): super().__init__( name="grep_file", description=get_grep_file_description(), ) - self.skills_directory = Path(skills_directory).resolve() - if not self.skills_directory.exists(): - raise ValueError(f"Skills directory does not exist: {self.skills_directory}") def _get_declaration(self) -> types.FunctionDeclaration: return types.FunctionDeclaration( @@ -325,7 +309,7 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> return "Error: No file path provided" try: - path, working_dir = _resolve_working_path(tool_context, path_str) + path = _resolve_working_path(path_str) return await _run_with_deadline( functools.partial( @@ -334,7 +318,6 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> pattern, recursive=recursive, ignore_case=ignore_case, - allowed_root=[working_dir, Path(self.skills_directory)], ), self._TIMEOUT_SECONDS, ) @@ -394,9 +377,9 @@ async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> return "Error: No file path provided" try: - path, working_dir = _resolve_working_path(tool_context, file_path_str) + path = _resolve_working_path(file_path_str) - return edit_file_content(path, old_string, new_string, replace_all, allowed_root=working_dir) + return edit_file_content(path, old_string, new_string, replace_all) except (FileNotFoundError, IsADirectoryError, ValueError, PermissionError, IOError) as e: error_msg = f"Error editing file {file_path_str}: {e}" logger.error(error_msg) diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/skill_tool.py b/python/packages/kagent-adk/src/kagent/adk/tools/skill_tool.py deleted file mode 100644 index bb47d2fac5..0000000000 --- a/python/packages/kagent-adk/src/kagent/adk/tools/skill_tool.py +++ /dev/null @@ -1,103 +0,0 @@ -"""Tool for discovering and loading skills.""" - -from __future__ import annotations - -import logging -from pathlib import Path -from typing import Any, Dict - -from google.adk.tools import BaseTool, ToolContext -from google.genai import types -from kagent.skills import ( - discover_skills, - generate_skills_tool_description, - load_skill_content, -) - -logger = logging.getLogger("kagent_adk." + __name__) - - -class SkillsTool(BaseTool): - """Discover and load skill instructions. - - This tool dynamically discovers available skills and embeds their metadata in the - tool description. Agent invokes a skill by name to load its full instructions. - """ - - def __init__(self, skills_directory: str | Path): - self.skills_directory = Path(skills_directory).resolve() - if not self.skills_directory.exists(): - raise ValueError(f"Skills directory does not exist: {self.skills_directory}") - - self._skill_cache: Dict[str, str] = {} - - # Generate description with available skills embedded - description = self._generate_description_with_skills() - - super().__init__( - name="skills", - description=description, - ) - - def _generate_description_with_skills(self) -> str: - """Generate tool description with available skills embedded.""" - skills = discover_skills(self.skills_directory) - return generate_skills_tool_description(skills) - - def _get_declaration(self) -> types.FunctionDeclaration: - return types.FunctionDeclaration( - name=self.name, - description=self.description, - parameters=types.Schema( - type=types.Type.OBJECT, - properties={ - "command": types.Schema( - type=types.Type.STRING, - description='The skill name (no arguments). E.g., "data-analysis" or "pdf-processing"', - ), - }, - required=["command"], - ), - ) - - async def run_async(self, *, args: Dict[str, Any], tool_context: ToolContext) -> str: - """Execute skill loading by name.""" - skill_name = args.get("command", "").strip() - - if not skill_name: - return "Error: No skill name provided" - - return self._invoke_skill(skill_name) - - def _invoke_skill(self, skill_name: str) -> str: - """Load and return the full content of a skill.""" - # Check cache first - if skill_name in self._skill_cache: - return self._skill_cache[skill_name] - - try: - content = load_skill_content(self.skills_directory, skill_name) - formatted_content = self._format_skill_content(skill_name, content) - - # Cache the formatted content - self._skill_cache[skill_name] = formatted_content - - return formatted_content - except (FileNotFoundError, IOError) as e: - logger.error(f"Failed to load skill {skill_name}: {e}") - return f"Error loading skill '{skill_name}': {e}" - except Exception as e: - logger.error(f"An unexpected error occurred while loading skill {skill_name}: {e}") - return f"An unexpected error occurred while loading skill '{skill_name}': {e}" - - def _format_skill_content(self, skill_name: str, content: str) -> str: - """Format skill content for display to the agent.""" - header = ( - f'The "{skill_name}" skill is loading\n\n' - f"Base directory for this skill: {self.skills_directory}/{skill_name}\n\n" - ) - footer = ( - "\n\n---\n" - "The skill has been loaded. Follow the instructions above and use the bash tool to execute commands." - ) - return header + content + footer diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/skills_plugin.py b/python/packages/kagent-adk/src/kagent/adk/tools/skills_plugin.py deleted file mode 100644 index 7e947a5725..0000000000 --- a/python/packages/kagent-adk/src/kagent/adk/tools/skills_plugin.py +++ /dev/null @@ -1,73 +0,0 @@ -from __future__ import annotations - -import logging -from pathlib import Path -from typing import Optional - -from google.adk.agents import BaseAgent, LlmAgent - -from ..tools import BashTool, EditFileTool, GrepFileTool, ListFilesTool, ReadFileTool, WriteFileTool -from .skill_tool import SkillsTool - -logger = logging.getLogger("kagent_adk." + __name__) - - -def add_skills_tool_to_agent( - skills_directory: str | Path, - agent: BaseAgent, - *, - file_search_tools: bool = False, - bash_venv_path: str = "", -) -> None: - """Utility function to add Skills and Bash tools to a given agent. - - Args: - agent: The LlmAgent instance to which the tools will be added. - skills_directory: Path to directory containing skill folders. - """ - - if not isinstance(agent, LlmAgent): - return - - skills_directory = Path(skills_directory) - existing_tool_names = {getattr(t, "name", None) for t in agent.tools} - - # Add SkillsTool if not already present - if "skills" not in existing_tool_names: - agent.tools.append(SkillsTool(skills_directory)) - logger.debug(f"Added skills invoke tool to agent: {agent.name}") - - # Add BashTool if not already present - if "bash" not in existing_tool_names: - agent.tools.append( - BashTool(skills_directory, file_search_tools=file_search_tools, bash_venv_path=bash_venv_path) - ) - logger.debug(f"Added bash tool to agent: {agent.name}") - - if "read_file" not in existing_tool_names: - agent.tools.append(ReadFileTool(skills_directory)) - logger.debug(f"Added read file tool to agent: {agent.name}") - - if "write_file" not in existing_tool_names: - agent.tools.append(WriteFileTool()) - logger.debug(f"Added write file tool to agent: {agent.name}") - - if "edit_file" not in existing_tool_names: - agent.tools.append(EditFileTool()) - logger.debug(f"Added edit file tool to agent: {agent.name}") - - # list_files/grep_file are opt-in: they give an agent broad filesystem - # visibility, so deployments enable them deliberately. Note this gate is - # theirs alone -- bash above is always registered. - if file_search_tools: - if "list_files" not in existing_tool_names: - agent.tools.append(ListFilesTool(skills_directory)) - logger.debug(f"Added list files tool to agent: {agent.name}") - - if "grep_file" not in existing_tool_names: - agent.tools.append(GrepFileTool(skills_directory)) - logger.debug(f"Added grep file tool to agent: {agent.name}") - else: - logger.debug( - f"Omitting list_files/grep_file tools for agent: {agent.name} (runtime.skill_tools.file_search_tools not enabled)" - ) diff --git a/python/packages/kagent-adk/src/kagent/adk/tools/skills_toolset.py b/python/packages/kagent-adk/src/kagent/adk/tools/skills_toolset.py deleted file mode 100644 index 16b72640b8..0000000000 --- a/python/packages/kagent-adk/src/kagent/adk/tools/skills_toolset.py +++ /dev/null @@ -1,81 +0,0 @@ -from __future__ import annotations - -import logging -from pathlib import Path -from typing import List, Optional - -try: - from typing_extensions import override -except ImportError: - from typing import override - -from google.adk.agents.readonly_context import ReadonlyContext -from google.adk.tools import BaseTool -from google.adk.tools.base_toolset import BaseToolset - -from ..tools import BashTool, EditFileTool, GrepFileTool, ListFilesTool, ReadFileTool, WriteFileTool -from .skill_tool import SkillsTool - -logger = logging.getLogger("kagent_adk." + __name__) - - -class SkillsToolset(BaseToolset): - """Toolset that provides Skills functionality for domain expertise execution. - - This toolset provides skills access through specialized tools: - 1. SkillsTool - Discover and load skill instructions - 2. ReadFileTool - Read files with line numbers - 3. WriteFileTool - Write/create files - 4. EditFileTool - Edit files with precise replacements - 5. ListFilesTool - List files and directories - 6. GrepFileTool - Search file contents with a regular expression - 7. BashTool - Execute shell commands - - Skills provide specialized domain knowledge and scripts that the agent can use - to solve complex tasks. The toolset enables discovery of available skills, - file manipulation, and command execution. - - Note: For file upload/download, use the ArtifactsToolset separately. - """ - - def __init__(self, skills_directory: str | Path, *, file_search_tools: bool = False, bash_venv_path: str = ""): - """Initialize the skills toolset. - - Args: - skills_directory: Path to directory containing skill folders. - """ - super().__init__() - self.skills_directory = Path(skills_directory) - - # Create skills tools - self.skills_tool = SkillsTool(skills_directory) - self.read_file_tool = ReadFileTool(skills_directory) - self.write_file_tool = WriteFileTool() - self.edit_file_tool = EditFileTool() - # list_files/grep_file are opt-in: they give an agent broad - # filesystem visibility, so deployments enable them deliberately. - # Note this gate is theirs alone -- bash below is always registered. - # A single list (rather than two separately-nullable attributes) - # keeps "both present or both absent" a structural guarantee instead - # of a convention the two attributes have to be kept in sync by hand. - self._file_search_tools: list[BaseTool] = ( - [ListFilesTool(skills_directory), GrepFileTool(skills_directory)] if file_search_tools else [] - ) - self.bash_tool = BashTool(skills_directory, file_search_tools=file_search_tools, bash_venv_path=bash_venv_path) - - @override - async def get_tools(self, readonly_context: Optional[ReadonlyContext] = None) -> List[BaseTool]: - """Get all skills tools. - - Returns: - List containing all skills tools: skills, read, write, edit, list, grep, and bash. - list/grep are omitted unless file_search_tools is enabled. - """ - return [ - self.skills_tool, - self.read_file_tool, - self.write_file_tool, - self.edit_file_tool, - *self._file_search_tools, - self.bash_tool, - ] diff --git a/python/packages/kagent-adk/tests/unittests/test_builtin_tools.py b/python/packages/kagent-adk/tests/unittests/test_builtin_tools.py new file mode 100644 index 0000000000..8c8d0e9e8d --- /dev/null +++ b/python/packages/kagent-adk/tests/unittests/test_builtin_tools.py @@ -0,0 +1,173 @@ +"""Built-in tools are independent of skills and respect runtime settings.""" + +import shlex +from types import SimpleNamespace + +import pytest +from google.adk.agents import LlmAgent +from google.adk.agents.invocation_context import InvocationContext +from google.adk.flows.llm_flows.tools._agent_tools import process_agent_tools +from google.adk.models.llm_request import LlmRequest +from google.adk.sessions import InMemorySessionService, Session +from google.adk.tools import FunctionTool, ToolContext +from google.adk.tools.skill_toolset import SkillToolset + +from kagent.adk.cli import add_tools_with_config +from kagent.adk.tools import add_builtin_tools_to_agent +from kagent.adk.types import parse_config + + +@pytest.fixture(autouse=True) +def workspace(tmp_path, monkeypatch): + monkeypatch.setattr("kagent.tools.workspace.WORKSPACE", tmp_path / "workspace") + + +@pytest.mark.parametrize("skills_mode", ["missing", "empty", "installed"]) +@pytest.mark.parametrize("disable_bash", [False, True]) +@pytest.mark.parametrize("file_search", [False, True]) +def test_builtin_tools(tmp_path, skills_mode, disable_bash, file_search): + skills_dir = tmp_path / "skills" + if skills_mode != "missing": + skills_dir.mkdir() + if skills_mode == "installed": + (skills_dir / "demo").mkdir() + (skills_dir / "demo" / "SKILL.md").write_text("---\nname: demo\ndescription: Demo skill.\n---\nRead files.\n") + agent = LlmAgent(name="test_agent", model="gemini-2.0-flash") + add_builtin_tools_to_agent( + skills_dir, agent, disable_bash=disable_bash, disable_file_search=not file_search, bash_venv_path="/opt/venv" + ) + names = {tool.name for tool in agent.tools if not isinstance(tool, SkillToolset)} + assert {"read_file", "write_file", "edit_file"} <= names + assert any(isinstance(tool, SkillToolset) for tool in agent.tools) == (skills_mode == "installed") + assert ("bash" in names) == (not disable_bash) + assert ("list_files" in names) == file_search + assert ("grep_file" in names) == file_search + if not disable_bash: + assert next(tool for tool in agent.tools if getattr(tool, "name", None) == "bash").bash_venv_path == "/opt/venv" + + +@pytest.mark.parametrize("disable_bash", [False, True]) +def test_runtime_tools_reach_subagents_without_skills(disable_bash): + child = LlmAgent(name="child", model="gemini-2.0-flash") + root = LlmAgent(name="root", model="gemini-2.0-flash", sub_agents=[child]) + config = parse_config({"runtime": {"builtin_tools": {"disable_bash": disable_bash}}, "agent": {}}) + add_tools_with_config(root, config) + for agent in [root, child]: + names = {tool.name for tool in agent.tools if not isinstance(tool, SkillToolset)} + assert {"read_file", "write_file", "edit_file"} <= names + assert ("bash" in names) == (not disable_bash) + assert {"list_files", "grep_file"} <= names + + +def test_builtin_tools_with_no_config(): + agent = LlmAgent(name="root", model="gemini-2.0-flash") + add_tools_with_config(agent) + assert {tool.name for tool in agent.tools} == { + "read_file", + "write_file", + "edit_file", + "list_files", + "grep_file", + "bash", + } + + +@pytest.mark.asyncio +async def test_builtin_file_and_shell_calls_without_skills(tmp_path): + agent = LlmAgent(name="test_agent", model="gemini-2.0-flash") + add_builtin_tools_to_agent(tmp_path / "missing-skills", agent) + tools = {tool.name: tool for tool in agent.tools} + context = SimpleNamespace(session=SimpleNamespace(id="ignored-actor-local-session-id")) + + async def run(name, args): + return await tools[name].run_async(args=args, tool_context=context) + + assert "Error" not in await run("write_file", {"file_path": "notes.txt", "content": "hello"}) + assert "hello" in await run("read_file", {"file_path": "notes.txt"}) + assert "Error" not in await run( + "edit_file", {"file_path": "notes.txt", "old_string": "hello", "new_string": "updated"} + ) + assert await run("bash", {"command": "cat notes.txt"}) == "updated" + assert "notes.txt" in await run("list_files", {}) + absolute = str(tmp_path / "absolute.txt") + assert "Error" not in await run("write_file", {"file_path": absolute, "content": "sandbox"}) + assert "sandbox" in await run("read_file", {"file_path": absolute}) + + +async def _model_request(agent): + context = InvocationContext( + agent=agent, + invocation_id="test-invocation", + session=Session(id="test-session", app_name="test", user_id="test-user"), + session_service=InMemorySessionService(), + ) + request = LlmRequest(model="gemini-2.0-flash") + # Exercise ADK's request builder, including declarations and dispatch mapping. + await process_agent_tools(context, request) + return request, ToolContext(context) + + +@pytest.mark.asyncio +@pytest.mark.parametrize("tool_name", ["read_file", "write_file", "edit_file", "list_files", "grep_file", "bash"]) +@pytest.mark.parametrize("wrapped", [False, True]) +async def test_defaults_preserve_custom_tool_declarations_and_dispatch(tool_name, wrapped): + def custom_tool(value: str) -> str: + """Return a value using the user's implementation.""" + return f"custom: {value}" + + custom_tool.__name__ = tool_name + supplied_tool = FunctionTool(custom_tool) if wrapped else custom_tool + agent = LlmAgent(name="custom", model="gemini-2.0-flash", tools=[supplied_tool]) + add_tools_with_config(agent) + add_tools_with_config(agent) + request, context = await _model_request(agent) + + declarations = [declaration for tool in request.config.tools for declaration in tool.function_declarations or []] + assert len(declarations) == 6 + assert len({declaration.name for declaration in declarations}) == 6 + assert request.tools_dict[tool_name].func is custom_tool + assert await request.tools_dict[tool_name].run_async(args={"value": "kept"}, tool_context=context) == "custom: kept" + + +@pytest.mark.asyncio +@pytest.mark.parametrize("disable_bash", [False, True]) +async def test_skill_model_request_matches_bash_availability(tmp_path, disable_bash): + skill = tmp_path / "skills" / "demo" + (skill / "scripts").mkdir(parents=True) + (skill / "SKILL.md").write_text("---\nname: demo\ndescription: Demo skill.\n---\nRun scripts/demo.sh.\n") + script = skill / "scripts" / "demo.sh" + script.write_text("printf skill-executed") + agent = LlmAgent(name="skilled", model="gemini-2.0-flash") + add_builtin_tools_to_agent(skill.parent, agent, disable_bash=disable_bash) + add_builtin_tools_to_agent(skill.parent, agent, disable_bash=disable_bash) + request, context = await _model_request(agent) + + prompt = request.config.system_instruction + assert "list_skills" in prompt + assert "load_skill" in prompt + assert "load_skill_resource" in prompt + assert "run_skill_script" not in request.tools_dict + assert ("bash" in request.tools_dict) == (not disable_bash) + assert sum(isinstance(tool, SkillToolset) for tool in agent.tools) == 1 + loaded = await request.tools_dict["load_skill"].run_async(args={"skill_name": "demo"}, tool_context=context) + assert loaded["instructions"] == "Run scripts/demo.sh." + resource = await request.tools_dict["load_skill_resource"].run_async( + args={"skill_name": "demo", "file_path": "scripts/demo.sh"}, tool_context=context + ) + assert resource["content"] == "printf skill-executed" + if disable_bash: + assert "The bash tool is unavailable" in prompt + assert "Do not execute bundled scripts" in prompt + assert "Run bundled scripts with the bash tool" not in prompt + else: + assert "Run bundled scripts with the bash tool" in prompt + assert str(skill.parent.resolve()) in prompt + assert "cannot run" not in prompt + assert "Do not execute" not in prompt + assert "run_skill_script" not in prompt + assert ( + await request.tools_dict["bash"].run_async( + args={"command": f"bash {shlex.quote(str(script))}"}, tool_context=context + ) + == "skill-executed" + ) diff --git a/python/packages/kagent-adk/tests/unittests/test_file_tools.py b/python/packages/kagent-adk/tests/unittests/test_file_tools.py index 48a0835221..139c0fca35 100644 --- a/python/packages/kagent-adk/tests/unittests/test_file_tools.py +++ b/python/packages/kagent-adk/tests/unittests/test_file_tools.py @@ -6,11 +6,16 @@ from unittest.mock import patch import pytest -from kagent.skills import get_session_path, initialize_session_path +from kagent.tools import get_workspace_path from kagent.adk.tools.file_tools import GrepFileTool, _run_with_deadline +@pytest.fixture(autouse=True) +def workspace(tmp_path, monkeypatch): + monkeypatch.setattr("kagent.tools.workspace.WORKSPACE", tmp_path / "workspace") + + class MockSession: def __init__(self, session_id: str = "test-session-grep-timeout"): self.id = session_id @@ -62,12 +67,11 @@ async def test_grep_file_tool_times_out_on_catastrophic_backtracking(tmp_path): skills_dir = tmp_path / "skills" skills_dir.mkdir() session_id = "test-session-grep-catastrophic" - initialize_session_path(session_id, str(skills_dir)) - working_dir = Path(get_session_path(session_id=session_id)) + working_dir = Path(get_workspace_path()) (working_dir / "evil.txt").write_text(CATASTROPHIC_SUBJECT + "\n") - tool = GrepFileTool(skills_directory=str(skills_dir)) + tool = GrepFileTool() # Comfortably above worker spawn cost, low enough to keep the test quick. tool._TIMEOUT_SECONDS = 3 @@ -103,10 +107,9 @@ def _session_with_file(tmp_path, session_id: str, name: str, content: str): """ skills_dir = tmp_path / "skills" skills_dir.mkdir() - initialize_session_path(session_id, str(skills_dir)) - working_dir = Path(get_session_path(session_id=session_id)) + working_dir = Path(get_workspace_path()) (working_dir / name).write_text(content) - return GrepFileTool(skills_directory=str(skills_dir)) + return GrepFileTool() @pytest.mark.asyncio @@ -154,9 +157,8 @@ async def test_grep_file_tool_still_rejects_a_whitespace_only_pattern(tmp_path): skills_dir = tmp_path / "skills" skills_dir.mkdir() session_id = "test-session-grep-blank" - initialize_session_path(session_id, str(skills_dir)) - tool = GrepFileTool(skills_directory=str(skills_dir)) + tool = GrepFileTool() with patch("kagent.adk.tools.file_tools.grep_content") as mocked: result = await tool.run_async( diff --git a/python/packages/kagent-adk/tests/unittests/test_skills_plugin.py b/python/packages/kagent-adk/tests/unittests/test_skills_plugin.py deleted file mode 100644 index c93ebe9524..0000000000 --- a/python/packages/kagent-adk/tests/unittests/test_skills_plugin.py +++ /dev/null @@ -1,29 +0,0 @@ -"""Tests for add_skills_tool_to_agent's list_files/grep_file feature-flag gating.""" - -from google.adk.agents import LlmAgent - -from kagent.adk.tools.skills_plugin import add_skills_tool_to_agent - - -def _tool_names(agent: LlmAgent) -> set[str]: - return {getattr(t, "name", None) for t in agent.tools} - - -def test_add_skills_tool_to_agent_omits_list_files_and_grep_file_by_default(tmp_path): - agent = LlmAgent(name="test_agent", model="gemini-2.0-flash", tools=[]) - - add_skills_tool_to_agent(str(tmp_path), agent) - - names = _tool_names(agent) - assert {"skills", "read_file", "write_file", "edit_file", "bash"} <= names - assert "list_files" not in names - assert "grep_file" not in names - - -def test_add_skills_tool_to_agent_adds_list_files_and_grep_file_when_enabled(tmp_path): - agent = LlmAgent(name="test_agent", model="gemini-2.0-flash", tools=[]) - - add_skills_tool_to_agent(str(tmp_path), agent, file_search_tools=True) - - names = _tool_names(agent) - assert {"skills", "read_file", "write_file", "edit_file", "bash", "list_files", "grep_file"} <= names diff --git a/python/packages/kagent-core/tests/test_runtime_config.py b/python/packages/kagent-core/tests/test_runtime_config.py index c8ade7c69c..0719a9773b 100644 --- a/python/packages/kagent-core/tests/test_runtime_config.py +++ b/python/packages/kagent-core/tests/test_runtime_config.py @@ -14,9 +14,9 @@ def test_shared_go_runtime_contract(): cfg = parse_runtime_config(path.read_text()) assert cfg.max_content_length == 0 assert cfg.token_propagation.enabled is False - assert cfg.skill_tools.bash_venv_path == "/opt/venv" - assert cfg.skill_tools.file_search_tools is False - assert cfg.skill_tools.disable_bash is True + assert cfg.builtin_tools.bash_venv_path == "/opt/venv" + assert cfg.builtin_tools.disable_file_search is False + assert cfg.builtin_tools.disable_bash is True assert cfg.telemetry.capture_content is False assert cfg.telemetry.traces.timeout.ToMilliseconds() == 1500 assert KAgentConfig(runtime=cfg).api_url == "http://controller:8083" @@ -25,8 +25,8 @@ def test_shared_go_runtime_contract(): def test_runtime_defaults(): cfg = RuntimeConfig() assert not cfg.HasField("max_content_length") - assert not cfg.skill_tools.file_search_tools - assert not cfg.skill_tools.disable_bash + assert not cfg.builtin_tools.disable_file_search + assert not cfg.builtin_tools.disable_bash assert not cfg.HasField("telemetry") @@ -41,8 +41,8 @@ def test_runtime_defaults(): {"telemetry": {"resource_attributes": {"service.name": "shared"}}}, {"telemetry": {"resource_attributes": {"service.namespace": "shared"}}}, {"log_level": "verbose"}, - {"skill_tools": {"file_search_tools": "false"}}, - {"skill_tools": {"disable_bash": "true"}}, + {"builtin_tools": {"disable_file_search": "false"}}, + {"builtin_tools": {"disable_bash": "true"}}, {"unknown": True}, ], ) diff --git a/python/packages/kagent-openai/README.md b/python/packages/kagent-openai/README.md index 282197dc07..8bdb83261a 100644 --- a/python/packages/kagent-openai/README.md +++ b/python/packages/kagent-openai/README.md @@ -1,6 +1,6 @@ # KAgent OpenAI Agents SDK Integration -OpenAI Agents SDK integration for KAgent with A2A (Agent-to-Agent) protocol support, session management, and optional skills integration. +OpenAI Agents SDK integration for KAgent with A2A (Agent-to-Agent) protocol support, session management, and optional file and bash tools. --- @@ -39,25 +39,27 @@ fastapi_app = app.build() --- -## Agent with Skills +## Optional tools -Skills provide domain expertise through filesystem-based instruction files and helper tools (read/write/edit files, bash execution). We provide a function to load all skill-related tools. Otherwise, you can select the ones you need by importing from `kagent.openai.tools`. +BYO harnesses keep agent behavior in user code. Import the basic filesystem and +bash tools you need from `kagent.openai.tools`: ```python from agents.agent import Agent -from kagent.openai import get_skill_tools - -tools = [my_custom_tool] -tools.extend(get_skill_tools("./skills")) +from kagent.openai.tools import bash, edit_file, read_file, write_file agent = Agent( - name="SkillfulAgent", - instructions="Use skills and tools when appropriate.", - tools=tools, + name="Assistant", + instructions="Use tools when appropriate.", + tools=[read_file, write_file, edit_file, bash], ) ``` -See [skills README](../../kagent-skills/README.md) for skill format and structure. +Skill discovery and loading are user-implemented in BYO harnesses. + +File and bash tools share `/data/workspace` within an Actor. Substrate snapshots +preserve that directory; applications create any upload or output directories +when needed. See [runtime helpers](../kagent-tools/README.md). --- @@ -140,7 +142,7 @@ See `samples/openai/` for complete examples: ## See Also - [OpenAI Agents SDK Docs](https://github.com/openai/agents) -- [KAgent Skills](../../kagent-skills/README.md) +- [Python runtime helpers](../kagent-tools/README.md) - [A2A Protocol](https://docs.kagent.ai/a2a) --- diff --git a/python/packages/kagent-openai/pyproject.toml b/python/packages/kagent-openai/pyproject.toml index 07a00ed93e..60afad090d 100644 --- a/python/packages/kagent-openai/pyproject.toml +++ b/python/packages/kagent-openai/pyproject.toml @@ -10,7 +10,7 @@ dependencies = [ "a2a-sdk>=1.1.5", "kagent-core[openai-agents]>=0.1.0", "kagent-proto>=0.1.0", - "kagent-skills>=0.1.0", + "kagent-tools>=0.1.0", "httpx>=0.25.0", "fastapi>=0.100.0", "uvicorn>=0.20.0", @@ -29,7 +29,7 @@ dev = [ [tool.uv.sources] kagent-core = {workspace = true} kagent-proto = {workspace = true} -kagent-skills = {workspace = true} +kagent-tools = {workspace = true} [build-system] requires = ["setuptools>=84.0.0", "wheel>=0.48.0"] diff --git a/python/packages/kagent-openai/src/kagent/openai/__init__.py b/python/packages/kagent-openai/src/kagent/openai/__init__.py index 1417fe6a87..70be8eec3d 100644 --- a/python/packages/kagent-openai/src/kagent/openai/__init__.py +++ b/python/packages/kagent-openai/src/kagent/openai/__init__.py @@ -9,7 +9,6 @@ # Re-export from agent subpackage for convenience from ._a2a import KAgentApp -from .tools import get_skill_tools -__all__ = ["KAgentApp", "get_skill_tools"] +__all__ = ["KAgentApp"] __version__ = "0.1.0" diff --git a/python/packages/kagent-openai/src/kagent/openai/tools/__init__.py b/python/packages/kagent-openai/src/kagent/openai/tools/__init__.py index c9363e0d2e..46d9c25307 100644 --- a/python/packages/kagent-openai/src/kagent/openai/tools/__init__.py +++ b/python/packages/kagent-openai/src/kagent/openai/tools/__init__.py @@ -1,3 +1,3 @@ -from ._tools import bash, edit_file, get_skill_tool, get_skill_tools, read_file, write_file +from ._tools import bash, edit_file, read_file, write_file -__all__ = ["edit_file", "write_file", "read_file", "bash", "get_skill_tool", "get_skill_tools"] +__all__ = ["edit_file", "write_file", "read_file", "bash"] diff --git a/python/packages/kagent-openai/src/kagent/openai/tools/_tools.py b/python/packages/kagent-openai/src/kagent/openai/tools/_tools.py index e5c13d1aa8..e0c0e800dc 100644 --- a/python/packages/kagent-openai/src/kagent/openai/tools/_tools.py +++ b/python/packages/kagent-openai/src/kagent/openai/tools/_tools.py @@ -1,67 +1,49 @@ -"""File operation and skill tools for agents. - -This module provides Read, Write, Edit, Bash, and Skills tools that agents can use. -These tools are wrappers around the centralized logic in the kagent-skills package. -""" +"""Optional filesystem and bash tools for BYO OpenAI agents.""" from __future__ import annotations -import logging from pathlib import Path from agents.exceptions import UserError from agents.run_context import RunContextWrapper -from agents.tool import FunctionTool, function_tool -from kagent.skills import ( - discover_skills, +from agents.tool import function_tool +from kagent.tools import ( edit_file_content, execute_command, - generate_skills_tool_description, get_bash_description, get_edit_file_description, get_read_file_description, - get_session_path, + get_workspace_path, get_write_file_description, - initialize_session_path, - load_skill_content, read_file_content, write_file_content, ) from .._agent_executor import SessionContext -logger = logging.getLogger(__name__) - - # --- System Tools --- -def _read_file_tool(skills_directory: str | Path) -> FunctionTool: - @function_tool( - name_override="read_file", - description_override=get_read_file_description(), - ) - def read_file( - wrapper: RunContextWrapper[SessionContext], - file_path: str, - offset: int | None = None, - limit: int | None = None, - ) -> str: - """Read a file from the filesystem.""" - try: - session_id = wrapper.context.session_id - working_dir = get_session_path(session_id) - path = Path(file_path) - if not path.is_absolute(): - path = working_dir / path - - allowed_dirs = [working_dir, Path(skills_directory)] - - return read_file_content(path, offset, limit, allowed_root=allowed_dirs) - except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as e: - raise UserError(str(e)) from e +@function_tool( + name_override="read_file", + description_override=get_read_file_description(), +) +def read_file( + wrapper: RunContextWrapper[SessionContext], + file_path: str, + offset: int | None = None, + limit: int | None = None, +) -> str: + """Read a file from the filesystem.""" + try: + working_dir = get_workspace_path() + path = Path(file_path) + if not path.is_absolute(): + path = working_dir / path - return read_file + return read_file_content(path, offset, limit) + except (FileNotFoundError, IsADirectoryError, PermissionError, OSError) as e: + raise UserError(str(e)) from e @function_tool( @@ -71,13 +53,12 @@ def read_file( def write_file(wrapper: RunContextWrapper[SessionContext], file_path: str, content: str) -> str: """Write content to a file.""" try: - session_id = wrapper.context.session_id - working_dir = get_session_path(session_id) + working_dir = get_workspace_path() path = Path(file_path) if not path.is_absolute(): path = working_dir / path - return write_file_content(path, content, allowed_root=working_dir) + return write_file_content(path, content) except OSError as e: raise UserError(str(e)) from e @@ -95,104 +76,23 @@ def edit_file( ) -> str: """Edit a file by replacing old_string with new_string.""" try: - session_id = wrapper.context.session_id - working_dir = get_session_path(session_id) + working_dir = get_workspace_path() path = Path(file_path) if not path.is_absolute(): path = working_dir / path - return edit_file_content(path, old_string, new_string, replace_all, allowed_root=working_dir) + return edit_file_content(path, old_string, new_string, replace_all) except (FileNotFoundError, IsADirectoryError, ValueError, OSError) as e: raise UserError(str(e)) from e -def _bash_tool(skills_directory: str | Path, bash_venv_path: str = "") -> FunctionTool: - @function_tool( - name_override="bash", - description_override=get_bash_description(), - ) - async def bash(wrapper: RunContextWrapper[SessionContext], command: str) -> str: - """Executes a bash command in a sandboxed environment.""" - try: - session_id = wrapper.context.session_id - working_dir = get_session_path(session_id) - return await execute_command(command, working_dir, Path(skills_directory), bash_venv_path=bash_venv_path) - except Exception as e: - raise UserError(f"Error executing command: {e}") from e - - return bash - - -read_file = _read_file_tool("/skills") -bash = _bash_tool("/skills") - -# --- Skill Tools --- - - -def get_skill_tool(skills_directory: str | Path = "/skills") -> FunctionTool: - """Create a Skill tool. - - This function generates a tool instance with skills discovered from the provided - directory, following the ADK pattern. - """ - skills_dir = Path(skills_directory) - if not skills_dir.exists(): - raise ValueError(f"Skills directory does not exist: {skills_dir}") - - # Discover skills and generate the tool description. - skills = discover_skills(skills_dir) - description = generate_skills_tool_description(skills) - - @function_tool(name_override="skills", description_override=description) - def skill_tool_impl(wrapper: RunContextWrapper[SessionContext], command: str) -> str: - """Execute a skill by name. - - Args: - command: The name of the skill to execute (e.g., "data-analysis") - - Returns: - The full skill instructions and context. - """ - # This function is cached internally, so calling it multiple times is safe. - initialize_session_path(wrapper.context.session_id, str(skills_dir)) - skill_name = command.strip() - - try: - content = load_skill_content(skills_dir, skill_name) - - # Mimic ADK's formatting - header = ( - f'The "{skill_name}" skill is loading\n\n' - f"Base directory for this skill: {skills_dir.resolve()}/{skill_name}\n\n" - ) - footer = ( - "\n\n---\n" - "The skill has been loaded. Follow the instructions above and use the bash tool to execute commands." - ) - return header + content + footer - - except (FileNotFoundError, OSError) as e: - return f"Error loading skill '{skill_name}': {e}" - except Exception as e: - return f"An unexpected error occurred while loading skill '{skill_name}': {e}" - - return skill_tool_impl - - -def get_skill_tools(skills_directory: str | Path = "/skills", *, bash_venv_path: str = "") -> list[FunctionTool]: - """ - Create a list of tools including the skill tool and file operation tools. - - Args: - skills_directory: Path to the directory containing skills. - - Returns: - A list of FunctionTool instances: skills tool, read_file, write_file, edit_file, and bash - """ - return [ - get_skill_tool(skills_directory), - _read_file_tool(skills_directory), - write_file, - edit_file, - _bash_tool(skills_directory, bash_venv_path), - ] +@function_tool( + name_override="bash", + description_override=get_bash_description(), +) +async def bash(wrapper: RunContextWrapper[SessionContext], command: str, timeout_seconds: int = 0) -> str: + """Execute a bash command in the agent sandbox.""" + try: + return await execute_command(command, get_workspace_path(), timeout_seconds=timeout_seconds) + except Exception as e: + raise UserError(f"Error executing command: {e}") from e diff --git a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py index 74eca38011..70f00e4a2f 100644 --- a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py +++ b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.py @@ -27,7 +27,7 @@ from kagent.config.v1 import config_pb2 as kagent_dot_config_dot_v1_dot_config__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1akagent/config/v1/adk.proto\x12\x10kagent.config.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1dkagent/config/v1/config.proto\"\x81\x01\n\tADKConfig\x12<\n\x07runtime\x18\x01 \x01(\x0b\x32\".kagent.config.v1.ADKRuntimeConfigR\x07runtime\x12\x36\n\x05\x61gent\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\x05\x61gent\"\xff\x07\n\x0e\x41\x44KAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x36\n\x05model\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x05model\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12 \n\x0binstruction\x18\x04 \x01(\tR\x0binstruction\x12G\n\nhttp_tools\x18\x05 \x03(\x0b\x32(.kagent.config.v1.ADKHTTPMCPServerConfigR\thttpTools\x12\x44\n\tsse_tools\x18\x06 \x03(\x0b\x32\'.kagent.config.v1.ADKSSEMCPServerConfigR\x08sseTools\x12J\n\x0bstdio_tools\x18\x07 \x03(\x0b\x32).kagent.config.v1.ADKStdioMCPServerConfigR\nstdioTools\x12K\n\rremote_agents\x18\x08 \x03(\x0b\x32&.kagent.config.v1.ADKRemoteAgentConfigR\x0cremoteAgents\x12\x1b\n\x06stream\x18\t \x01(\x08H\x00R\x06stream\x88\x01\x01\x12\x39\n\x06memory\x18\n \x01(\x0b\x32!.kagent.config.v1.ADKMemoryConfigR\x06memory\x12<\n\x07network\x18\x0b \x01(\x0b\x32\".kagent.config.v1.ADKNetworkConfigR\x07network\x12\x46\n\ragent_plugins\x18\x0c \x01(\x0b\x32!.kagent.config.v1.ADKAgentPluginsR\x0c\x61gentPlugins\x12I\n\x0e\x63ontext_config\x18\r \x01(\x0b\x32\".kagent.config.v1.ADKContextConfigR\rcontextConfig\x12$\n\x0bshare_tools\x18\x0e \x01(\x08H\x01R\nshareTools\x88\x01\x01\x12$\n\x0esession_db_url\x18\x0f \x01(\tR\x0csessionDbUrl\x12)\n\x10skills_directory\x18\x10 \x01(\tR\x0fskillsDirectory\x12?\n\nsub_agents\x18\x11 \x03(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\tsubAgents\x12\x39\n\x06output\x18\x12 \x01(\x0b\x32!.kagent.config.v1.ADKOutputConfigR\x06outputB\t\n\x07_streamB\x0e\n\x0c_share_tools\"\xa1\t\n\x0e\x41\x44KModelConfig\x12\x14\n\x05model\x18\x01 \x01(\tR\x05model\x12G\n\x07headers\x18\x02 \x03(\x0b\x32-.kagent.config.v1.ADKModelConfig.HeadersEntryR\x07headers\x12<\n\x18tls_insecure_skip_verify\x18\x03 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x04 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x05 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x12.\n\x13\x61pi_key_passthrough\x18\x06 \x01(\x08R\x11\x61piKeyPassthrough\x12\x35\n\x06openai\x18\n \x01(\x0b\x32\x1b.kagent.config.v1.ADKOpenAIH\x00R\x06openai\x12\x45\n\x0c\x61zure_openai\x18\x0b \x01(\x0b\x32 .kagent.config.v1.ADKAzureOpenAIH\x00R\x0b\x61zureOpenai\x12>\n\tanthropic\x18\x0c \x01(\x0b\x32\x1e.kagent.config.v1.ADKAnthropicH\x00R\tanthropic\x12O\n\x10gemini_vertex_ai\x18\r \x01(\x0b\x32#.kagent.config.v1.ADKGeminiVertexAIH\x00R\x0egeminiVertexAi\x12Q\n\x10gemini_anthropic\x18\x0e \x01(\x0b\x32$.kagent.config.v1.ADKGeminiAnthropicH\x00R\x0fgeminiAnthropic\x12\x35\n\x06ollama\x18\x0f \x01(\x0b\x32\x1b.kagent.config.v1.ADKOllamaH\x00R\x06ollama\x12\x35\n\x06gemini\x18\x10 \x01(\x0b\x32\x1b.kagent.config.v1.ADKGeminiH\x00R\x06gemini\x12\x38\n\x07\x62\x65\x64rock\x18\x11 \x01(\x0b\x32\x1c.kagent.config.v1.ADKBedrockH\x00R\x07\x62\x65\x64rock\x12@\n\x0bsap_ai_core\x18\x12 \x01(\x0b\x32\x1e.kagent.config.v1.ADKSAPAICoreH\x00R\tsapAiCore\x12\x38\n\x07\x66oundry\x18\x13 \x01(\x0b\x32\x1c.kagent.config.v1.ADKFoundryH\x00R\x07\x66oundry\x12\x38\n\x07mistral\x18\x14 \x01(\x0b\x32\x1c.kagent.config.v1.ADKMistralH\x00R\x07mistral\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x11\n\x08provider\x12\x05\xbaH\x02\x08\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\x86\x08\n\tADKOpenAI\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12I\n\x11\x66requency_penalty\x18\x02 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x00R\x10\x66requencyPenalty\x88\x01\x01\x12+\n\nmax_tokens\x18\x03 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x01R\tmaxTokens\x88\x01\x01\x12@\n\x15max_completion_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x02R\x13maxCompletionTokens\x88\x01\x01\x12\x1a\n\x01n\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x03R\x01n\x88\x01\x01\x12G\n\x10presence_penalty\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x04R\x0fpresencePenalty\x88\x01\x01\x12.\n\x10reasoning_effort\x18\x07 \x01(\tH\x05R\x0freasoningEffort\x88\x01\x01\x12\x17\n\x04seed\x18\x08 \x01(\x03H\x06R\x04seed\x88\x01\x01\x12>\n\x0btemperature\x18\t \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x07R\x0btemperature\x88\x01\x01\x12\x45\n\x07timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12\x31\n\x05top_p\x18\x0b \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x08R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x0c \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormat\x12O\n\x0etoken_exchange\x18\r \x01(\x0b\x32(.kagent.config.v1.ADKTokenExchangeConfigR\rtokenExchange:\x94\x01\xbaH\x90\x01\x1a\x8d\x01\n\x13openai.token_limits\x12;max_tokens and max_completion_tokens are mutually exclusive\x1a\x39!has(this.max_tokens) || !has(this.max_completion_tokens)B\x14\n\x12_frequency_penaltyB\r\n\x0b_max_tokensB\x18\n\x16_max_completion_tokensB\x04\n\x02_nB\x13\n\x11_presence_penaltyB\x13\n\x11_reasoning_effortB\x07\n\x05_seedB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\x8b\x01\n\x16\x41\x44KTokenExchangeConfig\x12`\n\x14gdch_service_account\x18\x01 \x01(\x0b\x32,.kagent.config.v1.ADKGDCHTokenExchangeConfigH\x00R\x12gdchServiceAccountB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\"|\n\x1a\x41\x44KGDCHTokenExchangeConfig\x12\x39\n\x14service_account_path\x18\x01 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x12serviceAccountPath\x12#\n\x08\x61udience\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x08\x61udience\"\xfa\x02\n\x0e\x41\x44KAzureOpenAI\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12+\n\nmax_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x05 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x07 \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormatB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\xbb\x03\n\x0c\x41\x44KAnthropic\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12!\n\x05top_k\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x03R\x04topK\x88\x01\x01\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12%\n\x0eprompt_caching\x18\x07 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x08 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtlB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_pB\x08\n\x06_top_k\"\xb7\x02\n\nADKMistral\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x45\n\x07timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeoutB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"i\n\x11\x41\x44KGeminiVertexAI\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\x14\n\x12\x41\x44KGeminiAnthropic\"\x8b\x01\n\tADKOllama\x12\x42\n\x07options\x18\x01 \x03(\x0b\x32(.kagent.config.v1.ADKOllama.OptionsEntryR\x07options\x1a:\n\x0cOptionsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"a\n\tADKGemini\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\xab\x03\n\nADKBedrock\x12\x16\n\x06region\x18\x01 \x01(\tR\x06region\x12\x45\n\x1f\x61\x64\x64itional_model_request_fields\x18\x02 \x01(\x0cR\x1c\x61\x64\x64itionalModelRequestFields\x12%\n\x0eprompt_caching\x18\x03 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x04 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtl\x12\x43\n\tguardrail\x18\x05 \x01(\x0b\x32%.kagent.config.v1.ADKBedrockGuardrailR\tguardrail\x12N\n\x0cread_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0breadTimeout\x12T\n\x0f\x63onnect_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0e\x63onnectTimeout\"e\n\x13\x41\x44KBedrockGuardrail\x12\x1e\n\nidentifier\x18\x01 \x01(\tR\nidentifier\x12\x18\n\x07version\x18\x02 \x01(\tR\x07version\x12\x14\n\x05trace\x18\x03 \x01(\tR\x05trace\"k\n\x0c\x41\x44KSAPAICore\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12%\n\x0eresource_group\x18\x02 \x01(\tR\rresourceGroup\x12\x19\n\x08\x61uth_url\x18\x03 \x01(\tR\x07\x61uthUrl\"\xa4\x01\n\nADKFoundry\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12\x39\n\napi_format\x18\x04 \x01(\tB\x1a\xbaH\x17r\x15R\x00R\x06openaiR\tanthropicR\tapiFormat\"\x94\x05\n\x17\x41\x44KHTTPConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12P\n\x07headers\x18\x02 \x03(\x0b\x32\x36.kagent.config.v1.ADKHTTPConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12\x31\n\x12terminate_on_close\x18\x05 \x01(\x08H\x00R\x10terminateOnClose\x88\x01\x01\x12<\n\x18tls_insecure_skip_verify\x18\x06 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x07 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x08 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x15\n\x13_terminate_on_closeB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xc8\x04\n\x16\x41\x44KSSEConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12O\n\x07headers\x18\x02 \x03(\x0b\x32\x35.kagent.config.v1.ADKSSEConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xfa\x01\n\x16\x41\x44KHTTPMCPServerConfig\x12I\n\x06params\x18\x01 \x01(\x0b\x32).kagent.config.v1.ADKHTTPConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12+\n\x11token_passthrough\x18\x05 \x01(\x08R\x10tokenPassthrough\"\xf8\x01\n\x15\x41\x44KSSEMCPServerConfig\x12H\n\x06params\x18\x01 \x01(\x0b\x32(.kagent.config.v1.ADKSSEConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12+\n\x11token_passthrough\x18\x05 \x01(\x08R\x10tokenPassthrough\"\xd7\x01\n\x17\x41\x44KStdioMCPServerConfig\x12\x18\n\x07\x63ommand\x18\x01 \x01(\tR\x07\x63ommand\x12\x12\n\x04\x61rgs\x18\x02 \x03(\tR\x04\x61rgs\x12\x44\n\x03\x65nv\x18\x03 \x03(\x0b\x32\x32.kagent.config.v1.ADKStdioMCPServerConfig.EnvEntryR\x03\x65nv\x12\x10\n\x03\x64ir\x18\x04 \x01(\tR\x03\x64ir\x1a\x36\n\x08\x45nvEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x02\n\x14\x41\x44KRemoteAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12M\n\x07headers\x18\x03 \x03(\x0b\x32\x33.kagent.config.v1.ADKRemoteAgentConfig.HeadersEntryR\x07headers\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12)\n\x10isolate_sessions\x18\x05 \x01(\x08R\x0fisolateSessions\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xe1\x03\n\x12\x41\x44KEmbeddingConfig\x12\x1a\n\x08provider\x18\x01 \x01(\tR\x08provider\x12\x14\n\x05model\x18\x02 \x01(\tR\x05model\x12\x19\n\x08\x62\x61se_url\x18\x03 \x01(\tR\x07\x62\x61seUrl\x12.\n\x13\x61pi_key_passthrough\x18\x04 \x01(\x08R\x11\x61piKeyPassthrough\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x12\x1a\n\x08\x65ndpoint\x18\x08 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\t \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\n \x01(\tR\napiVersionB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"}\n\x0f\x41\x44KMemoryConfig\x12&\n\x08ttl_days\x18\x01 \x01(\x03\x42\x0b\xbaH\x08\"\x06\x18\xff\xc1\x06(\x00R\x07ttlDays\x12\x42\n\tembedding\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKEmbeddingConfigR\tembedding\";\n\x10\x41\x44KNetworkConfig\x12\'\n\x0f\x61llowed_domains\x18\x01 \x03(\tR\x0e\x61llowedDomains\"Z\n\x10\x41\x44KContextConfig\x12\x46\n\ncompaction\x18\x01 \x01(\x0b\x32&.kagent.config.v1.ADKCompressionConfigR\ncompaction\"\xe1\x03\n\x14\x41\x44KCompressionConfig\x12\x43\n\x13\x63ompaction_interval\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x12\x63ompactionInterval\x88\x01\x01\x12\x35\n\x0coverlap_size\x18\x02 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x01R\x0boverlapSize\x88\x01\x01\x12K\n\x10summarizer_model\x18\x03 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x0fsummarizerModel\x12\'\n\x0fprompt_template\x18\x04 \x01(\tR\x0epromptTemplate\x12;\n\x0ftoken_threshold\x18\x05 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x02R\x0etokenThreshold\x88\x01\x01\x12\x44\n\x14\x65vent_retention_size\x18\x06 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x03R\x12\x65ventRetentionSize\x88\x01\x01\x42\x16\n\x14_compaction_intervalB\x0f\n\r_overlap_sizeB\x12\n\x10_token_thresholdB\x17\n\x15_event_retention_size\"J\n\x0f\x41\x44KOutputConfig\x12\x1f\n\x0bjson_schema\x18\x01 \x01(\x0cR\njsonSchema\x12\x16\n\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x82\x01\n\x0f\x41\x44KAgentPlugins\x12\x32\n\x06skills\x18\x01 \x03(\x0b\x32\x1a.kagent.config.v1.ADKSkillR\x06skills\x12;\n\x07plugins\x18\x02 \x03(\x0b\x32!.kagent.config.v1.ADKPluginBundleR\x07plugins\"Y\n\x08\x41\x44KSkill\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x39\n\x06source\x18\x02 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\"d\n\x0f\x41\x44KPluginBundle\x12\x39\n\x06source\x18\x01 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\x12\x16\n\x06skills\x18\x02 \x03(\tR\x06skills\"\xc7\x01\n\x0f\x41\x44KPluginSource\x12\x38\n\x03git\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKPluginGitSourceH\x00R\x03git\x12\x35\n\x02s3\x18\x03 \x01(\x0b\x32#.kagent.config.v1.ADKPluginS3SourceH\x00R\x02s3\x12\x1e\n\tdirectory\x18\x05 \x01(\tH\x00R\tdirectory\x12\x12\n\x04path\x18\x04 \x01(\tR\x04pathB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\">\n\x12\x41\x44KPluginGitSource\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n\x06\x63ommit\x18\x02 \x01(\tR\x06\x63ommit\"\x90\x01\n\x11\x41\x44KPluginS3Source\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x16\n\x06\x62ucket\x18\x02 \x01(\tR\x06\x62ucket\x12\x10\n\x03key\x18\x03 \x01(\tR\x03key\x12\x1d\n\nversion_id\x18\x04 \x01(\tR\tversionId\x12\x16\n\x06region\x18\x05 \x01(\tR\x06region\"\xfd\x06\n\x10\x41\x44KRuntimeConfig\x12J\n\x11\x63ontrol_plane_url\x18\x01 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\x0f\x63ontrolPlaneUrl\x12?\n\x0bgateway_url\x18\x02 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\ngatewayUrl\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12\x1c\n\tnamespace\x18\x04 \x01(\tR\tnamespace\x12>\n\tlog_level\x18\x05 \x01(\tB!\xbaH\x1er\x1cR\x00R\x05\x64\x65\x62ugR\x04infoR\x04warnR\x05\x65rrorR\x08logLevel\x12\x12\n\x04host\x18\x06 \x01(\tR\x04host\x12$\n\x04port\x18\x07 \x01(\rB\x0b\xbaH\x08*\x06\x18\xff\xff\x03 \x00H\x00R\x04port\x88\x01\x01\x12:\n\x12max_content_length\x18\x08 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x01R\x10maxContentLength\x88\x01\x01\x12U\n\x11token_propagation\x18\t \x01(\x0b\x32(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12\xb7\x02\n\ttelemetry\x18\n \x01(\x0b\x32!.kagent.config.v1.TelemetryConfigB\xf5\x01\xbaH\xf1\x01\xba\x01\xed\x01\n\"runtime.telemetry.service_identity\x12\x62runtime telemetry resource_attributes must not override compiled service.name or service.namespace\x1a\x63!(\'service.name\' in this.resource_attributes) && !(\'service.namespace\' in this.resource_attributes)R\ttelemetry\x12\x43\n\x0bskill_tools\x18\x0b \x01(\x0b\x32\".kagent.config.v1.SkillToolsConfigR\nskillToolsB\x07\n\x05_portB\x15\n\x13_max_content_lengthBCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1akagent/config/v1/adk.proto\x12\x10kagent.config.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\x1a\x1dkagent/config/v1/config.proto\"\x81\x01\n\tADKConfig\x12<\n\x07runtime\x18\x01 \x01(\x0b\x32\".kagent.config.v1.ADKRuntimeConfigR\x07runtime\x12\x36\n\x05\x61gent\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\x05\x61gent\"\xff\x07\n\x0e\x41\x44KAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x36\n\x05model\x18\x02 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x05model\x12 \n\x0b\x64\x65scription\x18\x03 \x01(\tR\x0b\x64\x65scription\x12 \n\x0binstruction\x18\x04 \x01(\tR\x0binstruction\x12G\n\nhttp_tools\x18\x05 \x03(\x0b\x32(.kagent.config.v1.ADKHTTPMCPServerConfigR\thttpTools\x12\x44\n\tsse_tools\x18\x06 \x03(\x0b\x32\'.kagent.config.v1.ADKSSEMCPServerConfigR\x08sseTools\x12J\n\x0bstdio_tools\x18\x07 \x03(\x0b\x32).kagent.config.v1.ADKStdioMCPServerConfigR\nstdioTools\x12K\n\rremote_agents\x18\x08 \x03(\x0b\x32&.kagent.config.v1.ADKRemoteAgentConfigR\x0cremoteAgents\x12\x1b\n\x06stream\x18\t \x01(\x08H\x00R\x06stream\x88\x01\x01\x12\x39\n\x06memory\x18\n \x01(\x0b\x32!.kagent.config.v1.ADKMemoryConfigR\x06memory\x12<\n\x07network\x18\x0b \x01(\x0b\x32\".kagent.config.v1.ADKNetworkConfigR\x07network\x12\x46\n\ragent_plugins\x18\x0c \x01(\x0b\x32!.kagent.config.v1.ADKAgentPluginsR\x0c\x61gentPlugins\x12I\n\x0e\x63ontext_config\x18\r \x01(\x0b\x32\".kagent.config.v1.ADKContextConfigR\rcontextConfig\x12$\n\x0bshare_tools\x18\x0e \x01(\x08H\x01R\nshareTools\x88\x01\x01\x12$\n\x0esession_db_url\x18\x0f \x01(\tR\x0csessionDbUrl\x12)\n\x10skills_directory\x18\x10 \x01(\tR\x0fskillsDirectory\x12?\n\nsub_agents\x18\x11 \x03(\x0b\x32 .kagent.config.v1.ADKAgentConfigR\tsubAgents\x12\x39\n\x06output\x18\x12 \x01(\x0b\x32!.kagent.config.v1.ADKOutputConfigR\x06outputB\t\n\x07_streamB\x0e\n\x0c_share_tools\"\xa1\t\n\x0e\x41\x44KModelConfig\x12\x14\n\x05model\x18\x01 \x01(\tR\x05model\x12G\n\x07headers\x18\x02 \x03(\x0b\x32-.kagent.config.v1.ADKModelConfig.HeadersEntryR\x07headers\x12<\n\x18tls_insecure_skip_verify\x18\x03 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x04 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x05 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x12.\n\x13\x61pi_key_passthrough\x18\x06 \x01(\x08R\x11\x61piKeyPassthrough\x12\x35\n\x06openai\x18\n \x01(\x0b\x32\x1b.kagent.config.v1.ADKOpenAIH\x00R\x06openai\x12\x45\n\x0c\x61zure_openai\x18\x0b \x01(\x0b\x32 .kagent.config.v1.ADKAzureOpenAIH\x00R\x0b\x61zureOpenai\x12>\n\tanthropic\x18\x0c \x01(\x0b\x32\x1e.kagent.config.v1.ADKAnthropicH\x00R\tanthropic\x12O\n\x10gemini_vertex_ai\x18\r \x01(\x0b\x32#.kagent.config.v1.ADKGeminiVertexAIH\x00R\x0egeminiVertexAi\x12Q\n\x10gemini_anthropic\x18\x0e \x01(\x0b\x32$.kagent.config.v1.ADKGeminiAnthropicH\x00R\x0fgeminiAnthropic\x12\x35\n\x06ollama\x18\x0f \x01(\x0b\x32\x1b.kagent.config.v1.ADKOllamaH\x00R\x06ollama\x12\x35\n\x06gemini\x18\x10 \x01(\x0b\x32\x1b.kagent.config.v1.ADKGeminiH\x00R\x06gemini\x12\x38\n\x07\x62\x65\x64rock\x18\x11 \x01(\x0b\x32\x1c.kagent.config.v1.ADKBedrockH\x00R\x07\x62\x65\x64rock\x12@\n\x0bsap_ai_core\x18\x12 \x01(\x0b\x32\x1e.kagent.config.v1.ADKSAPAICoreH\x00R\tsapAiCore\x12\x38\n\x07\x66oundry\x18\x13 \x01(\x0b\x32\x1c.kagent.config.v1.ADKFoundryH\x00R\x07\x66oundry\x12\x38\n\x07mistral\x18\x14 \x01(\x0b\x32\x1c.kagent.config.v1.ADKMistralH\x00R\x07mistral\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x11\n\x08provider\x12\x05\xbaH\x02\x08\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\x86\x08\n\tADKOpenAI\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12I\n\x11\x66requency_penalty\x18\x02 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x00R\x10\x66requencyPenalty\x88\x01\x01\x12+\n\nmax_tokens\x18\x03 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x01R\tmaxTokens\x88\x01\x01\x12@\n\x15max_completion_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x02R\x13maxCompletionTokens\x88\x01\x01\x12\x1a\n\x01n\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x03R\x01n\x88\x01\x01\x12G\n\x10presence_penalty\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\xc0H\x04R\x0fpresencePenalty\x88\x01\x01\x12.\n\x10reasoning_effort\x18\x07 \x01(\tH\x05R\x0freasoningEffort\x88\x01\x01\x12\x17\n\x04seed\x18\x08 \x01(\x03H\x06R\x04seed\x88\x01\x01\x12>\n\x0btemperature\x18\t \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x07R\x0btemperature\x88\x01\x01\x12\x45\n\x07timeout\x18\n \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12\x31\n\x05top_p\x18\x0b \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x08R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x0c \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormat\x12O\n\x0etoken_exchange\x18\r \x01(\x0b\x32(.kagent.config.v1.ADKTokenExchangeConfigR\rtokenExchange:\x94\x01\xbaH\x90\x01\x1a\x8d\x01\n\x13openai.token_limits\x12;max_tokens and max_completion_tokens are mutually exclusive\x1a\x39!has(this.max_tokens) || !has(this.max_completion_tokens)B\x14\n\x12_frequency_penaltyB\r\n\x0b_max_tokensB\x18\n\x16_max_completion_tokensB\x04\n\x02_nB\x13\n\x11_presence_penaltyB\x13\n\x11_reasoning_effortB\x07\n\x05_seedB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\x8b\x01\n\x16\x41\x44KTokenExchangeConfig\x12`\n\x14gdch_service_account\x18\x01 \x01(\x0b\x32,.kagent.config.v1.ADKGDCHTokenExchangeConfigH\x00R\x12gdchServiceAccountB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\"|\n\x1a\x41\x44KGDCHTokenExchangeConfig\x12\x39\n\x14service_account_path\x18\x01 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x12serviceAccountPath\x12#\n\x08\x61udience\x18\x02 \x01(\tB\x07\xbaH\x04r\x02\x10\x01R\x08\x61udience\"\xfa\x02\n\x0e\x41\x44KAzureOpenAI\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12+\n\nmax_tokens\x18\x04 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x05 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x06 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x42\n\napi_format\x18\x07 \x01(\tB#\xbaH r\x1eR\x00R\x0f\x63hatCompletionsR\tresponsesR\tapiFormatB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"\xbb\x03\n\x0c\x41\x44KAnthropic\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12!\n\x05top_k\x18\x05 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x03R\x04topK\x88\x01\x01\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12%\n\x0eprompt_caching\x18\x07 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x08 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtlB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_pB\x08\n\x06_top_k\"\xb7\x02\n\nADKMistral\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12+\n\nmax_tokens\x18\x02 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x01H\x00R\tmaxTokens\x88\x01\x01\x12>\n\x0btemperature\x18\x03 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\x00@)\x00\x00\x00\x00\x00\x00\x00\x00H\x01R\x0btemperature\x88\x01\x01\x12\x31\n\x05top_p\x18\x04 \x01(\x01\x42\x17\xbaH\x14\x12\x12\x19\x00\x00\x00\x00\x00\x00\xf0?)\x00\x00\x00\x00\x00\x00\x00\x00H\x02R\x04topP\x88\x01\x01\x12\x45\n\x07timeout\x18\x05 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeoutB\r\n\x0b_max_tokensB\x0e\n\x0c_temperatureB\x08\n\x06_top_p\"i\n\x11\x41\x44KGeminiVertexAI\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\x14\n\x12\x41\x44KGeminiAnthropic\"\x8b\x01\n\tADKOllama\x12\x42\n\x07options\x18\x01 \x03(\x0b\x32(.kagent.config.v1.ADKOllama.OptionsEntryR\x07options\x1a:\n\x0cOptionsEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"a\n\tADKGemini\x12>\n\x11max_output_tokens\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x0fmaxOutputTokens\x88\x01\x01\x42\x14\n\x12_max_output_tokens\"\xab\x03\n\nADKBedrock\x12\x16\n\x06region\x18\x01 \x01(\tR\x06region\x12\x45\n\x1f\x61\x64\x64itional_model_request_fields\x18\x02 \x01(\x0cR\x1c\x61\x64\x64itionalModelRequestFields\x12%\n\x0eprompt_caching\x18\x03 \x01(\x08R\rpromptCaching\x12,\n\tcache_ttl\x18\x04 \x01(\tB\x0f\xbaH\x0cr\nR\x00R\x02\x35mR\x02\x31hR\x08\x63\x61\x63heTtl\x12\x43\n\tguardrail\x18\x05 \x01(\x0b\x32%.kagent.config.v1.ADKBedrockGuardrailR\tguardrail\x12N\n\x0cread_timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0breadTimeout\x12T\n\x0f\x63onnect_timeout\x18\x07 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0e\x63onnectTimeout\"e\n\x13\x41\x44KBedrockGuardrail\x12\x1e\n\nidentifier\x18\x01 \x01(\tR\nidentifier\x12\x18\n\x07version\x18\x02 \x01(\tR\x07version\x12\x14\n\x05trace\x18\x03 \x01(\tR\x05trace\"k\n\x0c\x41\x44KSAPAICore\x12\x19\n\x08\x62\x61se_url\x18\x01 \x01(\tR\x07\x62\x61seUrl\x12%\n\x0eresource_group\x18\x02 \x01(\tR\rresourceGroup\x12\x19\n\x08\x61uth_url\x18\x03 \x01(\tR\x07\x61uthUrl\"\xa4\x01\n\nADKFoundry\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\x02 \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\x03 \x01(\tR\napiVersion\x12\x39\n\napi_format\x18\x04 \x01(\tB\x1a\xbaH\x17r\x15R\x00R\x06openaiR\tanthropicR\tapiFormat\"\x94\x05\n\x17\x41\x44KHTTPConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12P\n\x07headers\x18\x02 \x03(\x0b\x32\x36.kagent.config.v1.ADKHTTPConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12\x31\n\x12terminate_on_close\x18\x05 \x01(\x08H\x00R\x10terminateOnClose\x88\x01\x01\x12<\n\x18tls_insecure_skip_verify\x18\x06 \x01(\x08H\x01R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x07 \x01(\tH\x02R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x08 \x01(\x08H\x03R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x15\n\x13_terminate_on_closeB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xc8\x04\n\x16\x41\x44KSSEConnectionParams\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12O\n\x07headers\x18\x02 \x03(\x0b\x32\x35.kagent.config.v1.ADKSSEConnectionParams.HeadersEntryR\x07headers\x12\x45\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x12U\n\x10sse_read_timeout\x18\x04 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x0esseReadTimeout\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"\xfa\x01\n\x16\x41\x44KHTTPMCPServerConfig\x12I\n\x06params\x18\x01 \x01(\x0b\x32).kagent.config.v1.ADKHTTPConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12+\n\x11token_passthrough\x18\x05 \x01(\x08R\x10tokenPassthrough\"\xf8\x01\n\x15\x41\x44KSSEMCPServerConfig\x12H\n\x06params\x18\x01 \x01(\x0b\x32(.kagent.config.v1.ADKSSEConnectionParamsB\x06\xbaH\x03\xc8\x01\x01R\x06params\x12\x14\n\x05tools\x18\x02 \x03(\tR\x05tools\x12\'\n\x0f\x61llowed_headers\x18\x03 \x03(\tR\x0e\x61llowedHeaders\x12)\n\x10require_approval\x18\x04 \x01(\x08R\x0frequireApproval\x12+\n\x11token_passthrough\x18\x05 \x01(\x08R\x10tokenPassthrough\"\xd7\x01\n\x17\x41\x44KStdioMCPServerConfig\x12\x18\n\x07\x63ommand\x18\x01 \x01(\tR\x07\x63ommand\x12\x12\n\x04\x61rgs\x18\x02 \x03(\tR\x04\x61rgs\x12\x44\n\x03\x65nv\x18\x03 \x03(\x0b\x32\x32.kagent.config.v1.ADKStdioMCPServerConfig.EnvEntryR\x03\x65nv\x12\x10\n\x03\x64ir\x18\x04 \x01(\tR\x03\x64ir\x1a\x36\n\x08\x45nvEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xdb\x02\n\x14\x41\x44KRemoteAgentConfig\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n\x03url\x18\x02 \x01(\tR\x03url\x12M\n\x07headers\x18\x03 \x03(\x0b\x32\x33.kagent.config.v1.ADKRemoteAgentConfig.HeadersEntryR\x07headers\x12 \n\x0b\x64\x65scription\x18\x04 \x01(\tR\x0b\x64\x65scription\x12)\n\x10isolate_sessions\x18\x05 \x01(\x08R\x0fisolateSessions\x12\x45\n\x07timeout\x18\x06 \x01(\x0b\x32\x19.google.protobuf.DurationB\x10\xbaH\r\xaa\x01\n\"\x06\x08\x84\xfa\x85\xae\"*\x00R\x07timeout\x1a:\n\x0cHeadersEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\"\xe1\x03\n\x12\x41\x44KEmbeddingConfig\x12\x1a\n\x08provider\x18\x01 \x01(\tR\x08provider\x12\x14\n\x05model\x18\x02 \x01(\tR\x05model\x12\x19\n\x08\x62\x61se_url\x18\x03 \x01(\tR\x07\x62\x61seUrl\x12.\n\x13\x61pi_key_passthrough\x18\x04 \x01(\x08R\x11\x61piKeyPassthrough\x12<\n\x18tls_insecure_skip_verify\x18\x05 \x01(\x08H\x00R\x15tlsInsecureSkipVerify\x88\x01\x01\x12,\n\x10tls_ca_cert_path\x18\x06 \x01(\tH\x01R\rtlsCaCertPath\x88\x01\x01\x12\x38\n\x16tls_disable_system_cas\x18\x07 \x01(\x08H\x02R\x13tlsDisableSystemCas\x88\x01\x01\x12\x1a\n\x08\x65ndpoint\x18\x08 \x01(\tR\x08\x65ndpoint\x12\x1e\n\ndeployment\x18\t \x01(\tR\ndeployment\x12\x1f\n\x0b\x61pi_version\x18\n \x01(\tR\napiVersionB\x1b\n\x19_tls_insecure_skip_verifyB\x13\n\x11_tls_ca_cert_pathB\x19\n\x17_tls_disable_system_cas\"}\n\x0f\x41\x44KMemoryConfig\x12&\n\x08ttl_days\x18\x01 \x01(\x03\x42\x0b\xbaH\x08\"\x06\x18\xff\xc1\x06(\x00R\x07ttlDays\x12\x42\n\tembedding\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKEmbeddingConfigR\tembedding\";\n\x10\x41\x44KNetworkConfig\x12\'\n\x0f\x61llowed_domains\x18\x01 \x03(\tR\x0e\x61llowedDomains\"Z\n\x10\x41\x44KContextConfig\x12\x46\n\ncompaction\x18\x01 \x01(\x0b\x32&.kagent.config.v1.ADKCompressionConfigR\ncompaction\"\xe1\x03\n\x14\x41\x44KCompressionConfig\x12\x43\n\x13\x63ompaction_interval\x18\x01 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x00R\x12\x63ompactionInterval\x88\x01\x01\x12\x35\n\x0coverlap_size\x18\x02 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x01R\x0boverlapSize\x88\x01\x01\x12K\n\x10summarizer_model\x18\x03 \x01(\x0b\x32 .kagent.config.v1.ADKModelConfigR\x0fsummarizerModel\x12\'\n\x0fprompt_template\x18\x04 \x01(\tR\x0epromptTemplate\x12;\n\x0ftoken_threshold\x18\x05 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x01H\x02R\x0etokenThreshold\x88\x01\x01\x12\x44\n\x14\x65vent_retention_size\x18\x06 \x01(\x03\x42\r\xbaH\n\"\x08\x18\xff\xff\xff\xff\x07(\x00H\x03R\x12\x65ventRetentionSize\x88\x01\x01\x42\x16\n\x14_compaction_intervalB\x0f\n\r_overlap_sizeB\x12\n\x10_token_thresholdB\x17\n\x15_event_retention_size\"J\n\x0f\x41\x44KOutputConfig\x12\x1f\n\x0bjson_schema\x18\x01 \x01(\x0cR\njsonSchema\x12\x16\n\x06sha256\x18\x02 \x01(\tR\x06sha256\"\x82\x01\n\x0f\x41\x44KAgentPlugins\x12\x32\n\x06skills\x18\x01 \x03(\x0b\x32\x1a.kagent.config.v1.ADKSkillR\x06skills\x12;\n\x07plugins\x18\x02 \x03(\x0b\x32!.kagent.config.v1.ADKPluginBundleR\x07plugins\"Y\n\x08\x41\x44KSkill\x12\x12\n\x04name\x18\x01 \x01(\tR\x04name\x12\x39\n\x06source\x18\x02 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\"d\n\x0f\x41\x44KPluginBundle\x12\x39\n\x06source\x18\x01 \x01(\x0b\x32!.kagent.config.v1.ADKPluginSourceR\x06source\x12\x16\n\x06skills\x18\x02 \x03(\tR\x06skills\"\xc7\x01\n\x0f\x41\x44KPluginSource\x12\x38\n\x03git\x18\x02 \x01(\x0b\x32$.kagent.config.v1.ADKPluginGitSourceH\x00R\x03git\x12\x35\n\x02s3\x18\x03 \x01(\x0b\x32#.kagent.config.v1.ADKPluginS3SourceH\x00R\x02s3\x12\x1e\n\tdirectory\x18\x05 \x01(\tH\x00R\tdirectory\x12\x12\n\x04path\x18\x04 \x01(\tR\x04pathB\x0f\n\x06source\x12\x05\xbaH\x02\x08\x01\">\n\x12\x41\x44KPluginGitSource\x12\x10\n\x03url\x18\x01 \x01(\tR\x03url\x12\x16\n\x06\x63ommit\x18\x02 \x01(\tR\x06\x63ommit\"\x90\x01\n\x11\x41\x44KPluginS3Source\x12\x1a\n\x08\x65ndpoint\x18\x01 \x01(\tR\x08\x65ndpoint\x12\x16\n\x06\x62ucket\x18\x02 \x01(\tR\x06\x62ucket\x12\x10\n\x03key\x18\x03 \x01(\tR\x03key\x12\x1d\n\nversion_id\x18\x04 \x01(\tR\tversionId\x12\x16\n\x06region\x18\x05 \x01(\tR\x06region\"\x83\x07\n\x10\x41\x44KRuntimeConfig\x12J\n\x11\x63ontrol_plane_url\x18\x01 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\x0f\x63ontrolPlaneUrl\x12?\n\x0bgateway_url\x18\x02 \x01(\tB\x1e\xbaH\x1b\xd8\x01\x01r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\ngatewayUrl\x12\x12\n\x04name\x18\x03 \x01(\tR\x04name\x12\x1c\n\tnamespace\x18\x04 \x01(\tR\tnamespace\x12>\n\tlog_level\x18\x05 \x01(\tB!\xbaH\x1er\x1cR\x00R\x05\x64\x65\x62ugR\x04infoR\x04warnR\x05\x65rrorR\x08logLevel\x12\x12\n\x04host\x18\x06 \x01(\tR\x04host\x12$\n\x04port\x18\x07 \x01(\rB\x0b\xbaH\x08*\x06\x18\xff\xff\x03 \x00H\x00R\x04port\x88\x01\x01\x12:\n\x12max_content_length\x18\x08 \x01(\x03\x42\x07\xbaH\x04\"\x02(\x00H\x01R\x10maxContentLength\x88\x01\x01\x12U\n\x11token_propagation\x18\t \x01(\x0b\x32(.kagent.config.v1.TokenPropagationConfigR\x10tokenPropagation\x12\xb7\x02\n\ttelemetry\x18\n \x01(\x0b\x32!.kagent.config.v1.TelemetryConfigB\xf5\x01\xbaH\xf1\x01\xba\x01\xed\x01\n\"runtime.telemetry.service_identity\x12\x62runtime telemetry resource_attributes must not override compiled service.name or service.namespace\x1a\x63!(\'service.name\' in this.resource_attributes) && !(\'service.namespace\' in this.resource_attributes)R\ttelemetry\x12I\n\rbuiltin_tools\x18\x0b \x01(\x0b\x32$.kagent.config.v1.BuiltinToolsConfigR\x0c\x62uiltinToolsB\x07\n\x05_portB\x15\n\x13_max_content_lengthBCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -236,5 +236,5 @@ _globals['_ADKPLUGINS3SOURCE']._serialized_start=10346 _globals['_ADKPLUGINS3SOURCE']._serialized_end=10490 _globals['_ADKRUNTIMECONFIG']._serialized_start=10493 - _globals['_ADKRUNTIMECONFIG']._serialized_end=11386 + _globals['_ADKRUNTIMECONFIG']._serialized_end=11392 # @@protoc_insertion_point(module_scope) diff --git a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi index e57a227688..fc1ad0afee 100644 --- a/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi +++ b/python/packages/kagent-proto/src/kagent/config/v1/adk_pb2.pyi @@ -528,7 +528,7 @@ class ADKPluginS3Source(_message.Message): def __init__(self, endpoint: _Optional[str] = ..., bucket: _Optional[str] = ..., key: _Optional[str] = ..., version_id: _Optional[str] = ..., region: _Optional[str] = ...) -> None: ... class ADKRuntimeConfig(_message.Message): - __slots__ = ("control_plane_url", "gateway_url", "name", "namespace", "log_level", "host", "port", "max_content_length", "token_propagation", "telemetry", "skill_tools") + __slots__ = ("control_plane_url", "gateway_url", "name", "namespace", "log_level", "host", "port", "max_content_length", "token_propagation", "telemetry", "builtin_tools") CONTROL_PLANE_URL_FIELD_NUMBER: _ClassVar[int] GATEWAY_URL_FIELD_NUMBER: _ClassVar[int] NAME_FIELD_NUMBER: _ClassVar[int] @@ -539,7 +539,7 @@ class ADKRuntimeConfig(_message.Message): MAX_CONTENT_LENGTH_FIELD_NUMBER: _ClassVar[int] TOKEN_PROPAGATION_FIELD_NUMBER: _ClassVar[int] TELEMETRY_FIELD_NUMBER: _ClassVar[int] - SKILL_TOOLS_FIELD_NUMBER: _ClassVar[int] + BUILTIN_TOOLS_FIELD_NUMBER: _ClassVar[int] control_plane_url: str gateway_url: str name: str @@ -550,5 +550,5 @@ class ADKRuntimeConfig(_message.Message): max_content_length: int token_propagation: _config_pb2.TokenPropagationConfig telemetry: _config_pb2.TelemetryConfig - skill_tools: _config_pb2.SkillToolsConfig - def __init__(self, control_plane_url: _Optional[str] = ..., gateway_url: _Optional[str] = ..., name: _Optional[str] = ..., namespace: _Optional[str] = ..., log_level: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., max_content_length: _Optional[int] = ..., token_propagation: _Optional[_Union[_config_pb2.TokenPropagationConfig, _Mapping]] = ..., telemetry: _Optional[_Union[_config_pb2.TelemetryConfig, _Mapping]] = ..., skill_tools: _Optional[_Union[_config_pb2.SkillToolsConfig, _Mapping]] = ...) -> None: ... + builtin_tools: _config_pb2.BuiltinToolsConfig + def __init__(self, control_plane_url: _Optional[str] = ..., gateway_url: _Optional[str] = ..., name: _Optional[str] = ..., namespace: _Optional[str] = ..., log_level: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., max_content_length: _Optional[int] = ..., token_propagation: _Optional[_Union[_config_pb2.TokenPropagationConfig, _Mapping]] = ..., telemetry: _Optional[_Union[_config_pb2.TelemetryConfig, _Mapping]] = ..., builtin_tools: _Optional[_Union[_config_pb2.BuiltinToolsConfig, _Mapping]] = ...) -> None: ... diff --git a/python/packages/kagent-proto/src/kagent/config/v1/config_pb2.py b/python/packages/kagent-proto/src/kagent/config/v1/config_pb2.py index 34dd8cf0c5..6e4cc5da8a 100644 --- a/python/packages/kagent-proto/src/kagent/config/v1/config_pb2.py +++ b/python/packages/kagent-proto/src/kagent/config/v1/config_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import duration_pb2 as google_dot_protobuf_dot_duration__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1dkagent/config/v1/config.proto\x12\x10kagent.config.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\"\xca\x04\n\x0fTelemetryConfig\x12\x34\n\x06traces\x18\x01 \x01(\x0b\x32\x1c.kagent.config.v1.OTLPConfigR\x06traces\x12\x36\n\x07metrics\x18\x02 \x01(\x0b\x32\x1c.kagent.config.v1.OTLPConfigR\x07metrics\x12\x30\n\x04logs\x18\x03 \x01(\x0b\x32\x1c.kagent.config.v1.OTLPConfigR\x04logs\x12j\n\x13resource_attributes\x18\x04 \x03(\x0b\x32\x39.kagent.config.v1.TelemetryConfig.ResourceAttributesEntryR\x12resourceAttributes\x12\'\n\x0f\x63\x61pture_content\x18\x05 \x01(\x08R\x0e\x63\x61ptureContent\x12\x33\n\x16\x63\x61pture_raw_api_bodies\x18\x06 \x01(\x08R\x13\x63\x61ptureRawApiBodies\x12<\n\x11max_capture_bytes\x18\x07 \x01(\x05\x42\x0b\xbaH\x08\x1a\x06\x18\x80\x80\x04 \x00H\x00R\x0fmaxCaptureBytes\x88\x01\x01\x12\x32\n\x15openai_native_tracing\x18\x08 \x01(\x08R\x13openaiNativeTracing\x1a\x45\n\x17ResourceAttributesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x14\n\x12_max_capture_bytes\"\xd8\x01\n\nOTLPConfig\x12\x37\n\x08\x65ndpoint\x18\x01 \x01(\tB\x1b\xbaH\x18r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\x08\x65ndpoint\x12;\n\x08protocol\x18\x02 \x01(\tB\x1a\xbaH\x17r\x15R\x04grpcR\rhttp/protobufH\x00R\x08protocol\x88\x01\x01\x12G\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x12\xbaH\x0f\xaa\x01\x0c\"\x04\x08\x80\xa3\x05\x32\x04\x10\xc0\x84=R\x07timeoutB\x0b\n\t_protocol\"\x87\x01\n\x10SkillToolsConfig\x12*\n\x11\x66ile_search_tools\x18\x01 \x01(\x08R\x0f\x66ileSearchTools\x12$\n\x0e\x62\x61sh_venv_path\x18\x02 \x01(\tR\x0c\x62\x61shVenvPath\x12!\n\x0c\x64isable_bash\x18\x03 \x01(\x08R\x0b\x64isableBash\"\x8e\x02\n\x16TokenPropagationConfig\x12\x18\n\x07\x65nabled\x18\x01 \x01(\x08R\x07\x65nabled\x12M\n\x12sts_well_known_uri\x18\x02 \x01(\tB\x1b\xbaH\x18r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01H\x00R\x0fstsWellKnownUri\x88\x01\x01\x12\x39\n\tresources\x18\x03 \x03(\tB\x1b\xbaH\x18\x92\x01\x15\"\x13r\x11\x10\x01\x32\r^\\S(?:.*\\S)?$R\tresources\x12\x39\n\taudiences\x18\x04 \x03(\tB\x1b\xbaH\x18\x92\x01\x15\"\x13r\x11\x10\x01\x32\r^\\S(?:.*\\S)?$R\taudiencesB\x15\n\x13_sts_well_known_uriBCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1dkagent/config/v1/config.proto\x12\x10kagent.config.v1\x1a\x1b\x62uf/validate/validate.proto\x1a\x1egoogle/protobuf/duration.proto\"\xca\x04\n\x0fTelemetryConfig\x12\x34\n\x06traces\x18\x01 \x01(\x0b\x32\x1c.kagent.config.v1.OTLPConfigR\x06traces\x12\x36\n\x07metrics\x18\x02 \x01(\x0b\x32\x1c.kagent.config.v1.OTLPConfigR\x07metrics\x12\x30\n\x04logs\x18\x03 \x01(\x0b\x32\x1c.kagent.config.v1.OTLPConfigR\x04logs\x12j\n\x13resource_attributes\x18\x04 \x03(\x0b\x32\x39.kagent.config.v1.TelemetryConfig.ResourceAttributesEntryR\x12resourceAttributes\x12\'\n\x0f\x63\x61pture_content\x18\x05 \x01(\x08R\x0e\x63\x61ptureContent\x12\x33\n\x16\x63\x61pture_raw_api_bodies\x18\x06 \x01(\x08R\x13\x63\x61ptureRawApiBodies\x12<\n\x11max_capture_bytes\x18\x07 \x01(\x05\x42\x0b\xbaH\x08\x1a\x06\x18\x80\x80\x04 \x00H\x00R\x0fmaxCaptureBytes\x88\x01\x01\x12\x32\n\x15openai_native_tracing\x18\x08 \x01(\x08R\x13openaiNativeTracing\x1a\x45\n\x17ResourceAttributesEntry\x12\x10\n\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n\x05value\x18\x02 \x01(\tR\x05value:\x02\x38\x01\x42\x14\n\x12_max_capture_bytes\"\xd8\x01\n\nOTLPConfig\x12\x37\n\x08\x65ndpoint\x18\x01 \x01(\tB\x1b\xbaH\x18r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01R\x08\x65ndpoint\x12;\n\x08protocol\x18\x02 \x01(\tB\x1a\xbaH\x17r\x15R\x04grpcR\rhttp/protobufH\x00R\x08protocol\x88\x01\x01\x12G\n\x07timeout\x18\x03 \x01(\x0b\x32\x19.google.protobuf.DurationB\x12\xbaH\x0f\xaa\x01\x0c\"\x04\x08\x80\xa3\x05\x32\x04\x10\xc0\x84=R\x07timeoutB\x0b\n\t_protocol\"\x8d\x01\n\x12\x42uiltinToolsConfig\x12.\n\x13\x64isable_file_search\x18\x01 \x01(\x08R\x11\x64isableFileSearch\x12$\n\x0e\x62\x61sh_venv_path\x18\x02 \x01(\tR\x0c\x62\x61shVenvPath\x12!\n\x0c\x64isable_bash\x18\x03 \x01(\x08R\x0b\x64isableBash\"\x8e\x02\n\x16TokenPropagationConfig\x12\x18\n\x07\x65nabled\x18\x01 \x01(\x08R\x07\x65nabled\x12M\n\x12sts_well_known_uri\x18\x02 \x01(\tB\x1b\xbaH\x18r\x16\x32\x11^https?://[^/?#]+\x88\x01\x01H\x00R\x0fstsWellKnownUri\x88\x01\x01\x12\x39\n\tresources\x18\x03 \x03(\tB\x1b\xbaH\x18\x92\x01\x15\"\x13r\x11\x10\x01\x32\r^\\S(?:.*\\S)?$R\tresources\x12\x39\n\taudiences\x18\x04 \x03(\tB\x1b\xbaH\x18\x92\x01\x15\"\x13r\x11\x10\x01\x32\r^\\S(?:.*\\S)?$R\taudiencesB\x15\n\x13_sts_well_known_uriBCZAgithub.com/kagent-dev/kagent/go/api/gen/kagent/config/v1;configv1b\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -56,8 +56,8 @@ _globals['_TELEMETRYCONFIG_RESOURCEATTRIBUTESENTRY']._serialized_end=677 _globals['_OTLPCONFIG']._serialized_start=702 _globals['_OTLPCONFIG']._serialized_end=918 - _globals['_SKILLTOOLSCONFIG']._serialized_start=921 - _globals['_SKILLTOOLSCONFIG']._serialized_end=1056 - _globals['_TOKENPROPAGATIONCONFIG']._serialized_start=1059 - _globals['_TOKENPROPAGATIONCONFIG']._serialized_end=1329 + _globals['_BUILTINTOOLSCONFIG']._serialized_start=921 + _globals['_BUILTINTOOLSCONFIG']._serialized_end=1062 + _globals['_TOKENPROPAGATIONCONFIG']._serialized_start=1065 + _globals['_TOKENPROPAGATIONCONFIG']._serialized_end=1335 # @@protoc_insertion_point(module_scope) diff --git a/python/packages/kagent-proto/src/kagent/config/v1/config_pb2.pyi b/python/packages/kagent-proto/src/kagent/config/v1/config_pb2.pyi index 365f799b05..2187971cf4 100644 --- a/python/packages/kagent-proto/src/kagent/config/v1/config_pb2.pyi +++ b/python/packages/kagent-proto/src/kagent/config/v1/config_pb2.pyi @@ -47,15 +47,15 @@ class OTLPConfig(_message.Message): timeout: _duration_pb2.Duration def __init__(self, endpoint: _Optional[str] = ..., protocol: _Optional[str] = ..., timeout: _Optional[_Union[datetime.timedelta, _duration_pb2.Duration, _Mapping]] = ...) -> None: ... -class SkillToolsConfig(_message.Message): - __slots__ = ("file_search_tools", "bash_venv_path", "disable_bash") - FILE_SEARCH_TOOLS_FIELD_NUMBER: _ClassVar[int] +class BuiltinToolsConfig(_message.Message): + __slots__ = ("disable_file_search", "bash_venv_path", "disable_bash") + DISABLE_FILE_SEARCH_FIELD_NUMBER: _ClassVar[int] BASH_VENV_PATH_FIELD_NUMBER: _ClassVar[int] DISABLE_BASH_FIELD_NUMBER: _ClassVar[int] - file_search_tools: bool + disable_file_search: bool bash_venv_path: str disable_bash: bool - def __init__(self, file_search_tools: _Optional[bool] = ..., bash_venv_path: _Optional[str] = ..., disable_bash: _Optional[bool] = ...) -> None: ... + def __init__(self, disable_file_search: _Optional[bool] = ..., bash_venv_path: _Optional[str] = ..., disable_bash: _Optional[bool] = ...) -> None: ... class TokenPropagationConfig(_message.Message): __slots__ = ("enabled", "sts_well_known_uri", "resources", "audiences") diff --git a/python/packages/kagent-skills/README.md b/python/packages/kagent-skills/README.md deleted file mode 100644 index 776710c29c..0000000000 --- a/python/packages/kagent-skills/README.md +++ /dev/null @@ -1,5 +0,0 @@ -# KAgent Skills - -Core library for discovering, parsing, and loading KAgent skills from the filesystem. - -For example usage, see `kagent-adk` and `kagent-openai` packages. diff --git a/python/packages/kagent-skills/src/kagent/skills/discovery.py b/python/packages/kagent-skills/src/kagent/skills/discovery.py deleted file mode 100644 index 6467073573..0000000000 --- a/python/packages/kagent-skills/src/kagent/skills/discovery.py +++ /dev/null @@ -1,80 +0,0 @@ -from __future__ import annotations - -import logging -from pathlib import Path - -import yaml - -from .models import Skill - -logger = logging.getLogger(__name__) - - -def parse_skill_metadata(skill_file: Path) -> dict[str, str] | None: - """Parse YAML frontmatter from a SKILL.md file.""" - try: - with open(skill_file, encoding="utf-8") as f: - content = f.read() - - if not content.startswith("---"): - return None - - parts = content.split("---", 2) - if len(parts) < 3: - return None - - metadata = yaml.safe_load(parts[1]) - if isinstance(metadata, dict) and "name" in metadata and "description" in metadata: - return { - "name": metadata["name"], - "description": metadata["description"], - } - return None - except Exception as e: - logger.error(f"Failed to parse metadata from {skill_file}: {e}") - return None - - -def discover_skills(skills_directory: Path) -> list[Skill]: - """Discover available skills and return their metadata.""" - if not skills_directory.exists(): - logger.warning(f"Skills directory not found: {skills_directory}") - return [] - - skills = [] - for skill_dir in sorted(skills_directory.iterdir()): - if not skill_dir.is_dir(): - continue - - skill_file = skill_dir / "SKILL.md" - if not skill_file.exists(): - continue - - try: - metadata = parse_skill_metadata(skill_file) - if metadata: - skills.append(Skill(**metadata)) - except Exception as e: - logger.error(f"Failed to parse skill {skill_dir.name}: {e}") - - return skills - - -def load_skill_content(skills_directory: Path, skill_name: str) -> str: - """Load and return the full content of a skill's SKILL.md file.""" - # Find skill directory - skill_dir = skills_directory / skill_name - if not skill_dir.exists() or not skill_dir.is_dir(): - raise FileNotFoundError(f"Skill '{skill_name}' not found in {skills_directory}") - - skill_file = skill_dir / "SKILL.md" - if not skill_file.exists(): - raise FileNotFoundError(f"Skill '{skill_name}' has no SKILL.md file in {skill_dir}") - - try: - with open(skill_file, encoding="utf-8") as f: - content = f.read() - return content - except Exception as e: - logger.error(f"Failed to load skill {skill_name}: {e}") - raise OSError(f"Error loading skill '{skill_name}': {e}") from e diff --git a/python/packages/kagent-skills/src/kagent/skills/models.py b/python/packages/kagent-skills/src/kagent/skills/models.py deleted file mode 100644 index 9432011d64..0000000000 --- a/python/packages/kagent-skills/src/kagent/skills/models.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations - -from pydantic import BaseModel - - -class Skill(BaseModel): - """Represents the metadata for a skill. - - This is a simple data container used during the initial skill discovery - phase to hold the information parsed from a skill's SKILL.md frontmatter. - """ - - name: str - """The unique name/identifier of the skill.""" - - description: str - """A description of what the skill does and when to use it.""" - - license: str | None = None - """Optional license information for the skill.""" diff --git a/python/packages/kagent-skills/src/kagent/skills/prompts.py b/python/packages/kagent-skills/src/kagent/skills/prompts.py deleted file mode 100644 index 2cefea9c45..0000000000 --- a/python/packages/kagent-skills/src/kagent/skills/prompts.py +++ /dev/null @@ -1,151 +0,0 @@ -from .models import Skill - - -def generate_skills_xml(skills: list[Skill]) -> str: - """Formats a list of skills into an XML block for tool descriptions.""" - if not skills: - return "\n\n" - - skills_entries = [] - for skill in skills: - skill_xml = f"\n{skill.name}\n{skill.description}\n" - skills_entries.append(skill_xml) - - return "\n" + "\n".join(skills_entries) + "\n" - - -def generate_skills_tool_description(skills: list[Skill]) -> str: - """Generates the full, standardized description for the 'skills' tool.""" - skills_xml = generate_skills_xml(skills) - - # This description is based on the ADK version, which is the source of truth. - description = f"""Execute a skill within the main conversation - - -When users ask you to perform tasks, check if any of the available skills below can help complete the task more effectively. Skills provide specialized capabilities and domain knowledge. - -How to use skills: -- Invoke skills using this tool with the skill name only (no arguments) -- When you invoke a skill, the skill's full SKILL.md will load with detailed instructions -- Follow the skill's instructions and use the bash tool to execute commands -- Examples: - - command: \"data-analysis\" - invoke the data-analysis skill - - command: \"pdf-processing\" - invoke the pdf-processing skill - -Important: -- Only use skills listed in below -- Do not invoke a skill that is already loaded in the conversation -- After loading a skill, use the bash tool for execution -- If not specified, scripts are located in the skill-name/scripts subdirectory - - -{skills_xml} -""" - return description - - -def get_read_file_description() -> str: - """Returns the standardized description for the read_file tool.""" - return """Reads a file from the filesystem with line numbers. - -Usage: -- Provide a path to the file (absolute or relative to your working directory) -- Returns content with line numbers (format: LINE_NUMBER|CONTENT) -- Optional offset and limit parameters for reading specific line ranges -- Lines longer than 2000 characters are truncated -- Always read a file before editing it -- You can read from skills/ directory, uploads/, outputs/, or any file in your session -""" - - -def get_write_file_description() -> str: - """Returns the standardized description for the write_file tool.""" - return """Writes content to a file on the filesystem. - -Usage: -- Provide a path (absolute or relative to working directory) and content to write -- Overwrites existing files -- Creates parent directories if needed -- For existing files, read them first using read_file -- Prefer editing existing files over writing new ones -- You can write to your working directory, outputs/, or any writable location -- Note: skills/ directory is read-only -""" - - -def get_edit_file_description() -> str: - """Returns the standardized description for the edit_file tool.""" - return """Performs exact string replacements in files. - -Usage: -- You must read the file first using read_file -- Provide path (absolute or relative to working directory) -- When editing, preserve exact indentation from the file content -- Do NOT include line number prefixes in old_string or new_string -- old_string must be unique unless replace_all=true -- Use replace_all to rename variables/strings throughout the file -- old_string and new_string must be different -- Note: skills/ directory is read-only -""" - - -def get_list_files_description() -> str: - """Returns the standardized description for the list_files tool.""" - return """Lists files and directories at a given path. - -Usage: -- Provide a path (absolute or relative to your working directory); defaults to the working directory -- Directories are listed with a trailing "/"; files are followed by their size in bytes -- You can list skills/ directory, uploads/, outputs/, or any directory in your session -""" - - -def get_grep_file_description() -> str: - """Returns the standardized description for the grep_file tool.""" - return """Searches for a regular expression pattern in a file or directory. - -Usage: -- Provide a pattern and a path (absolute or relative to your working directory) -- Set recursive=true to search all files under a directory path -- Recursion does not follow symlinked subdirectories (e.g. skills/ is a symlink) - - point path directly at skills/ to search inside it -- Set ignore_case=true for case-insensitive matching -- Returns matching lines as path:line_number:content -- You can search the skills/ directory, uploads/, outputs/, or any file/directory in your session -""" - - -def get_bash_description(file_search_tools: bool = False) -> str: - """Returns the standardized description for the bash tool.""" - # This combines the useful parts from both ADK and OpenAI descriptions - description = """Execute bash commands in the skills environment with sandbox protection. - -Working Directory & Structure: -- Commands run in a temporary session directory: /tmp/kagent/{session_id}/ -- /skills -> All skills are available here (read-only). -- Your current working directory and /skills are added to PYTHONPATH. - -Python Imports (CRITICAL): -- To import from a skill, use the name of the skill. - Example: from skills_name.module import function -- If the skills name contains a dash '-', you need to use importlib to import it. - Example: - import importlib - skill_module = importlib.import_module('skill-name.module') - -For file operations: -- Use read_file, write_file, and edit_file for interacting with the filesystem. - -Timeouts: -- python scripts: 60s -- other commands: 30s -""" - # Appended as a trailing paragraph rather than interpolated into the - # description above, so that long, free-form prose block stays a plain - # string -- not an f-string where a stray '{'/'}' added later could - # raise or silently corrupt output. - if file_search_tools: - description += ( - "\nAlso available: list_files and grep_file, for exploring the filesystem without a full shell command.\n" - ) - return description diff --git a/python/packages/kagent-skills/src/kagent/skills/session.py b/python/packages/kagent-skills/src/kagent/skills/session.py deleted file mode 100644 index 0c9b67f153..0000000000 --- a/python/packages/kagent-skills/src/kagent/skills/session.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Manages isolated filesystem paths for agent sessions.""" - -import logging -import tempfile -from pathlib import Path - -logger = logging.getLogger(__name__) - -# Cache of initialized session paths to avoid re-creating symlinks -_session_path_cache: dict[str, Path] = {} - - -def initialize_session_path(session_id: str, skills_directory: str) -> Path: - """Initialize a session's working directory with skills symlink. - - Creates the directory structure and symlink to the skills directory. - - Directory structure: - /tmp/kagent/{session_id}/ - ├── skills/ -> symlink to skills_directory (read-only shared skills) - ├── uploads/ -> staged user files (temporary) - └── outputs/ -> generated files for return - - Args: - session_id: The unique ID of the current session. - skills_directory: Path to the shared skills directory. - - Returns: - The resolved path to the session's root directory. - """ - # Return cached path if already initialized - if session_id in _session_path_cache: - return _session_path_cache[session_id] - - # Initialize new session path - base_path = Path(tempfile.gettempdir()) / "kagent" - session_path = base_path / session_id - - # Create working directories - (session_path / "uploads").mkdir(parents=True, exist_ok=True) - (session_path / "outputs").mkdir(parents=True, exist_ok=True) - - # Create symlink to skills directory - skills_mount = Path(skills_directory) - skills_link = session_path / "skills" - if skills_mount.exists() and not skills_link.exists(): - try: - skills_link.symlink_to(skills_mount) - logger.debug(f"Created symlink: {skills_link} -> {skills_mount}") - except FileExistsError: - # Symlink already exists (race condition from concurrent session setup) - pass - except Exception as e: - # Log but don't fail - skills can still be accessed via absolute path - logger.warning(f"Failed to create skills symlink for session {session_id}: {e}") - - # Cache and return - resolved_path = session_path.resolve() - _session_path_cache[session_id] = resolved_path - return resolved_path - - -def get_session_path(session_id: str) -> Path: - """Get the working directory path for a session. - - This function retrieves the cached session path. If the session hasn't been - initialized, it falls back to auto-initialization with default /skills directory. - - Args: - session_id: The unique ID of the current session. - - Returns: - The resolved path to the session's root directory. - """ - # Return cached path if already initialized - if session_id in _session_path_cache: - return _session_path_cache[session_id] - - # Fallback: auto-initialize with default /skills - logger.warning( - f"Session {session_id} not initialized. " - f"Auto-initializing with default /skills. " - f"For custom skills directories, ensure the executor performs initialization." - ) - return initialize_session_path(session_id, "/skills") - - -def clear_session_cache(session_id: str | None = None) -> None: - """Clear cached session path(s). - - Args: - session_id: Specific session to clear. If None, clears all cached sessions. - """ - if session_id: - _session_path_cache.pop(session_id, None) - else: - _session_path_cache.clear() diff --git a/python/packages/kagent-skills/src/kagent/skills/shell.py b/python/packages/kagent-skills/src/kagent/skills/shell.py deleted file mode 100644 index fa4e348614..0000000000 --- a/python/packages/kagent-skills/src/kagent/skills/shell.py +++ /dev/null @@ -1,450 +0,0 @@ -"""Core, framework-agnostic logic for system tools (file and shell operations).""" - -from __future__ import annotations - -import asyncio -import enum -import logging -import os -import re -from pathlib import Path - -logger = logging.getLogger(__name__) - - -# --- File Operation Tools --- - -# Longest line read_file and grep_file will emit before truncating, counted in -# characters. Mirrors maxLineRunes in go/adk/pkg/tools/shell.go, and the -# "Lines longer than 2000 characters are truncated" both runtimes' read_file -# descriptions promise (see prompts.py). -_MAX_LINE_CHARS = 2000 - - -def _truncate_line(line: str) -> str: - """Shorten line to at most _MAX_LINE_CHARS characters, marking a cut with "...".""" - if len(line) > _MAX_LINE_CHARS: - return line[:_MAX_LINE_CHARS] + "..." - return line - - -class _WalkEntryAction(enum.Enum): - """How a recursive grep should treat one entry it walked.""" - - #: A regular, in-bounds file that should be grepped. - GREP = enum.auto() - #: Silently excluded by policy, not a read failure -- a non-regular file - #: (FIFO/socket/device), or a symlink whose target escapes the search root. - SKIP = enum.auto() - #: A genuine read/stat failure on this entry. - UNREADABLE = enum.auto() - - -def _classify_walk_entry(root: Path, entry: Path) -> tuple[_WalkEntryAction, Path | None]: - """Decide how grep_content should treat entry, given the resolved search root. - - This is the Python half of a contract the Go runtime implements as - classifyWalkEntry in go/adk/pkg/tools/grep.go. Both must sort a tree into - the same three outcomes, and reading them side by side is the only - practical way to confirm they still do -- so they share these names, this - argument order, and this return shape. Prefer changing both, or neither. - - They are not branch-for-branch identical, and should not be forced to be. - Go tests IsDir, EvalSymlinks, Stat and IsRegular separately because - filepath.WalkDir hands it directories and unresolvable links; here - os.walk yields only filenames, and Path.is_file() already collapses - "exists, resolves, and is a regular file" into one call. The outcomes - agree; the number of branches reaching them does not. - - One divergence remains, and it is narrower than a stat failure in general: - Path.is_file() swallows ENOENT, ENOTDIR, EBADF and ELOOP, so on a - non-symlink those return False and land on SKIP where Go's failed Stat - gives UNREADABLE. Reaching it needs the entry to vanish between os.walk - listing it and the stat below. EACCES is *not* in that set -- it raises, - and is caught below as UNREADABLE, so the two runtimes agree there. - Symlink loops and broken links are unaffected; both are UNREADABLE in - either runtime. - - Returns the resolved path alongside GREP so the caller reads what was - just checked rather than re-resolving the symlink separately. - """ - try: - # is_file() follows symlinks and checks S_ISREG, so a False covers two - # cases that deserve different treatment. - if not entry.is_file(): - # A broken symlink (or a symlink loop) is a genuine read failure. - # Counting it is what stops a tree of dangling links from reporting - # a confidently empty "no matches found". - if entry.is_symlink() and not entry.exists(): - return _WalkEntryAction.UNREADABLE, None - # A FIFO/socket/device is excluded by policy, not failure: opening - # one can block indefinitely, and grep has no business reading it. - return _WalkEntryAction.SKIP, None - - resolved = entry.resolve() - except OSError: - # Every stat above is on an entry os.walk has already listed, so a - # raise here means the entry became unreadable in between -- most often - # because its directory is readable but not searchable (mode 0o444), - # which lists names while denying stat on the children. - # - # This must be an outcome rather than an exception: the caller counts - # UNREADABLE and keeps walking, so letting it propagate would discard - # every match already found elsewhere in the tree. Go reaches the same - # answer by mapping each EvalSymlinks/Stat failure to unreadable. - return _WalkEntryAction.UNREADABLE, None - - # Bound each entry by the directory actually being searched. The caller's - # allowed_root is the whole session plus the skills dir, so it alone would - # let a symlink here pull in a file from a sibling directory nobody asked - # to search. Containment against root subsumes it: root was itself - # validated against allowed_root, so anything under root is inside a root. - if not resolved.is_relative_to(root): - return _WalkEntryAction.SKIP, None - - return _WalkEntryAction.GREP, resolved - - -def _validate_path( - file_path: Path, - allowed_roots: Path | list[Path] | None, -) -> Path: - """Resolve the path and ensure it is within at least one allowed root directory.""" - resolved = file_path.resolve() - if allowed_roots is None: - return resolved - - roots = [allowed_roots] if isinstance(allowed_roots, Path) else allowed_roots - for root in roots: - if resolved.is_relative_to(root.resolve()): - return resolved - - root_list = ", ".join(str(r.resolve()) for r in roots) - raise PermissionError(f"Access denied: {resolved} is outside the allowed directories: {root_list}") - - -def read_file_content( - file_path: Path, - offset: int | None = None, - limit: int | None = None, - allowed_root: Path | list[Path] | None = None, -) -> str: - """Reads a file with line numbers, raising errors on failure.""" - file_path = _validate_path(file_path, allowed_root) - - if not file_path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") - - if not file_path.is_file(): - raise IsADirectoryError(f"Path is not a file: {file_path}") - - try: - lines = file_path.read_text(encoding="utf-8").splitlines() - except Exception as e: - raise OSError(f"Error reading file {file_path}: {e}") from e - - start = (offset - 1) if offset and offset > 0 else 0 - end = (start + limit) if limit else len(lines) - - result_lines = [] - for i, line in enumerate(lines[start:end], start=start + 1): - result_lines.append(f"{i:6d}|{_truncate_line(line)}") - - if not result_lines: - return "File is empty." - - return "\n".join(result_lines) - - -def write_file_content(file_path: Path, content: str, allowed_root: Path | None = None) -> str: - """Writes content to a file, creating parent directories if needed.""" - file_path = _validate_path(file_path, allowed_root) - - try: - file_path.parent.mkdir(parents=True, exist_ok=True) - file_path.write_text(content, encoding="utf-8") - logger.info(f"Successfully wrote to {file_path}") - return f"Successfully wrote to {file_path}" - except Exception as e: - raise OSError(f"Error writing file {file_path}: {e}") from e - - -def edit_file_content( - file_path: Path, - old_string: str, - new_string: str, - replace_all: bool = False, - allowed_root: Path | None = None, -) -> str: - """Performs an exact string replacement in a file.""" - if old_string == new_string: - raise ValueError("old_string and new_string must be different") - - file_path = _validate_path(file_path, allowed_root) - - if not file_path.exists(): - raise FileNotFoundError(f"File not found: {file_path}") - - if not file_path.is_file(): - raise IsADirectoryError(f"Path is not a file: {file_path}") - - try: - content = file_path.read_text(encoding="utf-8") - except Exception as e: - raise OSError(f"Error reading file {file_path}: {e}") from e - - if old_string not in content: - raise ValueError(f"old_string not found in {file_path}") - - count = content.count(old_string) - if not replace_all and count > 1: - raise ValueError( - f"old_string appears {count} times in {file_path}. Provide more context or set replace_all=true." - ) - - if replace_all: - new_content = content.replace(old_string, new_string) - else: - new_content = content.replace(old_string, new_string, 1) - - try: - file_path.write_text(new_content, encoding="utf-8") - logger.info(f"Successfully replaced {count} occurrence(s) in {file_path}") - return f"Successfully replaced {count} occurrence(s) in {file_path}" - except Exception as e: - raise OSError(f"Error writing file {file_path}: {e}") from e - - -def list_dir_content(dir_path: Path, allowed_root: Path | list[Path] | None = None) -> str: - """Lists the entries of a directory, one per line. - - Directories are suffixed with "/"; files are followed by their size in bytes. - """ - dir_path = _validate_path(dir_path, allowed_root) - - if not dir_path.exists(): - raise FileNotFoundError(f"Directory not found: {dir_path}") - - if not dir_path.is_dir(): - raise NotADirectoryError(f"Path is not a directory: {dir_path}") - - entries = sorted(dir_path.iterdir(), key=lambda p: p.name) - if not entries: - return "Directory is empty." - - lines = [] - for entry in entries: - if entry.is_dir(): - lines.append(f"{entry.name}/") - continue - try: - size = entry.stat().st_size - except OSError: - lines.append(entry.name) - continue - lines.append(f"{entry.name}\t{size}") - - return "\n".join(lines) - - -def grep_content( - file_or_dir_path: Path, - pattern: str, - recursive: bool = False, - ignore_case: bool = False, - allowed_root: Path | list[Path] | None = None, -) -> str: - """Searches path for lines matching a regular expression pattern. - - If path is a directory, recursive must be true to search its files. - - pattern is untrusted, agent-controlled input: Python's backtracking `re` - engine can take catastrophically long on an adversarial pattern. This - function does not bound its own execution time -- callers must do so - (e.g. via a timeout around a thread/process offload) if the caller is - exposed to untrusted patterns. - """ - file_or_dir_path = _validate_path(file_or_dir_path, allowed_root) - - try: - compiled = re.compile(pattern, re.IGNORECASE if ignore_case else 0) - except re.error as e: - raise ValueError(f"invalid pattern: {e}") from e - - if not file_or_dir_path.exists(): - raise FileNotFoundError(f"Path not found: {file_or_dir_path}") - - def grep_file(file_path: Path) -> list[str]: - matches = [] - with file_path.open("r", encoding="utf-8", errors="replace") as f: - for line_num, line in enumerate(f, start=1): - line = line.rstrip("\n") - if compiled.search(line): - matches.append(f"{file_path}:{line_num}:{_truncate_line(line)}") - return matches - - results: list[str] = [] - skipped = 0 - if file_or_dir_path.is_dir(): - if not recursive: - raise IsADirectoryError(f"{file_or_dir_path} is a directory; set recursive=true to search directories") - - # os.walk (not rglob) so that a directory-level failure -- root or - # nested -- is observable via onerror. rglob() silently omits any - # directory it can't list, at any depth, with no hook to detect it; - # that let a nested unreadable subdirectory disappear from a - # recursive search with no signal at all, exactly the "confidently - # wrong empty result" failure mode skipped/the annotation below - # exists to prevent. followlinks defaults to False, so this doesn't - # descend into symlinked directories, matching grepFile's Go twin. - root_str = str(file_or_dir_path) - walk_errors: list[OSError] = [] - entries: list[Path] = [] - for dirpath, _dirnames, filenames in os.walk(file_or_dir_path, onerror=walk_errors.append): - entries.extend(Path(dirpath) / name for name in filenames) - - for walk_err in walk_errors: - if walk_err.filename == root_str: - # The search root itself couldn't be read: the search never - # actually ran, so surface a real error instead of a - # misleadingly confident "no matches found". - raise OSError(f"{file_or_dir_path} could not be read: {walk_err}") from walk_err - # A nested subdirectory that couldn't be read shouldn't abort - # matches already found in sibling directories -- just count it. - skipped += len(walk_errors) - - for entry in sorted(entries): - action, safe_entry = _classify_walk_entry(file_or_dir_path, entry) - if action is _WalkEntryAction.SKIP: - continue - if action is _WalkEntryAction.UNREADABLE: - skipped += 1 - continue - try: - results.extend(grep_file(safe_entry)) - except OSError: - # A read error on one file shouldn't abort matches already - # found elsewhere in the tree, but it also shouldn't look - # identical to a genuinely empty search -- hence the count. - skipped += 1 - else: - if not file_or_dir_path.is_file(): - raise OSError(f"{file_or_dir_path} is not a regular file") - results.extend(grep_file(file_or_dir_path)) - - if not results: - if skipped: - return f"no matches found ({skipped} entries could not be read)" - return "no matches found" - - output = "\n".join(results) - if skipped: - output += f"\n\n({skipped} entries could not be read)" - return output - - -# --- Shell Operation Tools --- - -# Matches env-var names containing secret-related segments as whole -# underscore-delimited tokens (e.g. OPENAI_API_KEY, DATABASE_PASSWORD) -# but not partial hits like TOKENIZERS_PARALLELISM. -_SECRET_PATTERNS = re.compile( - r"(?:^|_)(API_KEY|ACCESS_KEY|SECRET|TOKEN|PASSWORD|CREDENTIALS?|PRIVATE_KEY)(?:_|$)", - re.IGNORECASE, -) - -# Explicit denylist of known secret env vars injected by the kagent controller -# (see go/core/pkg/env/providers.go). Belt-and-suspenders: the regex handles -# the general case, this set catches any known vars that the regex might miss. -_SECRET_ENV_NAMES: set[str] = { - "OPENAI_API_KEY", - "ANTHROPIC_API_KEY", - "AZURE_OPENAI_API_KEY", - "AZURE_AD_TOKEN", - "GOOGLE_API_KEY", - "GOOGLE_APPLICATION_CREDENTIALS", - "AWS_ACCESS_KEY_ID", - "AWS_SECRET_ACCESS_KEY", - "AWS_SESSION_TOKEN", - "AWS_BEARER_TOKEN_BEDROCK", -} - - -def _sanitize_env(env: dict[str, str] | None = None) -> dict[str, str]: - """Return a copy of the environment with secret variables removed.""" - source = env if env is not None else os.environ - return {k: v for k, v in source.items() if k not in _SECRET_ENV_NAMES and not _SECRET_PATTERNS.search(k)} - - -def _get_command_timeout_seconds(command: str) -> float: - """Determine appropriate timeout for a command.""" - if "python " in command or "python3 " in command: - return 60.0 # 1 minute for python scripts - else: - return 30.0 # 30 seconds for other commands - - -async def execute_command( - command: str, - working_dir: Path, - skills_dir: Path = Path("/skills"), - bash_venv_path: str = "", -) -> str: - """Execute a shell command inside the agent runtime.""" - timeout = _get_command_timeout_seconds(command) - - env = _sanitize_env() - # Add skills directory and working directory to PYTHONPATH - pythonpath_additions = [str(working_dir), str(skills_dir)] - if "PYTHONPATH" in env: - pythonpath_additions.append(env["PYTHONPATH"]) - env["PYTHONPATH"] = ":".join(pythonpath_additions) - - # If a separate venv for shell commands is specified, use its python and pip - # Otherwise use the process Python environment. - if bash_venv_path: - bash_venv_bin = os.path.join(bash_venv_path, "bin") - # Prepend bash venv to PATH so its python and pip are used - env["PATH"] = f"{bash_venv_bin}:{env.get('PATH', '')}" - env["VIRTUAL_ENV"] = bash_venv_path - - try: - process = await asyncio.create_subprocess_exec( - "bash", - "-c", - command, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, - cwd=working_dir, - env=env, - ) - - try: - stdout, stderr = await asyncio.wait_for(process.communicate(), timeout=timeout) - except TimeoutError: - process.kill() - await process.wait() - return f"Error: Command timed out after {timeout}s" - - stdout_str = stdout.decode("utf-8", errors="replace") if stdout else "" - stderr_str = stderr.decode("utf-8", errors="replace") if stderr else "" - - if process.returncode != 0: - error_msg = f"Command failed with exit code {process.returncode}" - if stderr_str: - error_msg += f":\n{stderr_str}" - elif stdout_str: - error_msg += f":\n{stdout_str}" - return error_msg - - output = stdout_str - if stderr_str and "WARNING" not in stderr_str: - output += f"\n{stderr_str}" - - logger.info(f"Command executed successfully: {output}") - - return output.strip() if output.strip() else "Command completed successfully." - - except Exception as e: - logger.error(f"Error executing command: {e}") - return f"Error: {e}" diff --git a/python/packages/kagent-skills/.python-version b/python/packages/kagent-tools/.python-version similarity index 100% rename from python/packages/kagent-skills/.python-version rename to python/packages/kagent-tools/.python-version diff --git a/python/packages/kagent-tools/README.md b/python/packages/kagent-tools/README.md new file mode 100644 index 0000000000..61fe0f906c --- /dev/null +++ b/python/packages/kagent-tools/README.md @@ -0,0 +1,13 @@ +# Kagent Tools + +`kagent-tools` provides shared filesystem, shell, and workspace helpers used by `kagent-adk` and +`kagent-openai`. Import helpers from `kagent.tools`. The package has no runtime +dependencies. + +The workspace is `/data/workspace`, shared within an Actor and preserved by +Substrate snapshots. It is created on first use; applications create any +subdirectories they need. Paths follow normal filesystem semantics inside the +sandbox. + +ADK uses upstream skill discovery and `SkillToolset`. BYO harnesses, including +the OpenAI adapter, leave skill discovery and loading to user code. diff --git a/python/packages/kagent-skills/pyproject.toml b/python/packages/kagent-tools/pyproject.toml similarity index 71% rename from python/packages/kagent-skills/pyproject.toml rename to python/packages/kagent-tools/pyproject.toml index d70e5ab1cb..7d5159074f 100644 --- a/python/packages/kagent-skills/pyproject.toml +++ b/python/packages/kagent-tools/pyproject.toml @@ -1,13 +1,10 @@ [project] -name = "kagent-skills" +name = "kagent-tools" version = "0.1.0" -description = "Core library for discovering and loading KAgent skills." +description = "Shared filesystem, shell, and workspace helpers for kagent Python adapters." readme = "README.md" requires-python = ">=3.11" -dependencies = [ - "pydantic>=2.0.0", - "pyyaml>=6.0" -] +dependencies = [] [project.optional-dependencies] dev = [ @@ -21,8 +18,9 @@ dev = [ requires = ["setuptools>=84.0.0", "wheel>=0.48.0"] build-backend = "setuptools.build_meta" -[tool.hatch.build.targets.wheel] -packages = ["src/kagent"] +[tool.setuptools.packages.find] +where = ["src"] +include = ["kagent.tools*"] [tool.black] line-length = 120 diff --git a/python/packages/kagent-skills/src/kagent/skills/__init__.py b/python/packages/kagent-tools/src/kagent/tools/__init__.py similarity index 63% rename from python/packages/kagent-skills/src/kagent/skills/__init__.py rename to python/packages/kagent-tools/src/kagent/tools/__init__.py index 9cb9081c5b..2b0013ba55 100644 --- a/python/packages/kagent-skills/src/kagent/skills/__init__.py +++ b/python/packages/kagent-tools/src/kagent/tools/__init__.py @@ -1,7 +1,4 @@ -from .discovery import discover_skills, load_skill_content -from .models import Skill from .prompts import ( - generate_skills_tool_description, get_bash_description, get_edit_file_description, get_grep_file_description, @@ -9,11 +6,6 @@ get_read_file_description, get_write_file_description, ) -from .session import ( - clear_session_cache, - get_session_path, - initialize_session_path, -) from .shell import ( edit_file_content, execute_command, @@ -22,25 +14,20 @@ read_file_content, write_file_content, ) +from .workspace import get_workspace_path __all__ = [ - "discover_skills", - "load_skill_content", - "Skill", "read_file_content", "write_file_content", "edit_file_content", "list_dir_content", "grep_content", "execute_command", - "generate_skills_tool_description", "get_read_file_description", "get_write_file_description", "get_edit_file_description", "get_list_files_description", "get_grep_file_description", "get_bash_description", - "initialize_session_path", - "get_session_path", - "clear_session_cache", + "get_workspace_path", ] diff --git a/python/packages/kagent-tools/src/kagent/tools/prompts.py b/python/packages/kagent-tools/src/kagent/tools/prompts.py new file mode 100644 index 0000000000..0e238afe47 --- /dev/null +++ b/python/packages/kagent-tools/src/kagent/tools/prompts.py @@ -0,0 +1,86 @@ +def get_read_file_description() -> str: + """Returns the standardized description for the read_file tool.""" + return """Reads a file from the filesystem with line numbers. + +Usage: +- Provide a path to the file (absolute or relative to your working directory) +- Returns content with line numbers (format: LINE_NUMBER|CONTENT) +- Reads default to 2000 lines and results are capped at 64 KiB; use offset/limit to page +- Lines longer than 2000 characters are truncated +- Always read a file before editing it +- Paths are relative to /data/workspace unless absolute +""" + + +def get_write_file_description() -> str: + """Returns the standardized description for the write_file tool.""" + return """Writes content to a file on the filesystem. + +Usage: +- Provide a path (absolute or relative to working directory) and content to write +- Overwrites existing files +- Creates parent directories if needed +- For existing files, read them first using read_file +- Prefer editing existing files over writing new ones +- You can write to any writable location in the sandbox +- Filesystem access is governed by the sandbox +""" + + +def get_edit_file_description() -> str: + """Returns the standardized description for the edit_file tool.""" + return """Performs exact string replacements in files. + +Usage: +- You must read the file first using read_file +- Provide path (absolute or relative to working directory) +- When editing, preserve exact indentation from the file content +- Do NOT include line number prefixes in old_string or new_string +- old_string must be unique unless replace_all=true +- Use replace_all to rename variables/strings throughout the file +- old_string and new_string must be different +- Filesystem access is governed by the sandbox +""" + + +def get_list_files_description() -> str: + """Returns the standardized description for the list_files tool.""" + return """Lists files and directories at a given path. + +Usage: +- Provide a path (absolute or relative to your working directory); defaults to the working directory +- Directories are listed with a trailing "/"; files are followed by their size in bytes +- Paths are relative to /data/workspace unless absolute; results are capped at 64 KiB +""" + + +def get_grep_file_description() -> str: + """Returns the standardized description for the grep_file tool.""" + return """Searches for a regular expression pattern in a file or directory. + +Usage: +- Provide a pattern and a path (absolute or relative to your working directory) +- Set recursive=true to search all files under a directory path +- Recursion does not follow symlinked subdirectories; target a symlink directly to search it +- Set ignore_case=true for case-insensitive matching +- Returns matching lines as path:line_number:content +- Paths are relative to /data/workspace unless absolute; results are capped at 64 KiB +""" + + +def get_bash_description(file_search_tools: bool = False) -> str: + """Returns the standardized description for the bash tool.""" + description = """Execute a bash command inside the agent sandbox. + +- Working directory: /data/workspace. Files there survive suspend/resume and checkpoints. +- Each call starts a new shell; use cd inside a command or absolute paths. +- Default timeout: 60 seconds. Set timeout_seconds up to 600 for longer commands. +- Returns combined stdout and stderr, capped at 64 KiB with a truncation marker. +- Redirect large output to a file if you need to inspect it in full. +- Child processes are stopped when the call finishes or is canceled. +""" + if file_search_tools: + description += ( + "\nAlso available: list_files and grep_file, for exploring the filesystem without a full shell command.\n" + ) + return description diff --git a/python/packages/kagent-tools/src/kagent/tools/shell.py b/python/packages/kagent-tools/src/kagent/tools/shell.py new file mode 100644 index 0000000000..afdbcfe296 --- /dev/null +++ b/python/packages/kagent-tools/src/kagent/tools/shell.py @@ -0,0 +1,276 @@ +"""Filesystem and command helpers for the Substrate sandbox.""" + +from __future__ import annotations + +import asyncio +import enum +import os +import re +import signal +from pathlib import Path + +_MAX_LINE_CHARS = 2000 +_MAX_LINE_BYTES = 1024 * 1024 +_MAX_FILE_OUTPUT = 64 * 1024 +_DEFAULT_READ_LINES = 2000 +_FILE_TRUNCATION_MARKER = "[output truncated; use read_file offset/limit or a narrower search]" + + +def _truncate_line(line: str) -> str: + return line[:_MAX_LINE_CHARS] + "..." if len(line) > _MAX_LINE_CHARS else line + + +class _FileOutput: + """Keep complete result lines within the same byte budget as Go.""" + + def __init__(self): + self.lines: list[str] = [] + self.size = 0 + self.truncated = False + + def append(self, line: str) -> bool: + size = len(line.encode("utf-8")) + 1 + if self.size + size > _MAX_FILE_OUTPUT: + self.truncated = True + return False + self.lines.append(line) + self.size += size + return True + + def text(self) -> str: + lines = self.lines + ([_FILE_TRUNCATION_MARKER] if self.truncated else []) + return "\n".join(lines) + + +def _file_lines(path: Path): + if not path.is_file(): + raise OSError(f"{path} is not a regular file") + with path.open("rb") as stream: + line_num = 0 + while line := stream.readline(_MAX_LINE_BYTES + 1): + if len(line) > _MAX_LINE_BYTES: + raise OSError(f"Line exceeds {_MAX_LINE_BYTES} bytes in {path}") + line_num += 1 + yield line_num, line.decode("utf-8", errors="replace").rstrip("\r\n") + + +class _WalkEntryAction(enum.Enum): + GREP = enum.auto() + SKIP = enum.auto() + UNREADABLE = enum.auto() + + +def _classify_walk_entry(root: Path, entry: Path) -> tuple[_WalkEntryAction, Path | None]: + """Skip special files and links outside the requested search tree.""" + try: + if not entry.is_file(): + if entry.is_symlink() and not entry.exists(): + return _WalkEntryAction.UNREADABLE, None + return _WalkEntryAction.SKIP, None + resolved = entry.resolve() + except OSError: + return _WalkEntryAction.UNREADABLE, None + if not resolved.is_relative_to(root): + return _WalkEntryAction.SKIP, None + return _WalkEntryAction.GREP, resolved + + +def read_file_content(file_path: Path, offset: int | None = None, limit: int | None = None) -> str: + """Read a bounded page, without loading the whole file.""" + file_path = file_path.resolve() + if not file_path.exists(): + raise FileNotFoundError(f"File not found: {file_path}") + if not file_path.is_file(): + raise IsADirectoryError(f"Path is not a file: {file_path}") + start = max(offset or 1, 1) + line_limit = limit if limit and limit > 0 else _DEFAULT_READ_LINES + result = _FileOutput() + count = 0 + for line_num, line in _file_lines(file_path): + if line_num < start: + continue + count += 1 + if not result.append(f"{line_num:6d}|{_truncate_line(line)}"): + break + if count == line_limit: + if not limit or limit < 0: + result.append(f"[read limited to {line_limit} lines; continue with offset={line_num + 1}]") + break + return result.text() or "File is empty." + + +def write_file_content(file_path: Path, content: str) -> str: + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + return f"Successfully wrote to {file_path}" + + +def edit_file_content(file_path: Path, old_string: str, new_string: str, replace_all: bool = False) -> str: + if not old_string: + raise ValueError("old_string cannot be empty") + if old_string == new_string: + raise ValueError("old_string and new_string must be different") + content = file_path.read_text(encoding="utf-8") + count = content.count(old_string) + if not count: + raise ValueError(f"old_string not found in {file_path}") + if not replace_all and count > 1: + raise ValueError( + f"old_string appears {count} times in {file_path}. Provide more context or set replace_all=true." + ) + file_path.write_text(content.replace(old_string, new_string, -1 if replace_all else 1), encoding="utf-8") + return f"Successfully replaced {count if replace_all else 1} occurrence(s) in {file_path}" + + +def list_dir_content(dir_path: Path) -> str: + result = _FileOutput() + with os.scandir(dir_path) as entries: + for entry in entries: + line = entry.name + try: + info = entry.stat() + line += "/" if entry.is_dir() else f"\t{info.st_size}" + except OSError: + pass # A broken or vanished entry is listed without metadata. + if not result.append(line): + break + return result.text() or "Directory is empty." + + +def grep_content(file_or_dir_path: Path, pattern: str, recursive: bool = False, ignore_case: bool = False) -> str: + """Stream bounded results. Callers bound Python regex execution with a worker timeout.""" + root = file_or_dir_path.resolve() + try: + compiled = re.compile(pattern, re.IGNORECASE if ignore_case else 0) + except re.error as error: + raise ValueError(f"invalid pattern: {error}") from error + if not root.exists(): + raise FileNotFoundError(f"Path not found: {root}") + result = _FileOutput() + skipped = 0 + + def grep_file(path: Path): + for line_num, line in _file_lines(path): + if compiled.search(line) and not result.append(f"{path}:{line_num}:{_truncate_line(line)}"): + break + + def walk_error(error: OSError): + nonlocal skipped + if error.filename == str(root): + raise OSError(f"{root} could not be read: {error}") from error + skipped += 1 + + if root.is_dir(): + if not recursive: + raise IsADirectoryError(f"{root} is a directory; set recursive=true to search directories") + for dirpath, _dirnames, filenames in os.walk(root, onerror=walk_error): + for name in filenames: + action, entry = _classify_walk_entry(root, Path(dirpath) / name) + if action is _WalkEntryAction.UNREADABLE: + skipped += 1 + elif action is _WalkEntryAction.GREP: + try: + grep_file(entry) + except OSError: + skipped += 1 + if result.truncated: + break + if result.truncated: + break + else: + grep_file(root) + if not result.lines and not result.truncated: + return f"no matches found ({skipped} entries could not be read)" if skipped else "no matches found" + output = result.text() + return output + f"\n\n({skipped} entries could not be read)" if skipped else output + + +# --- Shell Operation Tools --- + +_MAX_COMMAND_OUTPUT = 64 * 1024 +_TRUNCATION_MARKER = "\n[output truncated at 65536 bytes; redirect output to a file for the full result]" + + +async def execute_command( + command: str, + working_dir: Path, + skills_dir: Path | None = None, + bash_venv_path: str = "", + timeout_seconds: int = 0, +) -> str: + """Run one command tree using the same defaults as the Go runtime.""" + if not isinstance(timeout_seconds, int) or not 0 <= timeout_seconds <= 600: + return "Error: timeout_seconds must be between 0 and 600 (0 uses the 60-second default)" + timeout = timeout_seconds or 60 + # Preserve the Actor environment, including SDK placeholders used by egress. + env = dict(os.environ) + python_paths = [str(working_dir)] + if skills_dir is not None: + python_paths.append(str(skills_dir)) + if env.get("PYTHONPATH"): + python_paths.append(env["PYTHONPATH"]) + env["PYTHONPATH"] = os.pathsep.join(python_paths) + if bash_venv_path: + env["PATH"] = str(Path(bash_venv_path) / "bin") + os.pathsep + env.get("PATH", "") + env["VIRTUAL_ENV"] = bash_venv_path + startup = asyncio.create_task( + asyncio.create_subprocess_exec( + "bash", + "-c", + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.STDOUT, + cwd=working_dir, + env=env, + start_new_session=True, + ) + ) + captured = bytearray() + truncated = False + + async def drain_output(): + nonlocal truncated + while chunk := await process.stdout.read(8192): + keep = min(len(chunk), _MAX_COMMAND_OUTPUT - len(captured)) + captured.extend(chunk[:keep]) + truncated = truncated or keep < len(chunk) + + async def wait_for_exit(): + # Process.wait() can wait for inherited pipes held by grandchildren. + # Observe the shell exit independently, then stop its whole process group. + while process.returncode is None: + await asyncio.sleep(0.01) + + reader = None + timed_out = False + try: + # Cancellation during process creation must not lose the process handle. + process = await asyncio.shield(startup) + reader = asyncio.create_task(drain_output()) + try: + await asyncio.wait_for(wait_for_exit(), timeout) + except TimeoutError: + timed_out = True + finally: + process = await startup + if reader is None: + reader = asyncio.create_task(drain_output()) + try: + os.killpg(process.pid, signal.SIGKILL) + except ProcessLookupError: + pass # The entire group has already exited. + try: + await asyncio.wait_for(asyncio.shield(reader), 1) + finally: + if not reader.done(): + reader.cancel() + await asyncio.gather(reader, return_exceptions=True) + await asyncio.wait_for(process.wait(), 1) + output = captured.decode("utf-8", errors="replace").strip() + if truncated: + output += _TRUNCATION_MARKER + if timed_out: + return f"Error: command timed out after {timeout}s\n{output}".rstrip() + if process.returncode != 0: + return f"Error: command failed with exit code {process.returncode}\n{output}".rstrip() + return output or "Command completed successfully." diff --git a/python/packages/kagent-tools/src/kagent/tools/workspace.py b/python/packages/kagent-tools/src/kagent/tools/workspace.py new file mode 100644 index 0000000000..a6df6f1245 --- /dev/null +++ b/python/packages/kagent-tools/src/kagent/tools/workspace.py @@ -0,0 +1,11 @@ +"""The durable workspace owned by one Substrate Actor.""" + +from pathlib import Path + +WORKSPACE = Path("/data/workspace") + + +def get_workspace_path() -> Path: + """Prepare durable files shared by this Actor's root agent and subagents.""" + WORKSPACE.mkdir(parents=True, exist_ok=True) + return WORKSPACE.resolve() diff --git a/python/packages/kagent-tools/tests/test_commands.py b/python/packages/kagent-tools/tests/test_commands.py new file mode 100644 index 0000000000..f438112f51 --- /dev/null +++ b/python/packages/kagent-tools/tests/test_commands.py @@ -0,0 +1,127 @@ +"""Command lifecycle and output contract shared with Go ADK.""" + +import asyncio +from pathlib import Path + +import pytest + +from kagent.tools import execute_command, get_workspace_path + + +@pytest.mark.asyncio +@pytest.mark.parametrize("exit_code", [0, 7]) +async def test_diagnostics_are_retained(tmp_path, caplog, exit_code): + result = await execute_command( + f"printf 'private-output\\n'; printf 'WARNING: diagnostic\\n' >&2; exit {exit_code}", tmp_path + ) + assert "private-output" in result + assert "WARNING: diagnostic" in result + assert "private-output" not in caplog.text + if exit_code: + assert "exit code 7" in result + + +@pytest.mark.asyncio +async def test_output_is_bounded_but_command_completes(tmp_path): + result = await execute_command("head -c 1048576 /dev/zero; echo finished > finished", tmp_path) + assert "[output truncated" in result + assert len(result) < 66000 + assert (tmp_path / "finished").read_text() == "finished\n" + + +@pytest.mark.asyncio +async def test_cancellation_stops_descendants(tmp_path): + task = asyncio.create_task( + execute_command( + "(echo started > started; while [ ! -f release ]; do sleep 0.01; done; echo leaked > leaked) & wait", + tmp_path, + ) + ) + try: + + async def started(): + while not (tmp_path / "started").exists(): + await asyncio.sleep(0.01) + + await asyncio.wait_for(started(), 3) + finally: + task.cancel() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 2) + # A surviving child can only write after cancellation has finished. + (tmp_path / "release").touch() + await asyncio.sleep(0.6) + assert not (tmp_path / "leaked").exists() + + +@pytest.mark.asyncio +async def test_timeout_stops_descendants_and_retains_output(tmp_path): + result = await execute_command( + "echo started; (sleep 1.5; echo leaked > leaked) & wait", tmp_path, timeout_seconds=1 + ) + assert "timed out after 1s" in result + assert "started" in result + await asyncio.sleep(0.6) + assert not (tmp_path / "leaked").exists() + + +@pytest.mark.asyncio +async def test_background_pipe_does_not_hold_call_open(tmp_path): + result = await asyncio.wait_for(execute_command("sleep 60 & echo done", tmp_path), 3) + assert result == "done" + + +@pytest.mark.asyncio +async def test_venv_and_skill_environment(tmp_path): + venv = tmp_path / "venv" + (venv / "bin").mkdir(parents=True) + executable = venv / "bin" / "kagent-test" + executable.write_text("#!/bin/sh\nprintf 'venv\\n'") + executable.chmod(0o755) + result = await execute_command( + 'kagent-test; printf "%s\\n%s" "$VIRTUAL_ENV" "$PYTHONPATH"', + tmp_path, + skills_dir=Path("/skills"), + bash_venv_path=str(venv), + ) + assert "venv\n" + str(venv) in result + assert str(tmp_path) + ":/skills" in result + + +@pytest.mark.asyncio +@pytest.mark.parametrize("seconds", [-1, 601]) +async def test_timeout_bounds(tmp_path, seconds): + result = await execute_command("echo should-not-run", tmp_path, timeout_seconds=seconds) + assert "timeout_seconds must be between" in result + + +def test_workspace_reopens_existing_files(tmp_path, monkeypatch): + monkeypatch.setattr("kagent.tools.workspace.WORKSPACE", tmp_path / "durable" / "workspace") + workspace = get_workspace_path() + (workspace / "notes.txt").write_text("saved") + assert (get_workspace_path() / "notes.txt").read_text() == "saved" + assert not (workspace / "uploads").exists() + assert not (workspace / "outputs").exists() + + +@pytest.mark.asyncio +async def test_cancellation_during_startup_reaps_the_command(tmp_path, monkeypatch): + actual_exec = asyncio.create_subprocess_exec + spawned = asyncio.Event() + release_handle = asyncio.Event() + + async def delayed_handle(*args, **kwargs): + process = await actual_exec(*args, **kwargs) + spawned.set() + await release_handle.wait() + return process + + monkeypatch.setattr(asyncio, "create_subprocess_exec", delayed_handle) + task = asyncio.create_task(execute_command("sleep 60", tmp_path)) + try: + await asyncio.wait_for(spawned.wait(), 3) + task.cancel() + finally: + release_handle.set() + with pytest.raises(asyncio.CancelledError): + await asyncio.wait_for(task, 2) diff --git a/python/packages/kagent-skills/src/kagent/tests/unittests/test_skill_execution.py b/python/packages/kagent-tools/tests/test_skill_execution.py similarity index 66% rename from python/packages/kagent-skills/src/kagent/tests/unittests/test_skill_execution.py rename to python/packages/kagent-tools/tests/test_skill_execution.py index c88254bb71..ff0c3d0952 100644 --- a/python/packages/kagent-skills/src/kagent/tests/unittests/test_skill_execution.py +++ b/python/packages/kagent-tools/tests/test_skill_execution.py @@ -1,26 +1,24 @@ import concurrent.futures import json import os +import shlex import shutil +import sys import tempfile import textwrap from pathlib import Path -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import patch import pytest -from kagent.skills import ( - discover_skills, - edit_file_content, +from kagent.tools import ( execute_command, grep_content, list_dir_content, - load_skill_content, read_file_content, - write_file_content, ) -from kagent.skills.prompts import get_bash_description -from kagent.skills.shell import _classify_walk_entry, _sanitize_env, _WalkEntryAction +from kagent.tools.prompts import get_bash_description +from kagent.tools.shell import _classify_walk_entry, _WalkEntryAction @pytest.fixture @@ -111,7 +109,9 @@ async def test_skill_core_logic(skill_test_env: Path): # 2. Execute the skill's core command, just as an agent would # We use the centralized `execute_command` function directly - command = "python skills/csv-to-json/scripts/convert.py uploads/data.csv outputs/result.json" + command = ( + f"{shlex.quote(sys.executable)} skills/csv-to-json/scripts/convert.py uploads/data.csv outputs/result.json" + ) result = await execute_command(command, working_dir=session_dir, skills_dir=Path("/skills")) assert "Successfully converted" in result @@ -136,29 +136,19 @@ async def test_execute_command_passes_command_as_one_argument(tmp_path): The command is passed as one argument to bash rather than interpolated into another shell command. """ + import asyncio + + actual_exec = asyncio.create_subprocess_exec captured = {} - async def mock_exec(*args, **kwargs): + async def capture(*args, **kwargs): captured["args"] = args - mock_process = MagicMock() - mock_process.communicate = AsyncMock(return_value=(b"ok", b"")) - mock_process.returncode = 0 - return mock_process - - injection_payload = 'ls"; cat /etc/passwd; echo "pwned' - - with ( - patch("asyncio.create_subprocess_shell") as mock_shell, - patch("asyncio.create_subprocess_exec", side_effect=mock_exec), - ): - await execute_command(injection_payload, working_dir=tmp_path, skills_dir=Path("/skills")) + return await actual_exec(*args, **kwargs) - # Invariant 1: create_subprocess_shell must never be used. - assert not mock_shell.called - - # The payload must arrive as one argument to bash. - args = captured["args"] - assert args == ("bash", "-c", injection_payload) + command = "printf '%s' 'a; b'" + with patch("asyncio.create_subprocess_exec", side_effect=capture): + assert await execute_command(command, working_dir=tmp_path) == "a; b" + assert captured["args"] == ("bash", "-c", command) def test_get_bash_description_omits_file_search_tools_by_default(): @@ -172,42 +162,14 @@ def test_get_bash_description_mentions_file_search_tools_when_enabled(): assert "list_files and grep_file" in desc -# --- Path traversal tests --- - - -def test_read_file_blocks_path_traversal(tmp_path): - """Reading a file outside the allowed root must raise PermissionError.""" - outside_file = tmp_path.parent / "outside.txt" - outside_file.write_text("secret") - - try: - with pytest.raises(PermissionError, match="outside the allowed director"): - read_file_content(outside_file, allowed_root=tmp_path) - finally: - outside_file.unlink(missing_ok=True) - - -def test_read_file_blocks_relative_traversal(tmp_path): - """Relative paths like ../foo that escape the root must be blocked.""" - (tmp_path / "subdir").mkdir() - outside = tmp_path.parent / "secret.txt" - outside.write_text("secret") - - try: - with pytest.raises(PermissionError, match="outside the allowed director"): - read_file_content( - tmp_path / "subdir" / "../../secret.txt", - allowed_root=tmp_path, - ) - finally: - outside.unlink(missing_ok=True) +# --- File reads --- def test_read_file_allows_path_inside_root(tmp_path): - """Files inside the allowed root should work normally.""" + """Read an ordinary file inside the sandbox.""" f = tmp_path / "hello.txt" f.write_text("hello world") - result = read_file_content(f, allowed_root=tmp_path) + result = read_file_content(f) assert "hello world" in result @@ -223,7 +185,7 @@ def test_read_file_truncates_a_long_line_without_failing_the_file(tmp_path): f = tmp_path / "bundle.min.js" f.write_text("a" * 100_000 + "\nsecond line\n") - result = read_file_content(f, allowed_root=tmp_path) + result = read_file_content(f) lines = result.split("\n") assert len(lines) == 2 @@ -234,49 +196,6 @@ def test_read_file_truncates_a_long_line_without_failing_the_file(tmp_path): assert "second line" in lines[1] -def test_read_file_allows_multiple_roots(tmp_path): - """Read should succeed when the file is inside any of the allowed roots.""" - skills_dir = tmp_path / "skills" - skills_dir.mkdir() - skill_file = skills_dir / "script.py" - skill_file.write_text("print('hello')") - - session_dir = tmp_path / "session" - session_dir.mkdir() - - # File is in skills_dir, not session_dir — should still be allowed - result = read_file_content(skill_file, allowed_root=[session_dir, skills_dir]) - assert "print('hello')" in result - - # File outside both roots should be blocked - outside = tmp_path / "outside.txt" - outside.write_text("secret") - with pytest.raises(PermissionError, match="outside the allowed directories"): - read_file_content(outside, allowed_root=[session_dir, skills_dir]) - - -def test_write_file_blocks_path_traversal(tmp_path): - """Writing a file outside the allowed root must raise PermissionError.""" - outside_path = tmp_path.parent / "evil.txt" - with pytest.raises(PermissionError, match="outside the allowed director"): - write_file_content(outside_path, "malicious", allowed_root=tmp_path) - assert not outside_path.exists() - - -def test_edit_file_blocks_path_traversal(tmp_path): - """Editing a file outside the allowed root must raise PermissionError.""" - outside_file = tmp_path.parent / "target.txt" - outside_file.write_text("original") - - try: - with pytest.raises(PermissionError, match="outside the allowed director"): - edit_file_content(outside_file, "original", "hacked", allowed_root=tmp_path) - # File must not have been modified - assert outside_file.read_text() == "original" - finally: - outside_file.unlink(missing_ok=True) - - # --- list_dir_content / grep_content tests --- @@ -298,16 +217,6 @@ def test_list_dir_content_nonexistent_path(tmp_path): list_dir_content(tmp_path / "does-not-exist") -def test_list_dir_content_blocks_path_traversal(tmp_path): - outside = tmp_path.parent / "outside-dir" - outside.mkdir(exist_ok=True) - try: - with pytest.raises(PermissionError, match="outside the allowed director"): - list_dir_content(outside, allowed_root=tmp_path) - finally: - shutil.rmtree(outside, ignore_errors=True) - - def test_grep_content_finds_match(tmp_path): f = tmp_path / "a.txt" f.write_text("hello world\nFOO bar\n") @@ -356,17 +265,6 @@ def test_grep_content_invalid_pattern(tmp_path): grep_content(f, "(") -def test_grep_content_blocks_path_traversal(tmp_path): - outside = tmp_path.parent / "outside.txt" - outside.write_text("secret") - - try: - with pytest.raises(PermissionError, match="outside the allowed director"): - grep_content(outside, "secret", allowed_root=tmp_path) - finally: - outside.unlink(missing_ok=True) - - def test_grep_content_recursive_skips_symlinks_that_escape_root(tmp_path): outside_dir = tmp_path.parent / "grep_symlink_outside" outside_dir.mkdir(exist_ok=True) @@ -383,7 +281,7 @@ def test_grep_content_recursive_skips_symlinks_that_escape_root(tmp_path): pytest.skip("symlinks not supported") try: - result = grep_content(tmp_path, "foo", recursive=True, allowed_root=tmp_path) + result = grep_content(tmp_path, "foo", recursive=True) assert "top secret" not in result finally: link.unlink(missing_ok=True) @@ -394,10 +292,8 @@ def test_grep_content_recursive_skips_symlinks_that_escape_root(tmp_path): def test_grep_content_recursive_skips_symlinks_that_escape_the_searched_directory(tmp_path): """A symlink may not pull in a file from outside the directory being searched. - allowed_root in production is the whole session dir plus the skills dir -- - wider than the search root -- so validating against it alone would let a - link in the searched subdirectory read a sibling the caller never asked - about. Go bounds each entry by the search root; this pins the same rule. + Both runtimes limit recursive searches to the requested tree, even when + sibling paths are also accessible within the sandbox. """ sibling = tmp_path / "sibling" sibling.mkdir() @@ -413,9 +309,7 @@ def test_grep_content_recursive_skips_symlinks_that_escape_the_searched_director except (OSError, NotImplementedError): pytest.skip("symlinks not supported") - # allowed_root is tmp_path (the shared parent), so the link's target IS - # inside the sandbox -- only the search-root check can exclude it. - result = grep_content(searched, "foo", recursive=True, allowed_root=tmp_path) + result = grep_content(searched, "foo", recursive=True) assert "own foo" in result assert "sibling foo" not in result @@ -431,7 +325,7 @@ def test_grep_content_counts_broken_symlink_as_unreadable(tmp_path): except (OSError, NotImplementedError): pytest.skip("symlinks not supported") - result = grep_content(tmp_path, "foo", recursive=True, allowed_root=tmp_path) + result = grep_content(tmp_path, "foo", recursive=True) assert "match.txt:1:foo readable" in result assert "could not be read" in result @@ -530,7 +424,7 @@ def test_grep_content_does_not_count_fifo_as_unreadable(tmp_path): except (AttributeError, NotImplementedError, OSError): pytest.skip("FIFOs not supported") - result = grep_content(tmp_path, "foo", recursive=True, allowed_root=tmp_path) + result = grep_content(tmp_path, "foo", recursive=True) assert "match.txt:1:foo readable" in result assert "could not be read" not in result @@ -559,7 +453,7 @@ def test_grep_content_recursive_skips_fifo_and_finds_matches_around_it(tmp_path) (tmp_path / "zzz_after.txt").write_text("foo after\n") try: - result = _call_with_timeout(grep_content, tmp_path, "foo", recursive=True, allowed_root=tmp_path) + result = _call_with_timeout(grep_content, tmp_path, "foo", recursive=True) except concurrent.futures.TimeoutError: pytest.fail("grep_content hung on a FIFO instead of skipping it") assert "aaa_before.txt:1:foo before" in result @@ -575,7 +469,7 @@ def test_grep_content_single_target_fifo_raises_instead_of_hanging(tmp_path): try: with pytest.raises(OSError, match="not a regular file"): - _call_with_timeout(grep_content, fifo_path, "foo", allowed_root=tmp_path) + _call_with_timeout(grep_content, fifo_path, "foo") except concurrent.futures.TimeoutError: pytest.fail("grep_content hung opening a FIFO directly instead of erroring") @@ -591,7 +485,7 @@ def test_grep_content_no_matches_found_is_annotated_when_a_file_could_not_be_rea unreadable.chmod(0o000) try: - result = grep_content(tmp_path, "foo", recursive=True, allowed_root=tmp_path) + result = grep_content(tmp_path, "foo", recursive=True) finally: unreadable.chmod(0o644) @@ -613,7 +507,7 @@ def test_grep_content_recursive_does_not_abort_or_discard_matches_on_an_unreadab noperm_sub.chmod(0o000) try: - result = grep_content(tmp_path, "foo", recursive=True, allowed_root=tmp_path) + result = grep_content(tmp_path, "foo", recursive=True) finally: noperm_sub.chmod(0o755) @@ -642,7 +536,7 @@ def test_grep_content_keeps_sibling_matches_when_an_entry_cannot_be_statted(tmp_ locked_sub.chmod(0o444) try: - result = grep_content(tmp_path, "foo", recursive=True, allowed_root=tmp_path) + result = grep_content(tmp_path, "foo", recursive=True) finally: locked_sub.chmod(0o755) @@ -665,7 +559,7 @@ def test_grep_content_annotates_skip_count_alongside_real_matches(tmp_path): noperm_sub.chmod(0o000) try: - result = grep_content(tmp_path, "foo", recursive=True, allowed_root=tmp_path) + result = grep_content(tmp_path, "foo", recursive=True) finally: noperm_sub.chmod(0o755) @@ -689,7 +583,7 @@ def test_grep_content_no_matches_found_is_annotated_when_a_subdirectory_could_no noperm_sub.chmod(0o000) try: - result = grep_content(tmp_path, "foo", recursive=True, allowed_root=tmp_path) + result = grep_content(tmp_path, "foo", recursive=True) finally: noperm_sub.chmod(0o755) @@ -706,7 +600,7 @@ def test_grep_content_recursive_on_fully_unreadable_root_raises_instead_of_empty try: with pytest.raises(OSError, match="could not be read"): - grep_content(tmp_path, "foo", recursive=True, allowed_root=tmp_path) + grep_content(tmp_path, "foo", recursive=True) finally: tmp_path.chmod(0o755) @@ -716,111 +610,14 @@ def test_grep_content_truncates_long_matched_lines(tmp_path): f = tmp_path / "long.txt" f.write_text(long_line + "\n") - result = grep_content(f, "foo", allowed_root=tmp_path) + result = grep_content(f, "foo") assert result.endswith("...") assert long_line not in result matched_line = result.split(":", 2)[-1] assert len(matched_line) < 2100 -def test_skill_discovery_and_loading(skill_test_env: Path): - """ - Tests the core logic of discovering a skill and loading its instructions. - """ - # The fixture creates the session dir, the skills are one level up in a separate dir - skills_root_dir = skill_test_env.parent / "skills_root" - - # 1. Test skill discovery - discovered = discover_skills(skills_root_dir) - assert len(discovered) == 1 - skill_meta = discovered[0] - assert skill_meta.name == "csv-to-json" - assert "Converts a CSV file" in skill_meta.description - - # 2. Test skill content loading - skill_content = load_skill_content(skills_root_dir, "csv-to-json") - assert "name: csv-to-json" in skill_content - assert "# CSV to JSON Conversion" in skill_content - assert 'Example: `bash("python skills/csv-to-json/scripts/convert.py' in skill_content - - -def test_sanitize_env_strips_secrets(): - """Verify _sanitize_env removes env vars matching secret patterns.""" - secret_vars = { - # Regex-matched vars - "OPENAI_API_KEY": "sk-secret", - "AZURE_OPENAI_API_KEY": "az-secret", - "ANTHROPIC_API_KEY": "ant-secret", - "GOOGLE_API_KEY": "goog-secret", - "SERPER_API_KEY": "serp-secret", - "LANGSMITH_API_KEY": "ls-secret", - "GRAFANA_SERVICE_ACCOUNT_TOKEN": "graf-token", - "DATABASE_PASSWORD": "db-pass", - "MY_SECRET": "shh", - "AWS_SECRET_ACCESS_KEY": "aws-secret", - "SSH_PRIVATE_KEY": "key-data", - "GIT_CREDENTIAL": "cred", - "GIT_CREDENTIALS": "cred-plural", - # Vars from providers.go that previously leaked - "GOOGLE_APPLICATION_CREDENTIALS": "/path/to/sa.json", - "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", - "AZURE_AD_TOKEN": "az-ad-token", - "AWS_SESSION_TOKEN": "session-tok", - "AWS_BEARER_TOKEN_BEDROCK": "bearer-tok", - } - safe_vars = { - "PATH": "/usr/bin", - "HOME": "/home/user", - "PYTHONPATH": "/some/path", - "LANG": "en_US.UTF-8", - "TOKENIZERS_PARALLELISM": "true", - "GOOGLE_CLOUD_PROJECT": "my-project", - "AWS_REGION": "us-east-1", - } - - result = _sanitize_env({**secret_vars, **safe_vars}) - - for key in secret_vars: - assert key not in result, f"{key} should have been stripped" - - for key, value in safe_vars.items(): - assert result[key] == value, f"{key} should be preserved" - - @pytest.mark.asyncio -async def test_execute_command_strips_secret_env_vars(tmp_path): - """Secret env vars must not be passed to sandboxed subprocesses.""" - captured = {} - - async def mock_exec(*args, **kwargs): - captured["env"] = kwargs.get("env", {}) - mock_process = MagicMock() - mock_process.communicate = AsyncMock(return_value=(b"ok", b"")) - mock_process.returncode = 0 - return mock_process - - env_overrides = { - "OPENAI_API_KEY": "sk-secret", - "ANTHROPIC_API_KEY": "ant-secret", - "GOOGLE_APPLICATION_CREDENTIALS": "/path/to/sa.json", - "AWS_ACCESS_KEY_ID": "AKIAIOSFODNN7EXAMPLE", - "PATH": "/usr/bin", - "HOME": "/home/user", - } - - with ( - patch.dict( - "os.environ", - env_overrides, - clear=True, - ), - patch("asyncio.create_subprocess_exec", side_effect=mock_exec), - ): - await execute_command("echo hello", working_dir=tmp_path) - - env = captured["env"] - assert "OPENAI_API_KEY" not in env - assert "ANTHROPIC_API_KEY" not in env - assert "GOOGLE_APPLICATION_CREDENTIALS" not in env - assert "AWS_ACCESS_KEY_ID" not in env - assert env["HOME"] == "/home/user" +async def test_command_inherits_actor_environment(tmp_path, monkeypatch): + monkeypatch.setenv("OPENAI_API_KEY", "egress-placeholder") + assert await execute_command('printf "%s" "$OPENAI_API_KEY"', working_dir=tmp_path) == "egress-placeholder" diff --git a/python/uv.lock b/python/uv.lock index 03279833a9..853801a361 100644 --- a/python/uv.lock +++ b/python/uv.lock @@ -22,7 +22,7 @@ members = [ "kagent-langgraph", "kagent-openai", "kagent-proto", - "kagent-skills", + "kagent-tools", "kebab", "poem-flow", "research-crew", @@ -256,9 +256,9 @@ name = "aiologic" version = "0.17.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "sniffio" }, - { name = "typing-extensions" }, - { name = "wrapt" }, + { name = "sniffio", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "wrapt", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f1/7a/d51f2fde1e8ae8a83431f8e97b7a71e9358cdb1d4d2ce6be387fa44d68de/aiologic-0.17.1.tar.gz", hash = "sha256:2e1b93b9e88ced318c2a63ad7b382688f40cbfe40e3d42258d49dc9c5aea179d", size = 252354, upload-time = "2026-06-27T20:41:33.25Z" } wheels = [ @@ -908,8 +908,8 @@ name = "culsans" version = "0.11.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "aiologic" }, - { name = "typing-extensions" }, + { name = "aiologic", marker = "python_full_version < '3.13'" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/d9/e3/49afa1bc180e0d28008ec6bcdf82a4072d1c7a41032b5b759b60814ca4b0/culsans-0.11.0.tar.gz", hash = "sha256:0b43d0d05dce6106293d114c86e3fb4bfc63088cfe8ff08ed3fe36891447fe33", size = 107546, upload-time = "2025-12-31T23:15:38.196Z" } wheels = [ @@ -1501,8 +1501,8 @@ name = "httpcore2" version = "2.13.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "h11" }, - { name = "truststore" }, + { name = "h11", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, + { name = "truststore", marker = "python_full_version != '3.12.*' or sys_platform != 'emscripten'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/cb/f3/1db7aa2bc2524062192bb0e0323969492d1883152a232fe36eea65f4e35c/httpcore2-2.13.1.tar.gz", hash = "sha256:e0aa977abe17e69a3b820a24542a6fa88702676d83880b8d194dcd18408e5103", size = 68071, upload-time = "2026-09-23T07:47:22.372Z" } wheels = [ @@ -1862,7 +1862,7 @@ dependencies = [ { name = "jsonref" }, { name = "kagent-core" }, { name = "kagent-proto" }, - { name = "kagent-skills" }, + { name = "kagent-tools" }, { name = "mcp" }, { name = "numpy" }, { name = "ollama" }, @@ -1909,7 +1909,7 @@ requires-dist = [ { name = "jsonref", specifier = ">=1.1.0" }, { name = "kagent-core", editable = "packages/kagent-core" }, { name = "kagent-proto", editable = "packages/kagent-proto" }, - { name = "kagent-skills", editable = "packages/kagent-skills" }, + { name = "kagent-tools", editable = "packages/kagent-tools" }, { name = "mcp", specifier = ">=2.2.0,<3" }, { name = "numpy", specifier = ">=2.2.6" }, { name = "ollama", specifier = ">=0.3.6" }, @@ -2074,7 +2074,7 @@ dependencies = [ { name = "httpx" }, { name = "kagent-core", extra = ["openai-agents"] }, { name = "kagent-proto" }, - { name = "kagent-skills" }, + { name = "kagent-tools" }, { name = "openai" }, { name = "openai-agents" }, { name = "pydantic" }, @@ -2098,7 +2098,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.25.0" }, { name = "kagent-core", extras = ["openai-agents"], editable = "packages/kagent-core" }, { name = "kagent-proto", editable = "packages/kagent-proto" }, - { name = "kagent-skills", editable = "packages/kagent-skills" }, + { name = "kagent-tools", editable = "packages/kagent-tools" }, { name = "openai", specifier = ">=1.72.0" }, { name = "openai-agents", specifier = ">=0.4.0" }, { name = "pydantic", specifier = ">=2.0.0" }, @@ -2128,13 +2128,9 @@ requires-dist = [ ] [[package]] -name = "kagent-skills" +name = "kagent-tools" version = "0.1.0" -source = { editable = "packages/kagent-skills" } -dependencies = [ - { name = "pydantic" }, - { name = "pyyaml" }, -] +source = { editable = "packages/kagent-tools" } [package.optional-dependencies] dev = [ @@ -2147,10 +2143,8 @@ dev = [ [package.metadata] requires-dist = [ { name = "black", marker = "extra == 'dev'", specifier = ">=23.0.0" }, - { name = "pydantic", specifier = ">=2.0.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=7.0.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, - { name = "pyyaml", specifier = ">=6.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.17.1" }, ] provides-extras = ["dev"]