mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
473d559042
Introduce a new `show_tool_calls_in_im` bot setting plus a full overhaul of how tool calls are surfaced in IM channels: - Add per-bot setting + migration (0072) and expose through settings API / handlers / frontend SDK. - Introduce a `toolCallDroppingStream` wrapper that filters tool_call_* events when the setting is off, keeping the rest of the stream intact. - Add a shared `ToolCallPresentation` model (Header / Body blocks / Footer) with plain and Markdown renderers, and a per-tool formatter registry that produces rich output (e.g. `web_search` link lists, `list` directory previews, `exec` stdout/stderr tails) instead of raw JSON dumps. - High-capability adapters (Telegram, Feishu, Matrix, Slack, Discord) now flush pre-text and then send ONE tool-call message per call, editing it in-place from `running` to `completed` / `failed`; mapping from callID to platform message ID is tracked per stream, with a fallback to a new message if the edit fails. Low-capability adapters (WeCom, QQ, DingTalk) keep posting a single final message, but now benefit from the same rich per-tool formatting. - Suppress the early duplicate `EventToolCallStart` (from `sdk.ToolInputStartPart`) so that the SDK's final `StreamToolCallPart` remains the single source of truth for tool call start, preventing duplicated "running" bubbles in IM. - Stop auto-populating `InputSummary` / `ResultSummary` after a per-tool formatter runs, which previously leaked the raw JSON result as a fallback footer underneath the formatted body. Add regression tests for the formatters, the Markdown renderer, the edit-in-place flow on Telegram/Matrix, and the JSON-leak guard on `list`.
1201 lines
31 KiB
Go
1201 lines
31 KiB
Go
package channel
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
"github.com/memohai/memoh/internal/textutil"
|
|
)
|
|
|
|
// toolFormatter produces a structured presentation for a specific built-in
|
|
// tool. Status is already inferred by the caller (running / completed /
|
|
// failed); the formatter may choose to populate Header, Body, Footer,
|
|
// InputSummary, ResultSummary, or any subset. Emoji / ToolName / Status are
|
|
// filled by the caller if left empty.
|
|
type toolFormatter func(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation
|
|
|
|
// toolFormatters registers the per-tool renderers. Missing entries fall back
|
|
// to the generic SummarizeToolInput / SummarizeToolResult helpers.
|
|
var toolFormatters = map[string]toolFormatter{
|
|
"list": formatList,
|
|
"read": formatRead,
|
|
"write": formatWrite,
|
|
"edit": formatEdit,
|
|
|
|
"exec": formatExec,
|
|
"bg_status": formatBgStatus,
|
|
|
|
"web_search": formatWebSearch,
|
|
"web_fetch": formatWebFetch,
|
|
|
|
"search_memory": formatSearchMemory,
|
|
"search_messages": formatSearchMessages,
|
|
"list_sessions": formatListSessions,
|
|
|
|
"list_schedule": formatListSchedule,
|
|
"get_schedule": formatGetSchedule,
|
|
"create_schedule": formatCreateSchedule,
|
|
"update_schedule": formatUpdateSchedule,
|
|
"delete_schedule": formatDeleteSchedule,
|
|
|
|
"send": formatSend,
|
|
"react": formatReact,
|
|
|
|
"get_contacts": formatGetContacts,
|
|
|
|
"list_email_accounts": formatListEmailAccounts,
|
|
"send_email": formatSendEmail,
|
|
"list_email": formatListEmail,
|
|
"read_email": formatReadEmail,
|
|
|
|
"browser_action": formatBrowserAction,
|
|
"browser_observe": formatBrowserObserve,
|
|
"browser_remote_session": formatBrowserRemoteSession,
|
|
|
|
"spawn": formatSpawn,
|
|
"use_skill": formatUseSkill,
|
|
|
|
"generate_image": formatGenerateImage,
|
|
"speak": formatSpeak,
|
|
"transcribe_audio": formatTranscribeAudio,
|
|
}
|
|
|
|
func lookupToolFormatter(name string) toolFormatter {
|
|
key := strings.ToLower(strings.TrimSpace(name))
|
|
if key == "" {
|
|
return nil
|
|
}
|
|
return toolFormatters[key]
|
|
}
|
|
|
|
// --- helpers ------------------------------------------------------------
|
|
|
|
func inputMap(tc *StreamToolCall) map[string]any {
|
|
if tc == nil {
|
|
return nil
|
|
}
|
|
m, _ := normalizeToMap(tc.Input)
|
|
return m
|
|
}
|
|
|
|
func resultMap(tc *StreamToolCall) map[string]any {
|
|
if tc == nil {
|
|
return nil
|
|
}
|
|
m, _ := normalizeToMap(tc.Result)
|
|
return m
|
|
}
|
|
|
|
func errorPresentation(p ToolCallPresentation, status ToolCallStatus, tc *StreamToolCall) (ToolCallPresentation, bool) {
|
|
if status != ToolCallStatusFailed {
|
|
return p, false
|
|
}
|
|
errText := ""
|
|
if res := resultMap(tc); res != nil {
|
|
errText = pickStringField(res, "error", "message", "stderr")
|
|
}
|
|
if errText == "" {
|
|
if tc != nil {
|
|
if s, ok := tc.Result.(string); ok {
|
|
errText = strings.TrimSpace(s)
|
|
}
|
|
}
|
|
}
|
|
if errText == "" {
|
|
errText = "failed"
|
|
}
|
|
p.Footer = "error: " + truncateSummary(errText)
|
|
return p, true
|
|
}
|
|
|
|
func truncLine(s string) string {
|
|
return textutil.TruncateRunesWithSuffix(strings.TrimSpace(s), 200, toolCallSummaryTruncMark)
|
|
}
|
|
|
|
func asSliceOfMaps(v any) []map[string]any {
|
|
if v == nil {
|
|
return nil
|
|
}
|
|
switch arr := v.(type) {
|
|
case []any:
|
|
out := make([]map[string]any, 0, len(arr))
|
|
for _, item := range arr {
|
|
if m, ok := normalizeToMap(item); ok {
|
|
out = append(out, m)
|
|
}
|
|
}
|
|
return out
|
|
case []map[string]any:
|
|
return arr
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// --- file tools ---------------------------------------------------------
|
|
|
|
func formatList(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
path := ""
|
|
if in != nil {
|
|
path = pickStringField(in, "path")
|
|
if path == "" {
|
|
path = "."
|
|
}
|
|
}
|
|
p := ToolCallPresentation{Header: path}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
return p
|
|
}
|
|
entries := asSliceOfMaps(res["entries"])
|
|
total := 0
|
|
if v, ok := numericField(res, "total_count"); ok {
|
|
total = int(v)
|
|
} else {
|
|
total = len(entries)
|
|
}
|
|
if total > 0 {
|
|
p.Header = fmt.Sprintf("%s · %d entries", path, total)
|
|
}
|
|
preview := 5
|
|
shown := 0
|
|
for _, e := range entries {
|
|
if shown >= preview {
|
|
break
|
|
}
|
|
name := pickStringField(e, "path")
|
|
if name == "" {
|
|
continue
|
|
}
|
|
isDir := false
|
|
if b, ok := e["is_dir"].(bool); ok {
|
|
isDir = b
|
|
}
|
|
kind := "file"
|
|
if isDir {
|
|
kind = "dir"
|
|
name = strings.TrimRight(name, "/") + "/"
|
|
}
|
|
size := ""
|
|
if sz, ok := numericField(e, "size"); ok && !isDir {
|
|
size = humanSize(int64(sz))
|
|
}
|
|
var text string
|
|
switch {
|
|
case isDir:
|
|
text = fmt.Sprintf("- %s (%s)", name, kind)
|
|
case size != "":
|
|
text = fmt.Sprintf("- %s (%s, %s)", name, kind, size)
|
|
default:
|
|
text = fmt.Sprintf("- %s (%s)", name, kind)
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: text})
|
|
shown++
|
|
}
|
|
if total > shown {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: fmt.Sprintf("…and %d more", total-shown)})
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatRead(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
path := pickStringField(in, "path", "file_path", "filepath")
|
|
p := ToolCallPresentation{Header: path}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
return p
|
|
}
|
|
if lines, ok := numericField(res, "total_lines"); ok && path != "" {
|
|
p.Header = fmt.Sprintf("%s · %d lines", path, int(lines))
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatWrite(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
path := pickStringField(in, "path", "file_path", "filepath")
|
|
p := ToolCallPresentation{Header: path}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatEdit(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
path := pickStringField(in, "path", "file_path", "filepath")
|
|
p := ToolCallPresentation{Header: path}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
return p
|
|
}
|
|
if changes, ok := numericField(res, "changes"); ok && int(changes) > 0 && path != "" {
|
|
p.Header = fmt.Sprintf("%s · %d changes", path, int(changes))
|
|
}
|
|
return p
|
|
}
|
|
|
|
// --- exec / bg_status --------------------------------------------------
|
|
|
|
func formatExec(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
cmd := pickStringField(in, "command", "cmd")
|
|
first := firstLine(cmd)
|
|
p := ToolCallPresentation{Header: "$ " + first}
|
|
if first == "" {
|
|
p.Header = ""
|
|
}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
return p
|
|
}
|
|
if st := pickStringField(res, "status"); st == "background_started" || st == "auto_backgrounded" {
|
|
taskID := pickStringField(res, "task_id")
|
|
out := pickStringField(res, "output_file")
|
|
parts := []string{st}
|
|
if taskID != "" {
|
|
parts = append(parts, "task_id="+taskID)
|
|
}
|
|
if out != "" {
|
|
parts = append(parts, out)
|
|
}
|
|
p.Footer = strings.Join(parts, " · ")
|
|
return p
|
|
}
|
|
exit := 0
|
|
if v, ok := numericField(res, "exit_code"); ok {
|
|
exit = int(v)
|
|
}
|
|
stdout := pickStringField(res, "stdout")
|
|
stderr := pickStringField(res, "stderr")
|
|
if stdout != "" {
|
|
text := truncLine(firstLine(stdout))
|
|
if text != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "stdout: " + text})
|
|
}
|
|
}
|
|
if stderr != "" {
|
|
text := truncLine(firstLine(stderr))
|
|
if text != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "stderr: " + text})
|
|
}
|
|
}
|
|
if status == ToolCallStatusFailed {
|
|
msg := firstLine(stderr)
|
|
if msg == "" {
|
|
msg = pickStringField(res, "error", "message")
|
|
}
|
|
if msg == "" {
|
|
msg = fmt.Sprintf("exit=%d", exit)
|
|
}
|
|
p.Footer = "error: " + truncateSummary(msg)
|
|
return p
|
|
}
|
|
p.Footer = fmt.Sprintf("exit=%d", exit)
|
|
return p
|
|
}
|
|
|
|
func formatBgStatus(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
action := pickStringField(in, "action")
|
|
taskID := pickStringField(in, "task_id")
|
|
header := action
|
|
if taskID != "" {
|
|
header = fmt.Sprintf("%s · %s", action, taskID)
|
|
}
|
|
p := ToolCallPresentation{Header: header}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
return p
|
|
}
|
|
tasks := asSliceOfMaps(res["tasks"])
|
|
if len(tasks) > 0 {
|
|
p.Header = fmt.Sprintf("%s · %d tasks", action, len(tasks))
|
|
preview := 5
|
|
for i, t := range tasks {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
id := pickStringField(t, "task_id", "id")
|
|
desc := pickStringField(t, "description")
|
|
st := pickStringField(t, "status")
|
|
exit := ""
|
|
if v, ok := numericField(t, "exit_code"); ok {
|
|
exit = fmt.Sprintf(" exit=%d", int(v))
|
|
}
|
|
label := id
|
|
if desc != "" {
|
|
label = fmt.Sprintf("%s \"%s\"", id, desc)
|
|
}
|
|
text := fmt.Sprintf("- %s · %s%s", label, st, exit)
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: text})
|
|
}
|
|
if len(tasks) > preview {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: fmt.Sprintf("…and %d more", len(tasks)-preview)})
|
|
}
|
|
return p
|
|
}
|
|
if msg := pickStringField(res, "message"); msg != "" {
|
|
p.Footer = msg
|
|
}
|
|
return p
|
|
}
|
|
|
|
// --- network tools ------------------------------------------------------
|
|
|
|
func formatWebSearch(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
query := pickStringField(in, "query")
|
|
p := ToolCallPresentation{Header: fmt.Sprintf("%q", query)}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
results := asSliceOfMaps(res["results"])
|
|
p.Header = fmt.Sprintf("%d results for %q", len(results), query)
|
|
preview := 5
|
|
for i, r := range results {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{
|
|
Type: ToolCallBlockLink,
|
|
Title: pickStringField(r, "title", "name"),
|
|
URL: pickStringField(r, "url", "link"),
|
|
Desc: truncLine(pickStringField(r, "description", "snippet", "content", "text")),
|
|
})
|
|
}
|
|
if len(results) > preview {
|
|
p.Footer = fmt.Sprintf("…and %d more", len(results)-preview)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatWebFetch(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
url := pickStringField(in, "url")
|
|
p := ToolCallPresentation{Header: url}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
return p
|
|
}
|
|
fetched := pickStringField(res, "url")
|
|
if fetched != "" {
|
|
url = fetched
|
|
}
|
|
title := pickStringField(res, "title")
|
|
if title != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockLink, Title: title, URL: url})
|
|
} else if url != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockLink, URL: url})
|
|
}
|
|
format := pickStringField(res, "format")
|
|
length := 0
|
|
if v, ok := numericField(res, "length"); ok {
|
|
length = int(v)
|
|
}
|
|
footer := format
|
|
if length > 0 {
|
|
footer = fmt.Sprintf("%s · %d chars", footer, length)
|
|
}
|
|
p.Footer = strings.TrimSpace(footer)
|
|
return p
|
|
}
|
|
|
|
// --- memory / history --------------------------------------------------
|
|
|
|
func formatSearchMemory(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
query := pickStringField(in, "query")
|
|
p := ToolCallPresentation{Header: fmt.Sprintf("%q", query)}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
results := asSliceOfMaps(res["results"])
|
|
if len(results) == 0 {
|
|
results = asSliceOfMaps(res["items"])
|
|
}
|
|
total := len(results)
|
|
if v, ok := numericField(res, "total"); ok && int(v) > total {
|
|
total = int(v)
|
|
}
|
|
p.Header = fmt.Sprintf("%d / %d results for %q", len(results), total, query)
|
|
preview := 5
|
|
for i, r := range results {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
text := pickStringField(r, "text", "content", "memory")
|
|
score := ""
|
|
if v, ok := numericField(r, "score"); ok {
|
|
score = fmt.Sprintf(" (%.2f)", v)
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "- " + truncLine(text) + score})
|
|
}
|
|
if len(results) > preview {
|
|
p.Footer = fmt.Sprintf("…and %d more", len(results)-preview)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatSearchMessages(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
keyword := pickStringField(in, "keyword", "query", "q")
|
|
header := ""
|
|
if keyword != "" {
|
|
header = fmt.Sprintf("keyword=%q", keyword)
|
|
}
|
|
p := ToolCallPresentation{Header: header}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
msgs := asSliceOfMaps(res["messages"])
|
|
summary := fmt.Sprintf("%d messages", len(msgs))
|
|
if keyword != "" {
|
|
summary = fmt.Sprintf("%s · keyword=%q", summary, keyword)
|
|
}
|
|
p.Header = summary
|
|
preview := 3
|
|
for i, m := range msgs {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
role := pickStringField(m, "role", "sender")
|
|
text := pickStringField(m, "text", "content")
|
|
when := pickStringField(m, "created_at", "timestamp")
|
|
label := role
|
|
if label == "" {
|
|
label = "msg"
|
|
}
|
|
prefix := label + ": "
|
|
if when != "" {
|
|
prefix = fmt.Sprintf("[%s] %s: ", when, label)
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: prefix + truncLine(text)})
|
|
}
|
|
if len(msgs) > preview {
|
|
p.Footer = fmt.Sprintf("…and %d more", len(msgs)-preview)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatListSessions(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
p := ToolCallPresentation{}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
sessions := asSliceOfMaps(res["sessions"])
|
|
p.Header = fmt.Sprintf("%d sessions", len(sessions))
|
|
preview := 5
|
|
for i, s := range sessions {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
id := pickStringField(s, "session_id", "id")
|
|
title := pickStringField(s, "title", "conversation_name")
|
|
platform := pickStringField(s, "platform")
|
|
last := pickStringField(s, "last_active")
|
|
parts := []string{fmt.Sprintf("- #%s", id)}
|
|
if title != "" {
|
|
parts = append(parts, fmt.Sprintf("\"%s\"", title))
|
|
}
|
|
if platform != "" {
|
|
parts = append(parts, "· "+platform)
|
|
}
|
|
if last != "" {
|
|
parts = append(parts, "· "+last)
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")})
|
|
}
|
|
if len(sessions) > preview {
|
|
p.Footer = fmt.Sprintf("…and %d more", len(sessions)-preview)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// --- schedule ----------------------------------------------------------
|
|
|
|
func formatListSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
p := ToolCallPresentation{}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
items := asSliceOfMaps(res["items"])
|
|
p.Header = fmt.Sprintf("%d schedules", len(items))
|
|
preview := 5
|
|
for i, item := range items {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "- " + summarizeScheduleEntry(item)})
|
|
}
|
|
if len(items) > preview {
|
|
p.Footer = fmt.Sprintf("…and %d more", len(items)-preview)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func summarizeScheduleEntry(item map[string]any) string {
|
|
id := pickStringField(item, "id")
|
|
name := pickStringField(item, "name")
|
|
pattern := pickStringField(item, "pattern")
|
|
enabled := true
|
|
if b, ok := item["enabled"].(bool); ok {
|
|
enabled = b
|
|
}
|
|
parts := []string{}
|
|
if id != "" {
|
|
parts = append(parts, fmt.Sprintf("[%s]", id))
|
|
}
|
|
if name != "" {
|
|
parts = append(parts, fmt.Sprintf("\"%s\"", name))
|
|
}
|
|
if pattern != "" {
|
|
parts = append(parts, "· "+pattern)
|
|
}
|
|
state := "enabled"
|
|
if !enabled {
|
|
state = "disabled"
|
|
}
|
|
parts = append(parts, "· "+state)
|
|
return strings.Join(parts, " ")
|
|
}
|
|
|
|
func formatGetSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
id := pickStringField(in, "id")
|
|
p := ToolCallPresentation{Header: id}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
if res := resultMap(tc); res != nil {
|
|
p.Header = summarizeScheduleEntry(res)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatCreateSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
name := pickStringField(in, "name")
|
|
pattern := pickStringField(in, "pattern")
|
|
p := ToolCallPresentation{Header: fmt.Sprintf("\"%s\" · cron %s", name, pattern)}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
if res := resultMap(tc); res != nil {
|
|
id := pickStringField(res, "id")
|
|
if id != "" {
|
|
p.Header = fmt.Sprintf("Created [%s] \"%s\" · cron %s", id, name, pattern)
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatUpdateSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
id := pickStringField(in, "id")
|
|
pattern := pickStringField(in, "pattern")
|
|
header := fmt.Sprintf("[%s]", id)
|
|
if pattern != "" {
|
|
header += " · pattern " + pattern
|
|
}
|
|
p := ToolCallPresentation{Header: header}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
p.Header = "Updated " + header
|
|
return p
|
|
}
|
|
|
|
func formatDeleteSchedule(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
id := pickStringField(in, "id")
|
|
p := ToolCallPresentation{Header: fmt.Sprintf("[%s]", id)}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
p.Header = fmt.Sprintf("Deleted [%s]", id)
|
|
return p
|
|
}
|
|
|
|
// --- messaging ---------------------------------------------------------
|
|
|
|
func formatSend(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
target := pickStringField(in, "target", "to", "recipient", "chat_id")
|
|
platform := pickStringField(in, "platform")
|
|
body := pickStringField(in, "body", "content", "message", "text")
|
|
header := ""
|
|
if target != "" {
|
|
header = "→ " + target
|
|
if platform != "" {
|
|
header += " (" + platform + ")"
|
|
}
|
|
}
|
|
p := ToolCallPresentation{Header: header}
|
|
if body != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: fmt.Sprintf("%q", truncLine(body))})
|
|
}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res != nil {
|
|
parts := []string{}
|
|
if delivered := pickStringField(res, "delivered"); delivered != "" {
|
|
parts = append(parts, delivered)
|
|
} else {
|
|
parts = append(parts, "delivered")
|
|
}
|
|
if msgID := pickStringField(res, "message_id"); msgID != "" {
|
|
parts = append(parts, "message_id="+msgID)
|
|
}
|
|
p.Footer = strings.Join(parts, " · ")
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatReact(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
emoji := pickStringField(in, "emoji")
|
|
msgID := pickStringField(in, "message_id")
|
|
remove := false
|
|
if b, ok := in["remove"].(bool); ok {
|
|
remove = b
|
|
}
|
|
action := "added"
|
|
if remove {
|
|
action = "removed"
|
|
}
|
|
p := ToolCallPresentation{Header: fmt.Sprintf("%s %s on %s", action, emoji, msgID)}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
return p
|
|
}
|
|
|
|
// --- contacts ----------------------------------------------------------
|
|
|
|
func formatGetContacts(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
p := ToolCallPresentation{}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
contacts := asSliceOfMaps(res["contacts"])
|
|
if contacts == nil {
|
|
contacts = asSliceOfMaps(res["items"])
|
|
}
|
|
p.Header = fmt.Sprintf("%d contacts", len(contacts))
|
|
preview := 5
|
|
for i, c := range contacts {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
name := pickStringField(c, "display_name", "name")
|
|
platform := pickStringField(c, "platform")
|
|
handle := pickStringField(c, "username", "handle", "channel_id")
|
|
last := pickStringField(c, "last_active")
|
|
parts := []string{"- " + name}
|
|
if platform != "" {
|
|
parts = append(parts, "· "+platform)
|
|
}
|
|
if handle != "" {
|
|
parts = append(parts, "· "+handle)
|
|
} else if last != "" {
|
|
parts = append(parts, "· "+last)
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")})
|
|
}
|
|
if len(contacts) > preview {
|
|
p.Footer = fmt.Sprintf("…and %d more", len(contacts)-preview)
|
|
}
|
|
return p
|
|
}
|
|
|
|
// --- email -------------------------------------------------------------
|
|
|
|
func formatListEmailAccounts(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
p := ToolCallPresentation{}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
accounts := asSliceOfMaps(res["accounts"])
|
|
p.Header = fmt.Sprintf("%d accounts", len(accounts))
|
|
preview := 5
|
|
for i, a := range accounts {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
addr := pickStringField(a, "address", "email")
|
|
perms := pickStringField(a, "permissions")
|
|
text := "- " + addr
|
|
if perms != "" {
|
|
text += " · " + perms
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: text})
|
|
}
|
|
if len(accounts) > preview {
|
|
p.Footer = fmt.Sprintf("…and %d more", len(accounts)-preview)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatSendEmail(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
to := pickStringField(in, "to", "recipient", "target")
|
|
subject := pickStringField(in, "subject")
|
|
header := ""
|
|
if to != "" {
|
|
header = "→ " + to
|
|
}
|
|
p := ToolCallPresentation{Header: header}
|
|
if subject != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "Subject: " + truncLine(subject)})
|
|
}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res != nil {
|
|
parts := []string{}
|
|
if st := pickStringField(res, "status"); st != "" {
|
|
parts = append(parts, "status="+st)
|
|
}
|
|
if msgID := pickStringField(res, "message_id"); msgID != "" {
|
|
parts = append(parts, "message_id="+msgID)
|
|
}
|
|
p.Footer = strings.Join(parts, " · ")
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatListEmail(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
p := ToolCallPresentation{}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
emails := asSliceOfMaps(res["emails"])
|
|
total := 0
|
|
if v, ok := numericField(res, "total"); ok {
|
|
total = int(v)
|
|
}
|
|
page := 0
|
|
if v, ok := numericField(res, "page"); ok {
|
|
page = int(v)
|
|
}
|
|
if total > 0 {
|
|
p.Header = fmt.Sprintf("page %d · %d of %d", page, len(emails), total)
|
|
} else {
|
|
p.Header = fmt.Sprintf("%d emails", len(emails))
|
|
}
|
|
preview := 5
|
|
for i, em := range emails {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
uid := pickStringField(em, "uid", "id")
|
|
from := pickStringField(em, "from", "sender")
|
|
subject := pickStringField(em, "subject")
|
|
when := pickStringField(em, "date", "received_at", "created_at")
|
|
parts := []string{"- #" + uid}
|
|
if from != "" {
|
|
parts = append(parts, "· "+from)
|
|
}
|
|
if subject != "" {
|
|
parts = append(parts, "· \""+truncLine(subject)+"\"")
|
|
}
|
|
if when != "" {
|
|
parts = append(parts, "· "+when)
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")})
|
|
}
|
|
if len(emails) > preview {
|
|
p.Footer = fmt.Sprintf("…and %d more", len(emails)-preview)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatReadEmail(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
uid := pickStringField(in, "uid", "id", "message_id")
|
|
p := ToolCallPresentation{Header: "#" + uid}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
return p
|
|
}
|
|
from := pickStringField(res, "from", "sender")
|
|
subject := pickStringField(res, "subject")
|
|
received := pickStringField(res, "date", "received_at", "received")
|
|
if from != "" {
|
|
p.Header = "From: " + from
|
|
}
|
|
if subject != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "Subject: " + truncLine(subject)})
|
|
}
|
|
if received != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "Received: " + received})
|
|
}
|
|
p.Footer = "(body hidden)"
|
|
return p
|
|
}
|
|
|
|
// --- browser -----------------------------------------------------------
|
|
|
|
func formatBrowserAction(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
action := pickStringField(in, "action")
|
|
url := pickStringField(in, "url")
|
|
header := action
|
|
if url != "" {
|
|
header = fmt.Sprintf("%s · %s", action, url)
|
|
}
|
|
p := ToolCallPresentation{Header: header}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatBrowserObserve(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
kind := pickStringField(in, "type", "kind")
|
|
p := ToolCallPresentation{Header: kind}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
return p
|
|
}
|
|
tabs := asSliceOfMaps(res["tabs"])
|
|
if len(tabs) > 0 {
|
|
p.Header = fmt.Sprintf("%s · %d tabs", kind, len(tabs))
|
|
preview := 5
|
|
for i, t := range tabs {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
title := pickStringField(t, "title")
|
|
u := pickStringField(t, "url")
|
|
active := false
|
|
if b, ok := t["active"].(bool); ok {
|
|
active = b
|
|
}
|
|
prefix := "- "
|
|
if active {
|
|
prefix = "- [active] "
|
|
}
|
|
parts := []string{prefix + title}
|
|
if u != "" {
|
|
parts = append(parts, "— "+u)
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")})
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatBrowserRemoteSession(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
action := pickStringField(in, "action")
|
|
p := ToolCallPresentation{Header: action}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res != nil {
|
|
id := pickStringField(res, "session_id")
|
|
expires := pickStringField(res, "expires", "expires_in", "ttl")
|
|
parts := []string{action}
|
|
if id != "" {
|
|
parts = append(parts, "session_id="+id)
|
|
}
|
|
if expires != "" {
|
|
parts = append(parts, "expires "+expires)
|
|
}
|
|
p.Header = strings.Join(parts, " · ")
|
|
}
|
|
return p
|
|
}
|
|
|
|
// --- subagent / skills / media -----------------------------------------
|
|
|
|
func formatSpawn(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
p := ToolCallPresentation{}
|
|
if status == ToolCallStatusRunning {
|
|
in := inputMap(tc)
|
|
tasks := asSliceOfMaps(in["tasks"])
|
|
if len(tasks) > 0 {
|
|
p.Header = fmt.Sprintf("%d subagent tasks", len(tasks))
|
|
}
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
results := asSliceOfMaps(res["results"])
|
|
succeeded := 0
|
|
for _, r := range results {
|
|
if b, ok := r["success"].(bool); ok && b {
|
|
succeeded++
|
|
continue
|
|
}
|
|
if b, ok := r["ok"].(bool); ok && b {
|
|
succeeded++
|
|
}
|
|
}
|
|
p.Header = fmt.Sprintf("%d / %d subagent tasks succeeded", succeeded, len(results))
|
|
preview := 5
|
|
for i, r := range results {
|
|
if i >= preview {
|
|
break
|
|
}
|
|
task := pickStringField(r, "task", "description")
|
|
sess := pickStringField(r, "session_id")
|
|
parts := []string{fmt.Sprintf("- \"%s\"", task)}
|
|
if sess != "" {
|
|
parts = append(parts, "· session "+sess)
|
|
}
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: strings.Join(parts, " ")})
|
|
}
|
|
if len(results) > preview {
|
|
p.Footer = fmt.Sprintf("…and %d more", len(results)-preview)
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatUseSkill(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
name := pickStringField(in, "name", "skill", "skill_name")
|
|
p := ToolCallPresentation{Header: name}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res != nil {
|
|
desc := pickStringField(res, "description", "summary")
|
|
if desc != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: truncLine(desc)})
|
|
}
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatGenerateImage(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
prompt := pickStringField(in, "prompt", "description")
|
|
p := ToolCallPresentation{Header: fmt.Sprintf("prompt: %q", truncLine(prompt))}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
return p
|
|
}
|
|
path := pickStringField(res, "path", "file", "url")
|
|
mime := pickStringField(res, "mime", "content_type")
|
|
size := ""
|
|
if v, ok := numericField(res, "size"); ok {
|
|
size = humanSize(int64(v))
|
|
}
|
|
headerParts := []string{}
|
|
if path != "" {
|
|
headerParts = append(headerParts, path)
|
|
}
|
|
if size != "" {
|
|
headerParts = append(headerParts, size)
|
|
}
|
|
if mime != "" {
|
|
headerParts = append(headerParts, mime)
|
|
}
|
|
if len(headerParts) > 0 {
|
|
p.Header = strings.Join(headerParts, " · ")
|
|
}
|
|
if prompt != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: "prompt: " + fmt.Sprintf("%q", truncLine(prompt))})
|
|
}
|
|
return p
|
|
}
|
|
|
|
func formatSpeak(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
in := inputMap(tc)
|
|
text := pickStringField(in, "text", "content")
|
|
p := ToolCallPresentation{Header: fmt.Sprintf("%q", truncLine(text))}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
p.Header = "delivered · " + fmt.Sprintf("%q", truncLine(text))
|
|
return p
|
|
}
|
|
|
|
func formatTranscribeAudio(tc *StreamToolCall, status ToolCallStatus) ToolCallPresentation {
|
|
p := ToolCallPresentation{}
|
|
if status == ToolCallStatusRunning {
|
|
return p
|
|
}
|
|
if e, done := errorPresentation(p, status, tc); done {
|
|
return e
|
|
}
|
|
res := resultMap(tc)
|
|
if res == nil {
|
|
return p
|
|
}
|
|
lang := pickStringField(res, "language", "lang")
|
|
durSec := 0
|
|
if v, ok := numericField(res, "duration"); ok {
|
|
durSec = int(v)
|
|
} else if v, ok := numericField(res, "duration_seconds"); ok {
|
|
durSec = int(v)
|
|
}
|
|
text := pickStringField(res, "text", "transcription")
|
|
headerParts := []string{}
|
|
if lang != "" {
|
|
headerParts = append(headerParts, lang)
|
|
}
|
|
if durSec > 0 {
|
|
headerParts = append(headerParts, fmt.Sprintf("%ds", durSec))
|
|
}
|
|
if len(headerParts) > 0 {
|
|
p.Header = strings.Join(headerParts, " · ")
|
|
}
|
|
if text != "" {
|
|
p.Body = append(p.Body, ToolCallBlock{Type: ToolCallBlockText, Text: fmt.Sprintf("%q", truncLine(text))})
|
|
}
|
|
return p
|
|
}
|
|
|
|
// humanSize formats a byte count into a short, human-readable string.
|
|
func humanSize(bytes int64) string {
|
|
const unit = 1024
|
|
if bytes < unit {
|
|
return fmt.Sprintf("%d B", bytes)
|
|
}
|
|
div, exp := int64(unit), 0
|
|
for n := bytes / unit; n >= unit; n /= unit {
|
|
div *= unit
|
|
exp++
|
|
}
|
|
units := []string{"KB", "MB", "GB", "TB"}
|
|
if exp >= len(units) {
|
|
exp = len(units) - 1
|
|
}
|
|
return fmt.Sprintf("%.1f %s", float64(bytes)/float64(div), units[exp])
|
|
}
|