Files
multica/server/pkg/agent/hermes.go
Bohan Jiang ce447c7f06 feat(agent): add custom CLI arguments support (#986)
* 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
2026-04-15 14:58:53 +08:00

643 lines
16 KiB
Go

package agent
import (
"bufio"
"context"
"encoding/json"
"fmt"
"os/exec"
"strings"
"sync"
"time"
)
// hermesBackend implements Backend by spawning `hermes acp` and communicating
// via the ACP (Agent Communication Protocol) JSON-RPC 2.0 over stdin/stdout.
// This is the same pattern as Codex but with the ACP protocol instead of
// the Codex-specific JSON-RPC methods.
type hermesBackend struct {
cfg Config
}
func (b *hermesBackend) Execute(ctx context.Context, prompt string, opts ExecOptions) (*Session, error) {
execPath := b.cfg.ExecutablePath
if execPath == "" {
execPath = "hermes"
}
if _, err := exec.LookPath(execPath); err != nil {
return nil, fmt.Errorf("hermes executable not found at %q: %w", execPath, err)
}
timeout := opts.Timeout
if timeout == 0 {
timeout = 20 * time.Minute
}
runCtx, cancel := context.WithTimeout(ctx, timeout)
hermesArgs := append([]string{"acp"}, opts.CustomArgs...)
cmd := exec.CommandContext(runCtx, execPath, hermesArgs...)
if opts.Cwd != "" {
cmd.Dir = opts.Cwd
}
env := buildEnv(b.cfg.Env)
// Enable yolo mode so Hermes auto-approves all tool executions.
env = append(env, "HERMES_YOLO_MODE=1")
cmd.Env = env
stdout, err := cmd.StdoutPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("hermes stdout pipe: %w", err)
}
stdin, err := cmd.StdinPipe()
if err != nil {
cancel()
return nil, fmt.Errorf("hermes stdin pipe: %w", err)
}
cmd.Stderr = newLogWriter(b.cfg.Logger, "[hermes:stderr] ")
if err := cmd.Start(); err != nil {
cancel()
return nil, fmt.Errorf("start hermes: %w", err)
}
b.cfg.Logger.Info("hermes acp started", "pid", cmd.Process.Pid, "cwd", opts.Cwd)
msgCh := make(chan Message, 256)
resCh := make(chan Result, 1)
var outputMu sync.Mutex
var output strings.Builder
promptDone := make(chan hermesPromptResult, 1)
c := &hermesClient{
cfg: b.cfg,
stdin: stdin,
pending: make(map[int]*pendingRPC),
onMessage: func(msg Message) {
if msg.Type == MessageText {
outputMu.Lock()
output.WriteString(msg.Content)
outputMu.Unlock()
}
trySend(msgCh, msg)
},
onPromptDone: func(result hermesPromptResult) {
select {
case promptDone <- result:
default:
}
},
}
// Start reading stdout in background.
readerDone := make(chan struct{})
go func() {
defer close(readerDone)
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
}
c.handleLine(line)
}
c.closeAllPending(fmt.Errorf("hermes process exited"))
}()
// Drive the ACP session lifecycle in a goroutine.
go func() {
defer cancel()
defer close(msgCh)
defer close(resCh)
defer func() {
stdin.Close()
_ = cmd.Wait()
}()
startTime := time.Now()
finalStatus := "completed"
var finalError string
var sessionID string
// 1. Initialize handshake.
_, err := c.request(runCtx, "initialize", map[string]any{
"protocolVersion": 1,
"clientInfo": map[string]any{
"name": "multica-agent-sdk",
"version": "0.2.0",
},
"clientCapabilities": map[string]any{},
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("hermes initialize failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
// 2. Create or resume a session.
cwd := opts.Cwd
if cwd == "" {
cwd = "."
}
if opts.ResumeSessionID != "" {
result, err := c.request(runCtx, "session/resume", map[string]any{
"cwd": cwd,
"sessionId": opts.ResumeSessionID,
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("hermes session/resume failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
sessionID = opts.ResumeSessionID
_ = result
} else {
result, err := c.request(runCtx, "session/new", map[string]any{
"cwd": cwd,
"mcpServers": []any{},
})
if err != nil {
finalStatus = "failed"
finalError = fmt.Sprintf("hermes session/new failed: %v", err)
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
sessionID = extractHermesSessionID(result)
if sessionID == "" {
finalStatus = "failed"
finalError = "hermes session/new returned no session ID"
resCh <- Result{Status: finalStatus, Error: finalError, DurationMs: time.Since(startTime).Milliseconds()}
return
}
}
c.sessionID = sessionID
b.cfg.Logger.Info("hermes session created", "session_id", sessionID)
// 3. Build the prompt content. If we have a system prompt, prepend it.
userText := prompt
if opts.SystemPrompt != "" {
userText = opts.SystemPrompt + "\n\n---\n\n" + prompt
}
// 4. Send the prompt and wait for PromptResponse.
_, err = c.request(runCtx, "session/prompt", map[string]any{
"sessionId": sessionID,
"prompt": []map[string]any{
{"type": "text", "text": userText},
},
})
if err != nil {
// If the request itself failed (not just context cancelled),
// check if the context was cancelled/timed out.
if runCtx.Err() == context.DeadlineExceeded {
finalStatus = "timeout"
finalError = fmt.Sprintf("hermes timed out after %s", timeout)
} else if runCtx.Err() == context.Canceled {
finalStatus = "aborted"
finalError = "execution cancelled"
} else {
finalStatus = "failed"
finalError = fmt.Sprintf("hermes session/prompt failed: %v", err)
}
} else {
// The prompt completed. Check if we got a promptDone result
// from the response parsing.
select {
case pr := <-promptDone:
if pr.stopReason == "cancelled" {
finalStatus = "aborted"
finalError = "hermes cancelled the prompt"
}
// Merge usage from the PromptResponse.
c.usageMu.Lock()
c.usage.InputTokens += pr.usage.InputTokens
c.usage.OutputTokens += pr.usage.OutputTokens
c.usageMu.Unlock()
default:
}
}
duration := time.Since(startTime)
b.cfg.Logger.Info("hermes finished", "pid", cmd.Process.Pid, "status", finalStatus, "duration", duration.Round(time.Millisecond).String())
// Close stdin and cancel context to signal hermes acp to exit.
stdin.Close()
cancel()
// Wait for the reader goroutine to finish so all output is accumulated.
<-readerDone
outputMu.Lock()
finalOutput := output.String()
outputMu.Unlock()
// Build usage map.
c.usageMu.Lock()
u := c.usage
c.usageMu.Unlock()
var usageMap map[string]TokenUsage
if u.InputTokens > 0 || u.OutputTokens > 0 || u.CacheReadTokens > 0 {
model := opts.Model
if model == "" {
model = "unknown"
}
usageMap = map[string]TokenUsage{model: u}
}
resCh <- Result{
Status: finalStatus,
Output: finalOutput,
Error: finalError,
DurationMs: duration.Milliseconds(),
SessionID: sessionID,
Usage: usageMap,
}
}()
return &Session{Messages: msgCh, Result: resCh}, nil
}
// ── hermesClient: ACP JSON-RPC 2.0 transport ──
type hermesPromptResult struct {
stopReason string
usage TokenUsage
}
type hermesClient struct {
cfg Config
stdin interface{ Write([]byte) (int, error) }
mu sync.Mutex
nextID int
pending map[int]*pendingRPC
sessionID string
onMessage func(Message)
onPromptDone func(hermesPromptResult)
usageMu sync.Mutex
usage TokenUsage
}
func (c *hermesClient) request(ctx context.Context, method string, params any) (json.RawMessage, error) {
c.mu.Lock()
id := c.nextID
c.nextID++
pr := &pendingRPC{ch: make(chan rpcResult, 1), method: method}
c.pending[id] = pr
c.mu.Unlock()
msg := map[string]any{
"jsonrpc": "2.0",
"id": id,
"method": method,
"params": params,
}
data, err := json.Marshal(msg)
if err != nil {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, err
}
data = append(data, '\n')
if _, err := c.stdin.Write(data); err != nil {
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, fmt.Errorf("write %s: %w", method, err)
}
select {
case res := <-pr.ch:
return res.result, res.err
case <-ctx.Done():
c.mu.Lock()
delete(c.pending, id)
c.mu.Unlock()
return nil, ctx.Err()
}
}
func (c *hermesClient) closeAllPending(err error) {
c.mu.Lock()
defer c.mu.Unlock()
for id, pr := range c.pending {
pr.ch <- rpcResult{err: err}
delete(c.pending, id)
}
}
func (c *hermesClient) handleLine(line string) {
var raw map[string]json.RawMessage
if err := json.Unmarshal([]byte(line), &raw); err != nil {
return
}
// Check if it's a response to our request (has id + result or error).
if _, hasID := raw["id"]; hasID {
if _, hasResult := raw["result"]; hasResult {
c.handleResponse(raw)
return
}
if _, hasError := raw["error"]; hasError {
c.handleResponse(raw)
return
}
}
// Notification (no id, has method) — session updates from Hermes.
if _, hasMethod := raw["method"]; hasMethod {
c.handleNotification(raw)
}
}
func (c *hermesClient) handleResponse(raw map[string]json.RawMessage) {
var id int
if err := json.Unmarshal(raw["id"], &id); err != nil {
// Try float (JSON numbers are floats by default).
var fid float64
if err := json.Unmarshal(raw["id"], &fid); err != nil {
return
}
id = int(fid)
}
c.mu.Lock()
pr, ok := c.pending[id]
if ok {
delete(c.pending, id)
}
c.mu.Unlock()
if !ok {
return
}
if errData, hasErr := raw["error"]; hasErr {
var rpcErr struct {
Code int `json:"code"`
Message string `json:"message"`
}
_ = json.Unmarshal(errData, &rpcErr)
pr.ch <- rpcResult{err: fmt.Errorf("%s: %s (code=%d)", pr.method, rpcErr.Message, rpcErr.Code)}
} else {
// If this is a prompt response, extract usage and stop reason.
if pr.method == "session/prompt" {
c.extractPromptResult(raw["result"])
}
pr.ch <- rpcResult{result: raw["result"]}
}
}
func (c *hermesClient) extractPromptResult(data json.RawMessage) {
var resp struct {
StopReason string `json:"stopReason"`
Usage *struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
TotalTokens int64 `json:"totalTokens"`
ThoughtTokens int64 `json:"thoughtTokens"`
CachedReadTokens int64 `json:"cachedReadTokens"`
} `json:"usage"`
}
if err := json.Unmarshal(data, &resp); err != nil {
return
}
pr := hermesPromptResult{
stopReason: resp.StopReason,
}
if resp.Usage != nil {
pr.usage = TokenUsage{
InputTokens: resp.Usage.InputTokens,
OutputTokens: resp.Usage.OutputTokens,
CacheReadTokens: resp.Usage.CachedReadTokens,
}
}
if c.onPromptDone != nil {
c.onPromptDone(pr)
}
}
func (c *hermesClient) handleNotification(raw map[string]json.RawMessage) {
var method string
_ = json.Unmarshal(raw["method"], &method)
if method != "session/update" {
return
}
var params struct {
SessionID string `json:"sessionId"`
Update json.RawMessage `json:"update"`
}
if p, ok := raw["params"]; ok {
_ = json.Unmarshal(p, &params)
}
if len(params.Update) == 0 {
return
}
// Parse the update discriminator.
var updateType struct {
SessionUpdate string `json:"sessionUpdate"`
}
_ = json.Unmarshal(params.Update, &updateType)
switch updateType.SessionUpdate {
case "agent_message_chunk":
c.handleAgentMessage(params.Update)
case "agent_thought_chunk":
c.handleAgentThought(params.Update)
case "tool_call":
c.handleToolCallStart(params.Update)
case "tool_call_update":
c.handleToolCallUpdate(params.Update)
case "usage_update":
c.handleUsageUpdate(params.Update)
}
}
func (c *hermesClient) handleAgentMessage(data json.RawMessage) {
var msg struct {
Content struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(data, &msg); err != nil || msg.Content.Text == "" {
return
}
if c.onMessage != nil {
c.onMessage(Message{Type: MessageText, Content: msg.Content.Text})
}
}
func (c *hermesClient) handleAgentThought(data json.RawMessage) {
var msg struct {
Content struct {
Type string `json:"type"`
Text string `json:"text"`
} `json:"content"`
}
if err := json.Unmarshal(data, &msg); err != nil || msg.Content.Text == "" {
return
}
if c.onMessage != nil {
c.onMessage(Message{Type: MessageThinking, Content: msg.Content.Text})
}
}
func (c *hermesClient) handleToolCallStart(data json.RawMessage) {
var msg struct {
ToolCallID string `json:"toolCallId"`
Title string `json:"title"`
Kind string `json:"kind"`
RawInput map[string]any `json:"rawInput"`
}
if err := json.Unmarshal(data, &msg); err != nil {
return
}
toolName := hermesToolNameFromTitle(msg.Title, msg.Kind)
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolUse,
Tool: toolName,
CallID: msg.ToolCallID,
Input: msg.RawInput,
})
}
}
func (c *hermesClient) handleToolCallUpdate(data json.RawMessage) {
var msg struct {
ToolCallID string `json:"toolCallId"`
Status string `json:"status"`
Kind string `json:"kind"`
RawOutput string `json:"rawOutput"`
}
if err := json.Unmarshal(data, &msg); err != nil {
return
}
// Only emit tool result when the call is completed.
if msg.Status != "completed" && msg.Status != "failed" {
return
}
if c.onMessage != nil {
c.onMessage(Message{
Type: MessageToolResult,
CallID: msg.ToolCallID,
Output: msg.RawOutput,
})
}
}
func (c *hermesClient) handleUsageUpdate(data json.RawMessage) {
var msg struct {
Usage struct {
InputTokens int64 `json:"inputTokens"`
OutputTokens int64 `json:"outputTokens"`
TotalTokens int64 `json:"totalTokens"`
CachedReadTokens int64 `json:"cachedReadTokens"`
} `json:"usage"`
}
if err := json.Unmarshal(data, &msg); err != nil {
return
}
c.usageMu.Lock()
// Usage updates from ACP are cumulative snapshots, so take the latest.
if msg.Usage.InputTokens > c.usage.InputTokens {
c.usage.InputTokens = msg.Usage.InputTokens
}
if msg.Usage.OutputTokens > c.usage.OutputTokens {
c.usage.OutputTokens = msg.Usage.OutputTokens
}
if msg.Usage.CachedReadTokens > c.usage.CacheReadTokens {
c.usage.CacheReadTokens = msg.Usage.CachedReadTokens
}
c.usageMu.Unlock()
}
// ── Helpers ──
func extractHermesSessionID(result json.RawMessage) string {
var r struct {
SessionID string `json:"sessionId"`
}
if err := json.Unmarshal(result, &r); err != nil {
return ""
}
return r.SessionID
}
// hermesToolNameFromTitle extracts a tool name from the ACP tool call title.
// Hermes ACP titles look like "terminal: ls -la", "read: /path/to/file", etc.
// Some titles have no colon (e.g. "execute code").
func hermesToolNameFromTitle(title string, kind string) string {
// Check exact-match titles first (no colon).
switch title {
case "execute code":
return "execute_code"
}
// Try to extract the tool name from before the first colon.
if idx := strings.Index(title, ":"); idx > 0 {
name := strings.TrimSpace(title[:idx])
// Map common ACP title prefixes back to tool names.
// Some titles include mode info like "patch (replace)", so check prefix.
switch {
case name == "terminal":
return "terminal"
case name == "read":
return "read_file"
case name == "write":
return "write_file"
case strings.HasPrefix(name, "patch"):
return "patch"
case name == "search":
return "search_files"
case name == "web search":
return "web_search"
case name == "extract":
return "web_extract"
case name == "delegate":
return "delegate_task"
case name == "analyze image":
return "vision_analyze"
}
return name
}
// Fall back to kind.
switch kind {
case "read":
return "read_file"
case "edit":
return "write_file"
case "execute":
return "terminal"
case "search":
return "search_files"
case "fetch":
return "web_search"
case "think":
return "thinking"
default:
return kind
}
}