mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-24 19:41:14 +02:00
Every other backend (claude, codex, opencode, openclaw, gemini) filters opts.CustomArgs through a per-backend blocked map so protocol-critical flags can't be overridden via the Create Agent UI. The hermes backend appended CustomArgs directly to argv, so any future flag we add to the map would be silently bypassed here. Add hermesBlockedArgs (with 'acp' as the pinned subcommand) and route CustomArgs through filterCustomArgs. Behaviour is identical for today's use cases; the change prevents accidental protocol-flag overrides and brings hermes in line with the other five backends. Closes #1113 Co-authored-by: shaun0927 <shaun0927@users.noreply.github.com>
652 lines
16 KiB
Go
652 lines
16 KiB
Go
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
|
|
}
|
|
}
|