mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-31 00:40:46 +02:00
* feat(agent): add custom CLI arguments support Allow users to configure custom CLI arguments per agent that get appended to the agent subprocess command at launch time. This enables use cases like specifying different models (--model o3), max turns, or other provider-specific flags without needing separate runtimes. Changes: - Add custom_args JSONB column to agent table (migration 041) - Update API handler to accept/return custom_args in create/update - Pass custom_args through claim endpoint to daemon - Append custom_args to CLI commands for all agent backends - Add ExecOptions.CustomArgs field in agent package - Add Custom Args tab in agent detail UI - Add --custom-args flag to CLI agent create/update commands Closes MUL-802 * fix(agent): filter protocol-critical flags from custom_args Add per-backend filtering of custom_args to prevent users from accidentally overriding flags that the daemon hardcodes for its communication protocol (e.g. --output-format, --input-format, --permission-mode for Claude). This follows the same pattern as custom_env's isBlockedEnvKey: we only block the small, stable set of flags that would break the daemon↔agent protocol — not every possible dangerous flag. Workspace members are trusted for everything else. Each backend defines its own blocked set: - Claude: -p, --output-format, --input-format, --permission-mode - Gemini: -p, --yolo, -o - Codex: --listen - OpenCode: --format - OpenClaw: --local, --json, --session-id, --message - Hermes: none (ACP is positional) Includes unit tests for the filtering logic. * fix(agent): address code review nits for custom_args - Replace module-level `nextArgId` counter with `crypto.randomUUID()` in custom-args-tab.tsx to avoid SSR ID conflicts - Add unit tests for custom args passthrough and blocked-arg filtering in both Claude and Gemini arg builders
508 lines
13 KiB
Go
508 lines
13 KiB
Go
package agent
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
// claudeBackend implements Backend by spawning the Claude Code CLI
|
|
// with --output-format stream-json.
|
|
type claudeBackend struct {
|
|
cfg Config
|
|
}
|
|
|
|
func (b *claudeBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
|
|
execPath := b.cfg.ExecutablePath
|
|
if execPath == "" {
|
|
execPath = "claude"
|
|
}
|
|
if _, err := exec.LookPath(execPath); err != nil {
|
|
return nil, fmt.Errorf("claude executable not found at %q: %w", execPath, err)
|
|
}
|
|
|
|
timeout := opts.Timeout
|
|
if timeout == 0 {
|
|
timeout = 20 * time.Minute
|
|
}
|
|
runCtx, cancel := context.WithTimeout(ctx, timeout)
|
|
|
|
args := buildClaudeArgs(opts, b.cfg.Logger)
|
|
|
|
cmd := exec.CommandContext(runCtx, execPath, args...)
|
|
cmd.WaitDelay = 10 * time.Second
|
|
if opts.Cwd != "" {
|
|
cmd.Dir = opts.Cwd
|
|
}
|
|
cmd.Env = buildEnv(b.cfg.Env)
|
|
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("claude stdout pipe: %w", err)
|
|
}
|
|
stdin, err := cmd.StdinPipe()
|
|
if err != nil {
|
|
cancel()
|
|
return nil, fmt.Errorf("claude stdin pipe: %w", err)
|
|
}
|
|
closeStdin := func() {
|
|
if stdin != nil {
|
|
_ = stdin.Close()
|
|
stdin = nil
|
|
}
|
|
}
|
|
cmd.Stderr = newLogWriter(b.cfg.Logger, "[claude:stderr] ")
|
|
|
|
if err := cmd.Start(); err != nil {
|
|
closeStdin()
|
|
cancel()
|
|
return nil, fmt.Errorf("start claude: %w", err)
|
|
}
|
|
if err := writeClaudeInput(stdin, prompt); err != nil {
|
|
closeStdin()
|
|
cancel()
|
|
_ = cmd.Wait()
|
|
return nil, fmt.Errorf("write claude input: %w", err)
|
|
}
|
|
closeStdin()
|
|
|
|
b.cfg.Logger.Info("claude started", "pid", cmd.Process.Pid, "cwd", opts.Cwd, "model", opts.Model)
|
|
|
|
msgCh := make(chan Message, 256)
|
|
resCh := make(chan Result, 1)
|
|
|
|
go func() {
|
|
defer cancel()
|
|
defer close(msgCh)
|
|
defer close(resCh)
|
|
|
|
startTime := time.Now()
|
|
var output strings.Builder
|
|
var sessionID string
|
|
finalStatus := "completed"
|
|
var finalError string
|
|
usage := make(map[string]TokenUsage)
|
|
|
|
// Close stdout when the context is cancelled so scanner.Scan() unblocks.
|
|
go func() {
|
|
<-runCtx.Done()
|
|
_ = stdout.Close()
|
|
}()
|
|
|
|
scanner := bufio.NewScanner(stdout)
|
|
scanner.Buffer(make([]byte, 0, 1024*1024), 10*1024*1024)
|
|
|
|
for scanner.Scan() {
|
|
line := strings.TrimSpace(scanner.Text())
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
var msg claudeSDKMessage
|
|
if err := json.Unmarshal([]byte(line), &msg); err != nil {
|
|
continue
|
|
}
|
|
|
|
switch msg.Type {
|
|
case "assistant":
|
|
b.handleAssistant(msg, msgCh, &output, usage)
|
|
case "user":
|
|
b.handleUser(msg, msgCh)
|
|
case "system":
|
|
if msg.SessionID != "" {
|
|
sessionID = msg.SessionID
|
|
}
|
|
trySend(msgCh, Message{Type: MessageStatus, Status: "running"})
|
|
case "result":
|
|
closeStdin()
|
|
sessionID = msg.SessionID
|
|
if msg.ResultText != "" {
|
|
output.Reset()
|
|
output.WriteString(msg.ResultText)
|
|
}
|
|
if msg.IsError {
|
|
finalStatus = "failed"
|
|
finalError = msg.ResultText
|
|
}
|
|
case "log":
|
|
if msg.Log != nil {
|
|
trySend(msgCh, Message{
|
|
Type: MessageLog,
|
|
Level: msg.Log.Level,
|
|
Content: msg.Log.Message,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Wait for process exit
|
|
exitErr := cmd.Wait()
|
|
duration := time.Since(startTime)
|
|
|
|
if runCtx.Err() == context.DeadlineExceeded {
|
|
finalStatus = "timeout"
|
|
finalError = fmt.Sprintf("claude timed out after %s", timeout)
|
|
} else if runCtx.Err() == context.Canceled {
|
|
finalStatus = "aborted"
|
|
finalError = "execution cancelled"
|
|
} else if exitErr != nil && finalStatus == "completed" {
|
|
finalStatus = "failed"
|
|
finalError = fmt.Sprintf("claude exited with error: %v", exitErr)
|
|
}
|
|
|
|
b.cfg.Logger.Info("claude finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
|
|
|
|
resCh <- Result{
|
|
Status: finalStatus,
|
|
Output: output.String(),
|
|
Error: finalError,
|
|
DurationMs: duration.Milliseconds(),
|
|
SessionID: sessionID,
|
|
Usage: usage,
|
|
}
|
|
}()
|
|
|
|
return &Session{Messages: msgCh, Result: resCh}, nil
|
|
}
|
|
|
|
func (b *claudeBackend) handleAssistant(msg claudeSDKMessage, ch chan<- Message, output *strings.Builder, usage map[string]TokenUsage) {
|
|
var content claudeMessageContent
|
|
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
|
return
|
|
}
|
|
|
|
// Accumulate token usage per model.
|
|
if content.Usage != nil && content.Model != "" {
|
|
u := usage[content.Model]
|
|
u.InputTokens += content.Usage.InputTokens
|
|
u.OutputTokens += content.Usage.OutputTokens
|
|
u.CacheReadTokens += content.Usage.CacheReadInputTokens
|
|
u.CacheWriteTokens += content.Usage.CacheCreationInputTokens
|
|
usage[content.Model] = u
|
|
}
|
|
|
|
for _, block := range content.Content {
|
|
switch block.Type {
|
|
case "text":
|
|
if block.Text != "" {
|
|
output.WriteString(block.Text)
|
|
trySend(ch, Message{Type: MessageText, Content: block.Text})
|
|
}
|
|
case "thinking":
|
|
if block.Text != "" {
|
|
trySend(ch, Message{Type: MessageThinking, Content: block.Text})
|
|
}
|
|
case "tool_use":
|
|
var input map[string]any
|
|
if block.Input != nil {
|
|
_ = json.Unmarshal(block.Input, &input)
|
|
}
|
|
trySend(ch, Message{
|
|
Type: MessageToolUse,
|
|
Tool: block.Name,
|
|
CallID: block.ID,
|
|
Input: input,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *claudeBackend) handleUser(msg claudeSDKMessage, ch chan<- Message) {
|
|
var content claudeMessageContent
|
|
if err := json.Unmarshal(msg.Message, &content); err != nil {
|
|
return
|
|
}
|
|
|
|
for _, block := range content.Content {
|
|
if block.Type == "tool_result" {
|
|
resultStr := ""
|
|
if block.Content != nil {
|
|
resultStr = string(block.Content)
|
|
}
|
|
trySend(ch, Message{
|
|
Type: MessageToolResult,
|
|
CallID: block.ToolUseID,
|
|
Output: resultStr,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
func (b *claudeBackend) handleControlRequest(msg claudeSDKMessage, stdin interface{ Write([]byte) (int, error) }) {
|
|
// Auto-approve all tool uses in autonomous/daemon mode.
|
|
var req claudeControlRequestPayload
|
|
if err := json.Unmarshal(msg.Request, &req); err != nil {
|
|
return
|
|
}
|
|
|
|
var inputMap map[string]any
|
|
if req.Input != nil {
|
|
_ = json.Unmarshal(req.Input, &inputMap)
|
|
}
|
|
if inputMap == nil {
|
|
inputMap = map[string]any{}
|
|
}
|
|
|
|
response := map[string]any{
|
|
"type": "control_response",
|
|
"response": map[string]any{
|
|
"subtype": "success",
|
|
"request_id": msg.RequestID,
|
|
"response": map[string]any{
|
|
"behavior": "allow",
|
|
"updatedInput": inputMap,
|
|
},
|
|
},
|
|
}
|
|
|
|
data, err := json.Marshal(response)
|
|
if err != nil {
|
|
b.cfg.Logger.Warn("claude: failed to marshal control response", "error", err)
|
|
return
|
|
}
|
|
data = append(data, '\n')
|
|
if _, err := stdin.Write(data); err != nil {
|
|
b.cfg.Logger.Warn("claude: failed to write control response", "error", err)
|
|
}
|
|
}
|
|
|
|
// ── Claude SDK JSON types ──
|
|
|
|
type claudeSDKMessage struct {
|
|
Type string `json:"type"`
|
|
Message json.RawMessage `json:"message,omitempty"`
|
|
Subtype string `json:"subtype,omitempty"`
|
|
SessionID string `json:"session_id,omitempty"`
|
|
|
|
// result fields
|
|
ResultText string `json:"result,omitempty"`
|
|
IsError bool `json:"is_error,omitempty"`
|
|
DurationMs float64 `json:"duration_ms,omitempty"`
|
|
NumTurns int `json:"num_turns,omitempty"`
|
|
|
|
// log fields
|
|
Log *claudeLogEntry `json:"log,omitempty"`
|
|
|
|
// control request fields
|
|
RequestID string `json:"request_id,omitempty"`
|
|
Request json.RawMessage `json:"request,omitempty"`
|
|
}
|
|
|
|
type claudeLogEntry struct {
|
|
Level string `json:"level"`
|
|
Message string `json:"message"`
|
|
}
|
|
|
|
type claudeMessageContent struct {
|
|
Role string `json:"role"`
|
|
Model string `json:"model"`
|
|
Content []claudeContentBlock `json:"content"`
|
|
Usage *claudeUsage `json:"usage,omitempty"`
|
|
}
|
|
|
|
type claudeUsage struct {
|
|
InputTokens int64 `json:"input_tokens"`
|
|
OutputTokens int64 `json:"output_tokens"`
|
|
CacheReadInputTokens int64 `json:"cache_read_input_tokens"`
|
|
CacheCreationInputTokens int64 `json:"cache_creation_input_tokens"`
|
|
}
|
|
|
|
type claudeContentBlock struct {
|
|
Type string `json:"type"`
|
|
Text string `json:"text,omitempty"`
|
|
ID string `json:"id,omitempty"`
|
|
Name string `json:"name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
ToolUseID string `json:"tool_use_id,omitempty"`
|
|
Content json.RawMessage `json:"content,omitempty"`
|
|
}
|
|
|
|
type claudeControlRequestPayload struct {
|
|
Subtype string `json:"subtype"`
|
|
ToolName string `json:"tool_name,omitempty"`
|
|
Input json.RawMessage `json:"input,omitempty"`
|
|
}
|
|
|
|
// ── Shared helpers ──
|
|
|
|
func trySend(ch chan<- Message, msg Message) {
|
|
select {
|
|
case ch <- msg:
|
|
default:
|
|
// Channel full — drop message. Final output is accumulated separately
|
|
// in Result.Output, so only streaming consumers are affected.
|
|
}
|
|
}
|
|
|
|
// claudeBlockedArgs are flags hardcoded by the daemon that must not be
|
|
// overridden by user-configured custom_args. Overriding these would break
|
|
// the daemon↔Claude communication protocol.
|
|
var claudeBlockedArgs = map[string]blockedArgMode{
|
|
"-p": blockedStandalone, // non-interactive mode
|
|
"--output-format": blockedWithValue, // stream-json protocol
|
|
"--input-format": blockedWithValue, // stream-json protocol
|
|
"--permission-mode": blockedWithValue, // bypassPermissions for autonomous operation
|
|
}
|
|
|
|
func buildClaudeArgs(opts ExecOptions, logger *slog.Logger) []string {
|
|
args := []string{
|
|
"-p",
|
|
"--output-format", "stream-json",
|
|
"--input-format", "stream-json",
|
|
"--verbose",
|
|
"--strict-mcp-config",
|
|
"--permission-mode", "bypassPermissions",
|
|
}
|
|
if opts.Model != "" {
|
|
args = append(args, "--model", opts.Model)
|
|
}
|
|
if opts.MaxTurns > 0 {
|
|
args = append(args, "--max-turns", fmt.Sprintf("%d", opts.MaxTurns))
|
|
}
|
|
if opts.SystemPrompt != "" {
|
|
args = append(args, "--append-system-prompt", opts.SystemPrompt)
|
|
}
|
|
if opts.ResumeSessionID != "" {
|
|
args = append(args, "--resume", opts.ResumeSessionID)
|
|
}
|
|
args = append(args, filterCustomArgs(opts.CustomArgs, claudeBlockedArgs, logger)...)
|
|
return args
|
|
}
|
|
|
|
func writeClaudeInput(w io.Writer, prompt string) error {
|
|
data, err := buildClaudeInput(prompt)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := w.Write(data); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func buildClaudeInput(prompt string) ([]byte, error) {
|
|
payload := map[string]any{
|
|
"type": "user",
|
|
"message": map[string]any{
|
|
"role": "user",
|
|
"content": []map[string]string{
|
|
{
|
|
"type": "text",
|
|
"text": prompt,
|
|
},
|
|
},
|
|
},
|
|
}
|
|
data, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal claude input: %w", err)
|
|
}
|
|
return append(data, '\n'), nil
|
|
}
|
|
|
|
func buildEnv(extra map[string]string) []string {
|
|
return mergeEnv(os.Environ(), extra)
|
|
}
|
|
|
|
func mergeEnv(base []string, extra map[string]string) []string {
|
|
env := make([]string, 0, len(base)+len(extra))
|
|
for _, entry := range base {
|
|
key, _, _ := strings.Cut(entry, "=")
|
|
if isFilteredChildEnvKey(key) {
|
|
continue
|
|
}
|
|
env = append(env, entry)
|
|
}
|
|
for k, v := range extra {
|
|
env = append(env, k+"="+v)
|
|
}
|
|
return env
|
|
}
|
|
|
|
func isFilteredChildEnvKey(key string) bool {
|
|
return key == "CLAUDECODE" ||
|
|
strings.HasPrefix(key, "CLAUDECODE_") ||
|
|
strings.HasPrefix(key, "CLAUDE_CODE_")
|
|
}
|
|
|
|
// blockedArgMode specifies whether a blocked arg takes a value or is standalone.
|
|
type blockedArgMode int
|
|
|
|
const (
|
|
blockedWithValue blockedArgMode = iota // flag takes a value (next arg or =value)
|
|
blockedStandalone // flag is boolean, no value
|
|
)
|
|
|
|
// filterCustomArgs removes protocol-critical flags from user-configured custom
|
|
// args to prevent breaking daemon↔agent communication. Each backend defines its
|
|
// own blocked set (the flags it hardcodes). This is intentionally narrow — we
|
|
// only block args that would break the communication protocol, not every
|
|
// possible dangerous flag. Workspace members are trusted to configure agents
|
|
// sensibly, same as with custom_env.
|
|
func filterCustomArgs(args []string, blocked map[string]blockedArgMode, logger *slog.Logger) []string {
|
|
if len(args) == 0 {
|
|
return args
|
|
}
|
|
filtered := make([]string, 0, len(args))
|
|
skip := false
|
|
for _, arg := range args {
|
|
if skip {
|
|
skip = false
|
|
continue
|
|
}
|
|
// Check if this arg is a blocked flag or starts with "blockedFlag=".
|
|
flag := arg
|
|
hasInlineValue := false
|
|
if idx := strings.Index(arg, "="); idx > 0 {
|
|
flag = arg[:idx]
|
|
hasInlineValue = true
|
|
}
|
|
mode, isBlocked := blocked[flag]
|
|
if isBlocked {
|
|
logger.Warn("custom_args: blocked protocol-critical flag, skipping", "flag", flag)
|
|
if mode == blockedWithValue && !hasInlineValue {
|
|
// The next arg is the value for this flag — skip it too.
|
|
skip = true
|
|
}
|
|
continue
|
|
}
|
|
filtered = append(filtered, arg)
|
|
}
|
|
return filtered
|
|
}
|
|
|
|
func detectCLIVersion(ctx context.Context, execPath string) (string, error) {
|
|
cmd := exec.CommandContext(ctx, execPath, "--version")
|
|
data, err := cmd.Output()
|
|
if err != nil {
|
|
return "", fmt.Errorf("detect version for %s: %w", execPath, err)
|
|
}
|
|
return strings.TrimSpace(string(data)), nil
|
|
}
|
|
|
|
// logWriter adapts a *slog.Logger to an io.Writer for capturing stderr.
|
|
type logWriter struct {
|
|
logger *slog.Logger
|
|
prefix string
|
|
}
|
|
|
|
func newLogWriter(logger *slog.Logger, prefix string) *logWriter {
|
|
return &logWriter{logger: logger, prefix: prefix}
|
|
}
|
|
|
|
func (w *logWriter) Write(p []byte) (int, error) {
|
|
text := strings.TrimSpace(string(p))
|
|
if text != "" {
|
|
w.logger.Debug(w.prefix + text)
|
|
}
|
|
return len(p), nil
|
|
}
|