mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-16 14:49:09 +02:00
* feat(agent): add Hermes Agent Provider via ACP protocol Integrate Hermes as a new agent backend using the ACP (Agent Communication Protocol) JSON-RPC 2.0 over stdio — the same pattern as the Codex provider but with ACP-specific methods. - New hermesBackend spawns `hermes acp` and drives initialize → session/new → session/prompt lifecycle - Handles session/update notifications: agent_message_chunk, agent_thought_chunk, tool_call, tool_call_update, usage_update - Auto-approves tool executions via HERMES_YOLO_MODE env var - Supports session resume, model override, system prompt injection - Token usage extracted from PromptResponse and usage_update events - Auto-detected at daemon startup via MULTICA_HERMES_PATH env var Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * feat(ui): optimize runtime icons and fix create-agent dialog overflow - Replace OpenClaw pixel-art icon (32 rects) with clean vector paths - Add Hermes provider icon (NousResearch mascot, 48x48 webp data URI) - Use provider-specific icons in runtime selector instead of generic Monitor - Fix dialog overflow: add min-w-0 to grid item so truncate works Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(agent): add required mcpServers param to Hermes ACP session/new ACP SDK v0.11.2 requires mcpServers as a mandatory field in NewSessionRequest. Without it, Pydantic validation fails with "Invalid params" and the agent immediately errors out. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
642 lines
16 KiB
Go
642 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)
|
|
|
|
cmd := exec.CommandContext(runCtx, execPath, "acp")
|
|
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
|
|
}
|
|
}
|