package agent import ( "bufio" "context" "encoding/json" "fmt" "os/exec" "strings" "sync" "time" ) // hermesBlockedArgs are flags hardcoded by the daemon that must not be // overridden by user-configured custom_args. `acp` is the protocol // subcommand that drives the ACP JSON-RPC transport; overriding it // would break the daemon↔Hermes communication contract. var hermesBlockedArgs = map[string]blockedArgMode{ "acp": blockedStandalone, } // 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"}, filterCustomArgs(opts.CustomArgs, hermesBlockedArgs, b.cfg.Logger)...) cmd := exec.CommandContext(runCtx, execPath, hermesArgs...) b.cfg.Logger.Debug("agent command", "exec", execPath, "args", 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, ¶ms) } 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 } }