Ryanhub - file viewer
filename: assistant/agent/human_tool_list.go
branch: main
back to repo
package agent

import (
	"encoding/json"
	"fmt"
	"sort"
	"strings"
	"unicode/utf8"

	"assistant/tools"
)

const (
	maxToolDescRunes = 160
	maxPropDescRunes = 72
)

// formatToolListHuman renders tools as compact Markdown for the chat UI.
func formatToolListHuman(reg tools.Registry) string {
	if len(reg) == 0 {
		return "No tools are registered."
	}
	names := make([]string, 0, len(reg))
	for n := range reg {
		names = append(names, n)
	}
	sort.Strings(names)
	var b strings.Builder
	for _, n := range names {
		b.WriteString(formatOneToolMarkdown(reg[n]))
		b.WriteString("\n\n")
	}
	b.WriteString(toolListFooterMarkdown())
	return strings.TrimSpace(b.String())
}

func toolListFooterMarkdown() string {
	return strings.Join([]string{
		"### How to run",
		"- `/tool <name> key=value …` - separate pairs with spaces",
		"- Quote text: `content=\"likes dark mode\"`",
		"- Numbers/booleans unquoted: `limit=20` `action=create`",
		"- Optional JSON: `/tool calendar_list_range {\"from\":\"2025-01-01\",\"to\":\"2025-02-01\"}`",
	}, "\n")
}

func formatOneToolMarkdown(t tools.Tool) string {
	var b strings.Builder
	b.WriteString("### ")
	b.WriteString(t.Name)
	b.WriteString("\n\n")
	desc := shortenPlain(cleanForMarkdown(strings.TrimSpace(t.Description)), maxToolDescRunes)
	if desc != "" {
		b.WriteString("*")
		b.WriteString(desc)
		b.WriteString("*\n\n")
	}
	lines, example := schemaToMarkdownLines(t.Parameters, t.Name)
	for _, line := range lines {
		b.WriteString("- ")
		b.WriteString(line)
		b.WriteString("\n")
	}
	b.WriteString("- Example: `")
	b.WriteString(example)
	b.WriteString("`")
	return b.String()
}

func schemaToMarkdownLines(params json.RawMessage, toolName string) (lines []string, example string) {
	if len(params) == 0 {
		return []string{"*No arguments.*"}, fmt.Sprintf("/tool %s", toolName)
	}
	var root map[string]interface{}
	if err := json.Unmarshal(params, &root); err != nil {
		return []string{fmt.Sprintf("Schema error: %v", err)}, fmt.Sprintf("/tool %s", toolName)
	}
	props, _ := root["properties"].(map[string]interface{})
	if len(props) == 0 {
		return []string{"*No arguments.*"}, fmt.Sprintf("/tool %s", toolName)
	}
	reqSet := map[string]struct{}{}
	if req, ok := root["required"].([]interface{}); ok {
		for _, r := range req {
			if s, ok := r.(string); ok {
				reqSet[s] = struct{}{}
			}
		}
	}
	names := make([]string, 0, len(props))
	for k := range props {
		names = append(names, k)
	}
	sort.Strings(names)
	for _, key := range names {
		p, _ := props[key].(map[string]interface{})
		lines = append(lines, describePropMarkdown(key, p, reqSet))
	}
	ex := buildExampleLine(toolName, names, props, reqSet)
	return lines, ex
}

func describePropMarkdown(name string, p map[string]interface{}, req map[string]struct{}) string {
	_, required := req[name]
	reqLabel := "opt"
	if required {
		reqLabel = "req"
	}
	typ := typeString(p)
	desc := ""
	if d, ok := p["description"].(string); ok {
		desc = shortenPlain(cleanForMarkdown(strings.TrimSpace(d)), maxPropDescRunes)
	}
	var sb strings.Builder
	sb.WriteString("`")
	sb.WriteString(name)
	sb.WriteString("` ")
	sb.WriteString(typ)
	sb.WriteString(" · ")
	sb.WriteString(reqLabel)
	if desc != "" {
		sb.WriteString(" - ")
		sb.WriteString(desc)
	}
	if raw, ok := p["enum"]; ok {
		sb.WriteString(" · ")
		sb.WriteString(shortEnum(raw))
	}
	return sb.String()
}

func shortEnum(raw interface{}) string {
	switch v := raw.(type) {
	case []interface{}:
		parts := make([]string, 0, len(v))
		for _, x := range v {
			parts = append(parts, fmt.Sprint(x))
		}
		s := strings.Join(parts, ", ")
		if utf8.RuneCountInString(s) > 48 {
			return "enum: " + shortenPlain(s, 45)
		}
		return "enum: " + s
	default:
		return ""
	}
}

func cleanForMarkdown(s string) string {
	s = strings.ReplaceAll(s, "\n", " ")
	s = strings.ReplaceAll(s, "\r", "")
	s = strings.ReplaceAll(s, "*", "")
	s = strings.ReplaceAll(s, "_", " ")
	for strings.Contains(s, "  ") {
		s = strings.ReplaceAll(s, "  ", " ")
	}
	return strings.TrimSpace(s)
}

// shortenPlain trims to max runes, preferring end of first sentence.
func shortenPlain(s string, max int) string {
	if max <= 0 || s == "" {
		return s
	}
	if utf8.RuneCountInString(s) <= max {
		return s
	}
	for _, sep := range []string{". ", "? ", "! ", "; "} {
		if idx := strings.Index(s, sep); idx != -1 && idx < max+20 {
			frag := s[:idx+1]
			if utf8.RuneCountInString(frag) <= max {
				return frag
			}
		}
	}
	rs := []rune(s)
	if len(rs) <= max {
		return s
	}
	cut := max - 1
	for cut > 0 && cut < len(rs) && rs[cut] != ' ' {
		cut--
	}
	if cut < max/2 {
		cut = max - 1
	}
	return string(rs[:cut]) + "…"
}

func typeString(p map[string]interface{}) string {
	t, ok := p["type"].(string)
	if !ok {
		return "any"
	}
	return t
}

func buildExampleLine(toolName string, keys []string, props map[string]interface{}, req map[string]struct{}) string {
	var parts []string
	parts = append(parts, "/tool", toolName)
	for _, k := range keys {
		if _, isReq := req[k]; !isReq {
			continue
		}
		p, _ := props[k].(map[string]interface{})
		parts = append(parts, examplePair(k, p))
	}
	if len(parts) == 2 {
		for _, k := range keys {
			if _, isReq := req[k]; isReq {
				continue
			}
			p, _ := props[k].(map[string]interface{})
			parts = append(parts, examplePair(k, p))
			break
		}
	}
	return strings.Join(parts, " ")
}

func examplePair(key string, p map[string]interface{}) string {
	if enums, ok := p["enum"].([]interface{}); ok && len(enums) > 0 {
		if s, ok := enums[0].(string); ok {
			return fmt.Sprintf("%s=%s", key, s)
		}
	}
	switch typeString(p) {
	case "integer", "number":
		return fmt.Sprintf("%s=1", key)
	case "boolean":
		return fmt.Sprintf("%s=true", key)
	default:
		if key == "content" || key == "query" || key == "title" || key == "notes" {
			return fmt.Sprintf(`%s="…"`, key)
		}
		if key == "from" || key == "due_at" {
			return fmt.Sprintf("%s=2025-01-01", key)
		}
		if key == "to" {
			return "to=2025-02-01"
		}
		if key == "url" {
			return `url="https://…"`
		}
		return fmt.Sprintf(`%s="…"`, key)
	}
}