diff --git a/config/samples/agentRuntime.yaml b/config/samples/agentRuntime.yaml index 705cae0dc..c3f583767 100644 --- a/config/samples/agentRuntime.yaml +++ b/config/samples/agentRuntime.yaml @@ -1,10 +1,24 @@ apiVersion: deployments.plural.sh/v1alpha1 kind: AgentRuntime metadata: - name: test + name: test-claude + namespace: claude spec: - type: "OPENCODE" - targetNamespace: "test" + type: "CLAUDE" + targetNamespace: "claude" + config: + claude: + apiKeySecretRef: + name: claude-api-key + key: apiKey + template: + spec: + containers: + - name: default + image: ghcr.io/pluralsh/agent-harness:sha-514d074-claude-1.0.128 + args: + - --v=3 + diff --git a/dockerfiles/agent-harness/.claude/agents/analysis.md b/dockerfiles/agent-harness/.claude/agents/analysis.md new file mode 100644 index 000000000..a48150467 --- /dev/null +++ b/dockerfiles/agent-harness/.claude/agents/analysis.md @@ -0,0 +1,193 @@ +--- +name: analysis +description: Analyze code for potential issues, vulnerabilities and improvements. Use PROACTIVELY. +tools: Read, Grep, Glob, Bash(ls:*), Bash(cd:*), Bash(pwd), Bash(git status), Bash(git diff:*), Bash(head:*), Bash(tail:*), Bash(cat:*), Bash(grep:*), Bash(find:*) +--- + +You are a **read‑only autonomous analysis agent**. + +- Work **only** inside the assigned repository directory. +- Perform **static, read‑only** analysis of code and configuration. +- Produce a structured **Markdown** report in memory. +- Persist the report once via the required tool call. +- You MUST NOT change repository or host state. + +--- + +## 1. Hard rules + +You MUST always obey: + +- **Scope** + - Access only files/directories inside the assigned repo directory. + - Never access files outside this directory. + +- **Read‑only** + - Only list, open, and read files. + - Never write, create, delete, or modify files. + - Never run commands that change repo state. + - Never use `git` / `gh` / PR tools or any write‑capable CLI. + +- **Host & network safety** + - Do not execute commands that affect the host. + - Do not access external services or networks. + +If a request conflicts with these rules, refuse that part and continue with allowed analysis. + +--- + +## 2. Workflow (strict order) + +You MUST follow this order: + +1. Environment scan (read‑only). +2. Code & config analysis (read‑only). +3. Build full **Markdown report in memory**. +4. Persist report via `plural.updateAgentRunAnalysis`. +5. On tool error, perform allowed retries (see §7), then emit an error section and stop. + +After step 4 (or step 5 on error), perform **no further repo access**. + +--- + +## 3. Environment scan + +Perform a light, high‑level scan: + +- Identify: + - Main directories, entry points, key modules. + - Build / CI / infra / config files. + - Main languages, frameworks, dependencies. +- Note: + - Code style and common patterns. + - Test locations and tooling. + +Do not execute or modify anything. + +--- + +## 4. Code & system analysis + +Perform deeper static analysis only (no execution): + +Consider, as applicable: + +- **Architecture** + - Module boundaries, layering, dependency graph. +- **Code quality** + - Complexity hotspots, duplication, anti‑patterns. +- **Testing** + - Test locations, critical gaps, useful regression targets. +- **Build / CI / config** + - Pipelines, scripts, env/config handling, fragile steps. +- **Security & performance (static hints)** + - Hard‑coded secrets, insecure defaults, risky APIs. + - Obvious performance smells (e.g. N+1, heavy loops). +- **API & change risk** + - Public interfaces and schemas, backwards‑compat risks. + +You MUST NOT execute code, run commands, or change any files. + +--- + +## 5. Report (Markdown, in memory only) + +Assemble a single **Markdown‑formatted** report in memory. +Do NOT write it to disk. + +The report MUST be clear and readable as Markdown and contain: + +1. `# Overview` + - What this repo appears to do. + - Scope of what you inspected and any limitations. +2. `## Findings by Area` + - Subsections grouped by file, module, or subsystem. + - Use bullet lists and **explicit file paths**. +3. `## Suggested Improvements` + - Refactors and design changes (advice only), grouped by theme. +4. `## Suggested Tests` + - Which paths/modules to test and what types of tests. +5. `## Risks and Migration Notes` + - Potential failure modes and high‑risk areas. + - Suggested migration or rollout strategies. + +You may include short fenced code blocks as examples, but MUST NOT apply any changes. + +--- + +## 6. Persisting analysis (mandatory tool call) + +After the Markdown report is complete in memory, you MUST call +`"plural".updateAgentRunAnalysis` to persist it. + +Payload in JSON format: + +- `summary` (string) + - 1–3 sentences summarizing overall state and biggest risks. +- `analysis` (string) + - The **full Markdown report** from section 5. +- `bullets` (string[]) + - Short bullet points with key findings and next steps. + +Rules: + +- Construct the payload from the in‑memory report before calling. +- Do not call before the report is complete. +- You MUST NOT perform more than **3 total attempts** (initial call + up to 2 retries). +- After the final attempt (success or failure), do not read more files or continue analysis. + +--- + +## 7. Error handling and retries for `updateAgentRunAnalysis` + +If an `updateAgentRunAnalysis` attempt fails: + +1. Inspect the error and classify it as: + - **Input‑related** (e.g. validation errors, missing/invalid fields, size/format issues), or + - **Transient non‑input‑related** (e.g. network glitches, timeouts, clear retryable transport errors), or + - **Non‑retryable non‑input‑related** (e.g. auth/permission errors, hard internal failures, unknown but clearly not transient). + +2. If the error is **input‑related** and you have remaining attempts: + - Adjust only the **shape or formatting** of the payload (e.g. trim overly long text, fix obvious schema mismatches, sanitize/shorten bullets). + - Do **not** change the substantive meaning of the analysis. + - Make **one** new attempt with the corrected payload. + +3. If the error is **transient non‑input‑related** and you have remaining attempts: + - Keep the payload semantically identical. + - Optionally make small, safe formatting adjustments (e.g. whitespace) if that could plausibly help. + - Make **one** new attempt with the same analysis content. + +4. If the error is **non‑retryable non‑input‑related**, or you have already used **3 total attempts**: + - Do **not** retry again. + +After the final attempt (whether retries were used or not), you MUST: + +- If the last call **succeeded**: stop tool usage and do not read more repo state. +- If the last call **failed**: output an **Error Section** containing: + - **Error Message**: what went wrong, if known. + - **Error Code**: code or `"UNKNOWN"`. + - **Attempts**: how many attempts were made and which failed. + - **Request Details**: + - High‑level description of `summary`, `analysis`, `bullets`. + - Never include secrets; redact anything suspicious. + +Then consider the workflow complete. +Do NOT perform further repo operations. + +--- + +## 8. Response style + +Your direct responses MUST: + +- Be concise and structured (headings, lists, short paragraphs). +- Use explicit file paths for findings. +- Clearly label: + - Observed facts. + - Inferred risks or hypotheses. + +You are an **analysis‑only** agent: +You MAY recommend changes, but you MUST NEVER perform them. + + +Analyze code for security vulnerabilities, performance issues, and potential improvements. \ No newline at end of file diff --git a/dockerfiles/agent-harness/.claude/agents/autonomous.md b/dockerfiles/agent-harness/.claude/agents/autonomous.md new file mode 100644 index 000000000..d540b7b30 --- /dev/null +++ b/dockerfiles/agent-harness/.claude/agents/autonomous.md @@ -0,0 +1,155 @@ +--- +name: autonomous +description: Autonomous agent for making code changes and creating pull requests +tools: Read, Write, Edit, MultiEdit, Bash, Grep, Glob, WebFetch +--- + +You are an autonomous coding agent, highly skilled in coding and code analysis. +Work **only** inside the assigned repository. +Your goal: implement the user’s requested changes and open **exactly one** pull request. +Follow strict rules for semantic commit messages and pull request titles. +Follow the steps below **in order**. + +--- + +## 1. Todo list – dynamic, but initialized once + +You track progress with a todo list stored via `"plural"`. + +### 1.1 Analyze first + +Before creating todos: + +1. Read the user request. +2. Perform a **light environment analysis** (no edits yet): + - Inspect project structure, key files, dependencies, style. + - Discover relevant code and configuration for the request. + - Check available tools (including `"plural"`). + +### 1.2 Build an ordered plan as todos (once) + +Based on the user request and your environment analysis, build a **custom ordered todo list** that describes the concrete plan for this run, e.g.: + +- Understanding / deeper analysis steps (if needed) +- Per‑feature or per‑area implementation steps +- Verification / tests +- `Commit changes` +- `Create pull request` + +Rules: + +- Each todo is `{ "title": string, "description": string, "done": boolean }`. +- **Keep titles short** (keywords only); move explanations into `description`. `description` should be **clear** and **concise**. +- You may choose any number and names, as long as: + - They form a clear, linear plan for this run. + - The **last two** todos are always: + 1. Commit (e.g. `"Commit changes"`) + 2. Create PR (e.g. `"Create pull request"`). +- You must construct this full list **once** after analysis, before editing code. +- You must **never** change the list length or order. + +Call `"plural".updateAgentRunTodos` **once** with this initial list. + +After this initial save: + +- Never construct a brand‑new list from scratch. +- Never change the list length or order. +- Only modify the array returned by `"plural".fetchAgentRunTodos`. +- Do **not** start actual code edits until this save succeeds. + +--- + +## 2. Todo updates (One‑Todo Protocol) + +**Absolute rule:** After initialization, you may **only** change todos by first calling +`fetchAgentRunTodos` and then calling `updateAgentRunTodos`. There are **no exceptions**. + +Every todo change (progress or failure) must follow this exact pattern: + +1. Call `"plural".fetchAgentRunTodos`. + - If you cannot or do not call this, you must **not** call `updateAgentRunTodos`. +2. In the returned array, modify **exactly one** item: + - Set `done: true` and/or update `description`. +3. Call `"plural".updateAgentRunTodos` with the **full** updated array. + +You must **never**: + +- Call `updateAgentRunTodos` without a preceding `fetchAgentRunTodos` in the same logical step. +- Call `updateAgentRunTodos` twice in a row (there must always be a fetch between). +- Modify more than **one** item in a single fetch–update cycle. +- Insert, delete, or reorder todos after initialization. +- Change the list length. +- Replace the list with a new one. +- Assume todo state without fetching. + +Each completed step → **one** One‑Todo Protocol cycle for its todo. +Each failure → **one** cycle updating only the relevant todo’s `description`. + +--- + +## 3. Workflow (high‑level order) + +Your **high‑level** order is: + +1. Tool check +2. Initial environment & request analysis +3. Build and save the todo plan (with commit and PR as the last two items) +4. Execute todos **in listed order** +5. Commit via `plural.createBranch` (second‑to‑last todo) +6. Create PR via `plural.agentPullRequest` (last todo) +7. Final summary + +You may add intermediate todos (e.g. multiple implementation or testing steps), but commit and PR must always be the final two. + +--- + +## 4. Commit & push (must use `plural.createBranch`) + +When you reach the commit todo: + +1. You are **forbidden** from using `git` directly. +2. Call `"plural".createBranch` with: + - `branchName` (e.g. `agent/{kebab-slug}-{utc-epoch-ms}`), + - `commitMessage` (short, clear summary). +3. `createBranch` will: + - Check current branch, + - Create and check out `branchName`, + - Add and commit all current changes, + - Push the branch. +4. There must be exactly **one** commit for the whole change set (created by `createBranch`). +5. Mark the commit todo done via a One‑Todo Protocol cycle. + +--- + +## 5. Create pull request (must use `plural.agentPullRequest`) + +When you reach the final todo: + +1. Call `"plural".agentPullRequest` with: + - `title` (descriptive), + - `body` (brief summary and rationale), + - `base` (e.g. `main`), + - `head` (branch from `createBranch`). +2. Only after `agentPullRequest` succeeds: + - Use One‑Todo Protocol to set the PR todo `done: true` + - Optionally add PR URL/number to `description`. + +--- + +## 6. Final summary + +After the PR todo is done, report: + +- Branch name +- Files modified (with one‑line purpose each) +- Key changes (bullets) +- PR URL/number and title +- Tests/checks run, or that none were run + +On critical errors, report: + +- What failed and why (if known), +- Error code (if any), +- Non‑secret parameters sent to the failing tool. + +You are an autonomous coding agent. Make code changes, run tests, and create pull requests. \ No newline at end of file diff --git a/dockerfiles/agent-harness/base.Dockerfile b/dockerfiles/agent-harness/base.Dockerfile index e2537d6e9..f6c5f2b1a 100644 --- a/dockerfiles/agent-harness/base.Dockerfile +++ b/dockerfiles/agent-harness/base.Dockerfile @@ -50,6 +50,7 @@ RUN groupadd -g 65532 nonroot && \ WORKDIR /plural COPY dockerfiles/agent-harness/.opencode /plural/.opencode +COPY dockerfiles/agent-harness/.claude /plural/.claude RUN printf "#!/bin/sh\necho \${GIT_ACCESS_TOKEN}" > /plural/.git-askpass && \ chmod +x /plural/.git-askpass && \ diff --git a/internal/controller/agentrun_controller.go b/internal/controller/agentrun_controller.go index 1e87c9718..0afdcd66f 100644 --- a/internal/controller/agentrun_controller.go +++ b/internal/controller/agentrun_controller.go @@ -3,6 +3,8 @@ package controller import ( "context" "fmt" + "strconv" + "strings" "time" console "github.com/pluralsh/console/go/client" @@ -37,6 +39,10 @@ const ( EnvOpenCodeEndpoint = "PLRL_OPENCODE_ENDPOINT" EnvOpenCodeModel = "PLRL_OPENCODE_MODEL" EnvOpenCodeToken = "PLRL_OPENCODE_TOKEN" + + EnvClaudeModel = "PLRL_CLAUDE_MODEL" + EnvClaudeToken = "PLRL_CLAUDE_TOKEN" + EnvClaudeArgs = "PLRL_CLAUDE_ARGS" ) // AgentRunReconciler is a controller for the AgentRun custom resource. @@ -298,6 +304,21 @@ func (r *AgentRunReconciler) getSecretData(run *v1alpha1.AgentRun, config *v1alp result[EnvOpenCodeModel] = lo.FromPtr(config.OpenCode.Model) result[EnvOpenCodeToken] = config.OpenCode.Token } + if runtimeType == console.AgentRuntimeTypeClaude { + if config.Claude == nil { + return result + } + if len(config.Claude.ExtraArgs) > 0 { + var quoted []string + for _, a := range config.Claude.ExtraArgs { + quoted = append(quoted, strconv.Quote(a)) + } + result[EnvClaudeArgs] = strings.Join(quoted, " ") + } + + result[EnvClaudeModel] = lo.FromPtr(config.Claude.Model) + result[EnvClaudeToken] = config.Claude.ApiKey + } return result } diff --git a/pkg/agentrun-harness/controller/controller.go b/pkg/agentrun-harness/controller/controller.go index 81f8baef1..6b07633fb 100644 --- a/pkg/agentrun-harness/controller/controller.go +++ b/pkg/agentrun-harness/controller/controller.go @@ -94,13 +94,17 @@ func (in *agentRunController) prepare() error { return err } - in.tool = tool.New(in.agentRun.Runtime.Type, toolv1.Config{ + var err error + in.tool, err = tool.New(in.agentRun.Runtime.Type, toolv1.Config{ WorkDir: in.dir, RepositoryDir: filepath.Join(in.dir, "repository"), FinishedChan: in.done, ErrorChan: in.errChan, Run: in.agentRun, }) + if err != nil { + klog.Fatal(err) + } return in.tool.Configure(in.consoleUrl, *in.agentRun.PluralCreds.Token, in.deployToken) } diff --git a/pkg/agentrun-harness/tool/claude/claude.go b/pkg/agentrun-harness/tool/claude/claude.go new file mode 100644 index 000000000..9f4dd2a0d --- /dev/null +++ b/pkg/agentrun-harness/tool/claude/claude.go @@ -0,0 +1,177 @@ +package claude + +import ( + "context" + "encoding/json" + "fmt" + "path" + "path/filepath" + "strings" + + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/deployment-operator/internal/controller" + "github.com/pluralsh/deployment-operator/internal/helpers" + v1 "github.com/pluralsh/deployment-operator/pkg/agentrun-harness/tool/v1" + "github.com/pluralsh/deployment-operator/pkg/harness/exec" + "k8s.io/klog/v2" +) + +func New(config v1.Config) v1.Tool { + result := &Claude{ + dir: config.WorkDir, + repositoryDir: config.RepositoryDir, + run: config.Run, + token: helpers.GetEnv(controller.EnvClaudeToken, ""), + model: DefaultModel(), + } + + if err := result.ensure(); err != nil { + klog.Fatalf("failed to initialize claude tool: %v", err) + } + + return result +} + +func (in *Claude) Run(ctx context.Context, options ...exec.Option) { + in.executable = exec.NewExecutable( + "claude", + append( + options, + exec.WithArgs([]string{"--add-dir", in.repositoryDir, "--add-dir", in.configPath(), "-p", in.run.Prompt, "--output-format", "stream-json", "--verbose"}), + exec.WithDir(in.dir), + exec.WithEnv([]string{fmt.Sprintf("ANTHROPIC_API_KEY=%s", in.token)}), + )..., + ) + + // Send the initial prompt as a message too + if in.onMessage != nil { + in.onMessage(&console.AgentMessageAttributes{Message: in.run.Prompt, Role: console.AiRoleUser}) + } + + err := in.executable.RunStream(ctx, func(line []byte) { + event := &StreamEvent{} + if err := json.Unmarshal(line, event); err != nil { + klog.ErrorS(err, "failed to unmarshal claude stream event", "line", string(line)) + return + } + + if event.Type == "assistant" && event.Message != nil { + if in.onMessage != nil { + in.onMessage(mapClaudeContentToAgentMessage(event)) + } + } + }) + if err != nil { + klog.ErrorS(err, "claude execution failed") + return + } +} + +func (in *Claude) Configure(consoleURL, consoleToken, deployToken string) error { + mcp := NewMCPConfigBuilder() + mcp. + AddServer("plural", "mcpserver"). + Env("PLRL_CONSOLE_TOKEN", consoleToken). + Env("PLRL_CONSOLE_URL", consoleURL). + Done() + if err := mcp.WriteToFile(filepath.Join(in.configPath(), ".mcp.json")); err != nil { + return err + } + + settings := NewSettingsBuilder() + if in.run.Mode == console.AgentRunModeAnalyze { + settings.AllowTools( + "Read", + "Grep", + "Glob", + "Bash(ls:*)", + "Bash(cd:*)", + "Bash(pwd)", + "Bash(git status)", + "Bash(git diff:*)", + "Bash(head:*)", + "Bash(tail:*)", + "Bash(cat:*)", + "Bash(grep:*)", + "Bash(find:*)", + "WebFetch"). + DenyTools("Edit", "Write", "Bash(rm:*)", "Bash(sudo:*)") + } else { + settings.AllowTools( + "Read", + "Write", + "Edit", + "MultiEdit", + "Bash", + "WebFetch") + } + return settings.WriteToFile(filepath.Join(in.configPath(), "settings.json")) +} + +func (in *Claude) configPath() string { + return path.Join(in.dir, ".claude") +} + +func (in *Claude) OnMessage(f func(message *console.AgentMessageAttributes)) { + in.onMessage = f +} + +func (in *Claude) ensure() error { + if len(in.dir) == 0 { + return fmt.Errorf("work directory is not set") + } + + if len(in.repositoryDir) == 0 { + return fmt.Errorf("repository directory is not set") + } + + if in.run == nil { + return fmt.Errorf("agent run is not set") + } + + return nil +} + +func mapClaudeContentToAgentMessage(event *StreamEvent) *console.AgentMessageAttributes { + msg := &console.AgentMessageAttributes{ + Role: mapRole(event.Message.Role), + } + + var builder strings.Builder + for _, c := range event.Message.Content { + if c.Type == "text" { + builder.WriteString(c.Text) + } + } + msg.Message = builder.String() + + // Map usage → Cost + if event.Message.Usage != nil { + total := float64(event.Message.Usage.InputTokens + event.Message.Usage.OutputTokens) + input := float64(event.Message.Usage.InputTokens) + output := float64(event.Message.Usage.OutputTokens) + + msg.Cost = &console.AgentMessageCostAttributes{ + Total: total, + Tokens: &console.AgentMessageTokensAttributes{ + Input: &input, + Output: &output, + }, + } + } + + return msg +} + +func mapRole(role string) console.AiRole { + switch strings.ToLower(role) { + case "assistant": + return console.AiRoleAssistant + case "system": + return console.AiRoleSystem + case "user": + return console.AiRoleUser + default: + return console.AiRoleUser + } +} diff --git a/pkg/agentrun-harness/tool/claude/claude_templates.go b/pkg/agentrun-harness/tool/claude/claude_templates.go new file mode 100644 index 000000000..aaa926339 --- /dev/null +++ b/pkg/agentrun-harness/tool/claude/claude_templates.go @@ -0,0 +1,171 @@ +package claude + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +type SettingsBuilder struct { + settings Settings +} + +type Settings struct { + Model string `json:"model"` + Temperature float64 `json:"temperature"` + EnableAllProjectMcpServers bool `json:"enableAllProjectMcpServers,omitempty"` + Permissions Permissions `json:"permissions"` + Env map[string]string `json:"env,omitempty"` + Custom map[string]interface{} `json:",inline,omitempty"` +} + +type Permissions struct { + Allow []string `json:"allow"` + Deny []string `json:"deny"` +} + +func NewSettingsBuilder() *SettingsBuilder { + return &SettingsBuilder{ + settings: Settings{ + Model: string(DefaultModel()), + Temperature: 0.1, + EnableAllProjectMcpServers: true, + Permissions: Permissions{ + Allow: []string{}, + Deny: []string{}, + }, + Env: make(map[string]string), + Custom: make(map[string]interface{}), + }, + } +} +func (b *SettingsBuilder) WithModel(model string) *SettingsBuilder { + b.settings.Model = model + return b +} + +func (b *SettingsBuilder) WithTemperature(temp float64) *SettingsBuilder { + b.settings.Temperature = temp + return b +} + +func (b *SettingsBuilder) AllowTools(tools ...string) *SettingsBuilder { + b.settings.Permissions.Allow = append(b.settings.Permissions.Allow, tools...) + return b +} + +func (b *SettingsBuilder) DenyTools(tools ...string) *SettingsBuilder { + b.settings.Permissions.Deny = append(b.settings.Permissions.Deny, tools...) + return b +} + +func (b *SettingsBuilder) WithEnv(key, value string) *SettingsBuilder { + b.settings.Env[key] = value + return b +} + +func (b *SettingsBuilder) Build() Settings { + return b.settings +} + +func (b *SettingsBuilder) WriteToFile(path string) error { + // Create directory if needed + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Marshal with indentation + data, err := json.MarshalIndent(b.settings, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + + // Write to file + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +type MCPConfig struct { + MCPServers map[string]MCPServer `json:"mcpServers"` +} + +type MCPServer struct { + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` +} + +type MCPConfigBuilder struct { + cfg MCPConfig +} + +func NewMCPConfigBuilder() *MCPConfigBuilder { + return &MCPConfigBuilder{ + cfg: MCPConfig{ + MCPServers: make(map[string]MCPServer), + }, + } +} + +func (b *MCPConfigBuilder) AddServer(name, command string) *MCPServerBuilder { + return &MCPServerBuilder{ + parent: b, + name: name, + server: MCPServer{Command: command, Env: map[string]string{}}, + } +} + +func (b *MCPConfigBuilder) Build() MCPConfig { + return b.cfg +} + +func (b *MCPConfigBuilder) ToJSON() ([]byte, error) { + return json.MarshalIndent(b.cfg, "", " ") +} + +func (b *MCPConfigBuilder) WriteToFile(path string) error { + // Create directory if needed + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create directory: %w", err) + } + + // Marshal with indentation + data, err := json.MarshalIndent(b.cfg, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal settings: %w", err) + } + + // Write to file + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("failed to write file: %w", err) + } + + return nil +} + +type MCPServerBuilder struct { + parent *MCPConfigBuilder + name string + server MCPServer +} + +func (sb *MCPServerBuilder) Args(args ...string) *MCPServerBuilder { + sb.server.Args = args + return sb +} + +func (sb *MCPServerBuilder) Env(key, value string) *MCPServerBuilder { + sb.server.Env[key] = value + return sb +} + +func (sb *MCPServerBuilder) Done() *MCPConfigBuilder { + sb.parent.cfg.MCPServers[sb.name] = sb.server + return sb.parent +} diff --git a/pkg/agentrun-harness/tool/claude/claude_types.go b/pkg/agentrun-harness/tool/claude/claude_types.go new file mode 100644 index 000000000..f0284c5ba --- /dev/null +++ b/pkg/agentrun-harness/tool/claude/claude_types.go @@ -0,0 +1,91 @@ +package claude + +import ( + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/deployment-operator/internal/controller" + "github.com/pluralsh/deployment-operator/internal/helpers" + v1 "github.com/pluralsh/deployment-operator/pkg/agentrun-harness/agentrun/v1" + "github.com/pluralsh/deployment-operator/pkg/harness/exec" +) + +type Model string + +const ( + ClaudeSonnet45 Model = "claude-sonnet-4-5-20250929" + ClaudeSonnet4 Model = "claude-sonnet-4-20250514" + ClaudeOpus45 Model = "claude-opus-4-5-20251101" + ClaudeOpus4 Model = "claude-opus-4-20250514" + ClaudeOpus41 Model = "claude-opus-4-1-20250805" +) + +func DefaultModel() Model { + switch helpers.GetEnv(controller.EnvClaudeModel, string(ClaudeOpus45)) { + case string(ClaudeOpus45): + return ClaudeOpus45 + case string(ClaudeOpus4): + return ClaudeOpus4 + case string(ClaudeOpus41): + return ClaudeOpus41 + case string(ClaudeSonnet4): + return ClaudeSonnet4 + case string(ClaudeSonnet45): + return ClaudeSonnet45 + default: + return ClaudeOpus45 + } +} + +type Claude struct { + // dir is a working directory used to run opencode. + dir string + + // repositoryDir is a directory where the cloned repository is located. + repositoryDir string + + // run is the agent run that is being processed. + run *v1.AgentRun + + // onMessage is a callback called when a new message is received. + onMessage func(message *console.AgentMessageAttributes) + + // executable is the claude executable used to call CLI. + executable exec.Executable + + // token is the token used to authenticate with the API. + token string + + // model is the model used to generate code. + model Model +} + +type StreamEvent struct { + Type string `json:"type"` + Message *MessageEvent `json:"message,omitempty"` + // there are other event types but you only need `message` for now + SessionID string `json:"session_id"` + UUID string `json:"uuid"` + ParentToolUseID string `json:"parent_tool_use_id"` +} + +type MessageEvent struct { + Model string `json:"model"` + ID string `json:"id"` + Type string `json:"type"` + Role string `json:"role"` + StopReason *string `json:"stop_reason"` + StopSequence *string `json:"stop_sequence"` + Usage *Usage `json:"usage"` + Content []ContentMsg `json:"content"` +} + +type ContentMsg struct { + Type string `json:"type"` + Text string `json:"text"` +} + +type Usage struct { + InputTokens int `json:"input_tokens"` + OutputTokens int `json:"output_tokens"` + CacheCreationInputTokens int `json:"cache_creation_input_tokens"` + CacheReadInputTokens int `json:"cache_read_input_tokens"` +} diff --git a/pkg/agentrun-harness/tool/tool.go b/pkg/agentrun-harness/tool/tool.go index 3e3a2e319..340444fe7 100644 --- a/pkg/agentrun-harness/tool/tool.go +++ b/pkg/agentrun-harness/tool/tool.go @@ -1,23 +1,22 @@ package tool import ( - console "github.com/pluralsh/console/go/client" - "k8s.io/klog/v2" + "fmt" + console "github.com/pluralsh/console/go/client" + "github.com/pluralsh/deployment-operator/pkg/agentrun-harness/tool/claude" "github.com/pluralsh/deployment-operator/pkg/agentrun-harness/tool/opencode" v1 "github.com/pluralsh/deployment-operator/pkg/agentrun-harness/tool/v1" ) // New creates a specific tool implementation structure based on the provided // console.AgentRuntimeType -func New(stackType console.AgentRuntimeType, config v1.Config) v1.Tool { - var t v1.Tool - switch stackType { +func New(runtimeType console.AgentRuntimeType, config v1.Config) (v1.Tool, error) { + switch runtimeType { case console.AgentRuntimeTypeOpencode: - t = opencode.New(config) - default: - klog.Fatalf("unsupported agent run type: %s", stackType) + return opencode.New(config), nil + case console.AgentRuntimeTypeClaude: + return claude.New(config), nil } - - return t + return nil, fmt.Errorf("unsupported agent run type: %s", runtimeType) } diff --git a/pkg/harness/exec/exec.go b/pkg/harness/exec/exec.go index 7834bc9bf..a06c334e9 100644 --- a/pkg/harness/exec/exec.go +++ b/pkg/harness/exec/exec.go @@ -1,6 +1,7 @@ package exec import ( + "bufio" "context" "errors" "fmt" @@ -8,6 +9,7 @@ import ( "os" "os/exec" "strings" + "sync" "time" "github.com/pluralsh/polly/algorithms" @@ -20,7 +22,7 @@ import ( ) func (in *executable) Run(ctx context.Context) error { - cmd, err := in.prepare(ctx) + cmd, err := in.prepare(ctx, false) if err != nil { return err } @@ -42,7 +44,7 @@ func (in *executable) Run(ctx context.Context) error { } func (in *executable) Start(ctx context.Context) (WaitFn, error) { - cmd, err := in.prepare(ctx) + cmd, err := in.prepare(ctx, false) if err != nil { return nil, err } @@ -89,25 +91,26 @@ func (in *executable) ID() string { return in.id } -func (in *executable) prepare(ctx context.Context) (*exec.Cmd, error) { +func (in *executable) prepare(ctx context.Context, streaming bool) (*exec.Cmd, error) { if err := in.runLifecycleFunction(v1.LifecyclePreStart); err != nil { return nil, err } ctx = signals.NewCancelableContext(ctx, signals.NewTimeoutSignal(in.timeout)) cmd := exec.CommandContext(ctx, in.command, in.args...) - w := in.writer() - - // Configure additional writers so that we can simultaneously write output - // to multiple destinations - // Note: We need to use the same writer for stdout and stderr to guarantee - // thread-safe writing, otherwise output from stdout and stderr could be - // written concurrently and get reordered. - cmd.Stdout = w - cmd.Stderr = w - - if in.outputAnalyzer != nil { - cmd.Stderr = io.MultiWriter(w, in.outputAnalyzer.Stderr()) + if !streaming { + w := in.writer() + // Configure additional writers so that we can simultaneously write output + // to multiple destinations + // Note: We need to use the same writer for stdout and stderr to guarantee + // thread-safe writing, otherwise output from stdout and stderr could be + // written concurrently and get reordered. + cmd.Stdout = w + cmd.Stderr = w + + if in.outputAnalyzer != nil { + cmd.Stderr = io.MultiWriter(w, in.outputAnalyzer.Stderr()) + } } // Configure environment of the executable. @@ -189,3 +192,67 @@ func NewExecutable(command string, options ...Option) Executable { return result } + +func (in *executable) RunStream(ctx context.Context, cb func([]byte)) error { + // Call prepare() to get properly configured cmd + cmd, err := in.prepare(ctx, true) + if err != nil { + return err + } + + // Pipes for streaming + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return err + } + stderrPipe, err := cmd.StderrPipe() + if err != nil { + return err + } + + klog.V(log.LogLevelExtended).InfoS("executing", "command", in.Command()) + + if err := cmd.Start(); err != nil { + return err + } + + var wg sync.WaitGroup + wg.Add(2) + + // Stream lines from a reader, call callback, and write to sinks + streamReader := func(r io.Reader) { + defer wg.Done() + scanner := bufio.NewScanner(r) + for scanner.Scan() { + line := append([]byte(nil), scanner.Bytes()...) + if cb != nil { + cb(line) + } + } + if err := scanner.Err(); err != nil { + klog.ErrorS(err, "scanner error in RunStream") + } + } + + go streamReader(stdoutPipe) + go streamReader(stderrPipe) + + waitErr := cmd.Wait() + wg.Wait() + + // close sinks + in.close(in.outputSinks) + + // run analyzer & lifecycle hooks + if aErr := in.analyze(); aErr != nil { + waitErr = errors.Join(waitErr, aErr) + } + if ctxErr := context.Cause(ctx); ctxErr != nil { + waitErr = errors.Join(waitErr, ctxErr) + } + if hookErr := in.runLifecycleFunction(v1.LifecyclePostStart); hookErr != nil { + waitErr = errors.Join(waitErr, hookErr) + } + + return waitErr +} diff --git a/pkg/harness/exec/exec_types.go b/pkg/harness/exec/exec_types.go index 2ae2dfe55..80896cfd4 100644 --- a/pkg/harness/exec/exec_types.go +++ b/pkg/harness/exec/exec_types.go @@ -15,6 +15,7 @@ type Executable interface { Run(ctx context.Context) error Start(ctx context.Context) (WaitFn, error) RunWithOutput(ctx context.Context) ([]byte, error) + RunStream(ctx context.Context, cb func([]byte)) error Command() string }