Changeset 0.11.0 (#62)

This commit is contained in:
2026-02-25 13:29:15 +00:00
parent d2ec55b16d
commit c9d8e9457e
56 changed files with 5664 additions and 91 deletions

View File

@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
@@ -39,11 +40,12 @@ type completionRequest struct {
type CompletionHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub // WebSocket hub for browser tool bridge
}
// NewCompletionHandler creates a new handler.
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores}
func NewCompletionHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *CompletionHandler {
return &CompletionHandler{vault: vault, stores: stores, hub: hub}
}
// ── Chat Completion ─────────────────────────
@@ -171,8 +173,9 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// Attach tool definitions if model supports tool calling and tools are available
if caps.ToolCalling && tools.HasTools() {
provReq.Tools = h.buildToolDefs()
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = h.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
}
// Determine streaming
@@ -189,19 +192,56 @@ func (h *CompletionHandler) Complete(c *gin.Context) {
}
// buildToolDefs converts registered tools to provider-format tool definitions.
func (h *CompletionHandler) buildToolDefs() []providers.ToolDef {
// If includeBrowser is true, also fetches tool schemas from enabled browser extensions.
func (h *CompletionHandler) buildToolDefs(ctx context.Context, userID string, includeBrowser bool) []providers.ToolDef {
allTools := tools.AllDefinitions()
defs := make([]providers.ToolDef, len(allTools))
for i, t := range allTools {
defs[i] = providers.ToolDef{
defs := make([]providers.ToolDef, 0, len(allTools))
for _, t := range allTools {
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
// Append browser-defined tool schemas from extensions
if includeBrowser {
exts, err := h.stores.Extensions.ListForUser(ctx, userID)
if err != nil {
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
return defs
}
for _, ext := range exts {
if ext.Tier != "browser" {
continue
}
var manifest struct {
Tools []struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
Tier string `json:"tier"`
} `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
continue
}
for _, t := range manifest.Tools {
defs = append(defs, providers.ToolDef{
Type: "function",
Function: providers.FunctionDef{
Name: t.Name,
Description: t.Description,
Parameters: t.Parameters,
},
})
}
}
}
return defs
}
@@ -216,7 +256,7 @@ func (h *CompletionHandler) streamCompletion(
req providers.CompletionRequest,
channelID, userID, model, configID, providerScope string,
) {
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID)
result := streamWithToolLoop(c, provider, cfg, &req, model, userID, channelID, h.hub)
// Persist assistant response
if result.Content != "" {

View File

@@ -0,0 +1,346 @@
package handlers
import (
"database/sql"
"encoding/json"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ExtensionHandler serves extension management endpoints.
type ExtensionHandler struct {
stores store.Stores
}
func NewExtensionHandler(stores store.Stores) *ExtensionHandler {
return &ExtensionHandler{stores: stores}
}
// ── User endpoints ──────────────────────────────
// ListUserExtensions returns all enabled extensions for the current user,
// with user-specific settings and enabled state merged in.
// GET /api/v1/extensions
func (h *ExtensionHandler) ListUserExtensions(c *gin.Context) {
userID := c.GetString("user_id")
tier := c.Query("tier") // optional filter
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
if tier != "" {
filtered := make([]models.UserExtension, 0)
for _, e := range exts {
if e.Tier == tier {
filtered = append(filtered, e)
}
}
exts = filtered
}
c.JSON(200, gin.H{"data": exts})
}
// UpdateUserExtensionSettings saves per-user settings for an extension.
// POST /api/v1/extensions/:id/settings
func (h *ExtensionHandler) UpdateUserExtensionSettings(c *gin.Context) {
userID := c.GetString("user_id")
extID := c.Param("id")
// Verify extension exists
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), extID)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
// System extensions can't be disabled by users
var body struct {
Settings json.RawMessage `json:"settings"`
IsEnabled *bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
enabled := true
if body.IsEnabled != nil {
if ext.IsSystem && !*body.IsEnabled {
c.JSON(403, gin.H{"error": "system extensions cannot be disabled"})
return
}
enabled = *body.IsEnabled
}
settings := json.RawMessage("{}")
if body.Settings != nil {
settings = body.Settings
}
eus := &models.ExtensionUserSettings{
ExtensionID: extID,
UserID: userID,
Settings: settings,
IsEnabled: enabled,
}
if err := h.stores.Extensions.SetUserSettings(c.Request.Context(), eus); err != nil {
c.JSON(500, gin.H{"error": "failed to save settings"})
return
}
c.JSON(200, gin.H{"ok": true})
}
// ── Admin endpoints ─────────────────────────────
// AdminListExtensions returns all extensions (enabled and disabled).
// GET /api/v1/admin/extensions
func (h *ExtensionHandler) AdminListExtensions(c *gin.Context) {
exts, err := h.stores.Extensions.ListAll(c.Request.Context())
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
c.JSON(200, gin.H{"data": exts})
}
// AdminInstallExtension installs an extension from a manifest.
// POST /api/v1/admin/extensions
func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
userID := c.GetString("user_id")
var body struct {
ExtID string `json:"ext_id" binding:"required"`
Name string `json:"name" binding:"required"`
Version string `json:"version"`
Tier string `json:"tier"`
Description string `json:"description"`
Author string `json:"author"`
Manifest json.RawMessage `json:"manifest"`
IsSystem bool `json:"is_system"`
IsEnabled bool `json:"is_enabled"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request: " + err.Error()})
return
}
// Defaults
if body.Version == "" {
body.Version = "0.0.0"
}
if body.Tier == "" {
body.Tier = models.ExtTierBrowser
}
if body.Manifest == nil {
body.Manifest = json.RawMessage("{}")
}
// Check for duplicate ext_id
existing, err := h.stores.Extensions.GetByExtID(c.Request.Context(), body.ExtID)
if err != nil && err != sql.ErrNoRows {
c.JSON(500, gin.H{"error": "failed to check existing extension"})
return
}
if existing != nil {
c.JSON(409, gin.H{"error": "extension with ext_id '" + body.ExtID + "' already installed"})
return
}
ext := &models.Extension{
ExtID: body.ExtID,
Name: body.Name,
Version: body.Version,
Tier: body.Tier,
Description: body.Description,
Author: body.Author,
Manifest: body.Manifest,
IsSystem: body.IsSystem,
IsEnabled: body.IsEnabled,
Scope: models.ScopeGlobal,
InstalledBy: &userID,
}
if err := h.stores.Extensions.Create(c.Request.Context(), ext); err != nil {
c.JSON(500, gin.H{"error": "failed to install extension"})
return
}
c.JSON(201, gin.H{"data": ext})
}
// AdminUpdateExtension updates an extension's config.
// PUT /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUpdateExtension(c *gin.Context) {
id := c.Param("id")
ext, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
var body struct {
Name *string `json:"name"`
Description *string `json:"description"`
IsSystem *bool `json:"is_system"`
IsEnabled *bool `json:"is_enabled"`
Manifest *json.RawMessage `json:"manifest"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(400, gin.H{"error": "invalid request body"})
return
}
if body.Name != nil {
ext.Name = *body.Name
}
if body.Description != nil {
ext.Description = *body.Description
}
if body.IsSystem != nil {
ext.IsSystem = *body.IsSystem
}
if body.IsEnabled != nil {
ext.IsEnabled = *body.IsEnabled
}
if body.Manifest != nil {
ext.Manifest = *body.Manifest
}
if err := h.stores.Extensions.Update(c.Request.Context(), id, ext); err != nil {
c.JSON(500, gin.H{"error": "failed to update extension"})
return
}
c.JSON(200, gin.H{"data": ext})
}
// ServeExtensionAsset serves browser extension JS files.
// GET /api/v1/extensions/:id/assets/*path
//
// For the MVP, scripts are stored inline in manifest._script.
// The :id param is the ext_id (e.g. "mermaid-renderer"), not the UUID.
func (h *ExtensionHandler) ServeExtensionAsset(c *gin.Context) {
extID := c.Param("id")
// path := c.Param("path") // reserved for future multi-file support
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err != nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if !ext.IsEnabled {
c.JSON(404, gin.H{"error": "extension not enabled"})
return
}
// Extract inline script from manifest
var manifest map[string]json.RawMessage
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
c.JSON(500, gin.H{"error": "invalid manifest"})
return
}
scriptRaw, ok := manifest["_script"]
if !ok {
c.JSON(404, gin.H{"error": "no script in manifest"})
return
}
// _script is a JSON string — unquote it
var script string
if err := json.Unmarshal(scriptRaw, &script); err != nil {
c.JSON(500, gin.H{"error": "invalid script in manifest"})
return
}
c.Header("Content-Type", "application/javascript; charset=utf-8")
c.Header("Cache-Control", "public, max-age=3600")
c.String(200, script)
}
// GetExtensionManifest returns the manifest for a specific extension.
// GET /api/v1/extensions/:id/manifest
func (h *ExtensionHandler) GetExtensionManifest(c *gin.Context) {
extID := c.Param("id")
ext, err := h.stores.Extensions.GetByExtID(c.Request.Context(), extID)
if err != nil {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
c.JSON(200, gin.H{"data": ext.Manifest})
}
// ListBrowserToolSchemas returns tool schemas from all enabled browser extensions.
// Used by the completion handler to include browser tools in LLM requests.
// GET /api/v1/extensions/tools
func (h *ExtensionHandler) ListBrowserToolSchemas(c *gin.Context) {
userID := c.GetString("user_id")
exts, err := h.stores.Extensions.ListForUser(c.Request.Context(), userID)
if err != nil {
c.JSON(500, gin.H{"error": "failed to list extensions"})
return
}
var toolSchemas []json.RawMessage
for _, ext := range exts {
if ext.Tier != "browser" {
continue
}
var manifest struct {
Tools []json.RawMessage `json:"tools"`
}
if err := json.Unmarshal(ext.Manifest, &manifest); err != nil {
continue
}
toolSchemas = append(toolSchemas, manifest.Tools...)
}
if toolSchemas == nil {
toolSchemas = make([]json.RawMessage, 0)
}
c.JSON(200, gin.H{"data": toolSchemas})
}
// AdminUninstallExtension removes an extension.
// DELETE /api/v1/admin/extensions/:id
func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
id := c.Param("id")
_, err := h.stores.Extensions.GetByID(c.Request.Context(), id)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "extension not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": "failed to fetch extension"})
return
}
if err := h.stores.Extensions.Delete(c.Request.Context(), id); err != nil {
c.JSON(500, gin.H{"error": "failed to uninstall extension"})
return
}
c.JSON(200, gin.H{"ok": true})
}

View File

@@ -134,7 +134,7 @@ func setupHarness(t *testing.T) *testHarness {
protected.POST("/channels", channels.CreateChannel)
// Completions
completions := NewCompletionHandler(nil, stores)
completions := NewCompletionHandler(nil, stores, nil)
protected.POST("/chat/completions", completions.Complete)
// Admin routes

View File

@@ -13,6 +13,7 @@ import (
capspkg "git.gobha.me/xcaliber/chat-switchboard/capabilities"
"git.gobha.me/xcaliber/chat-switchboard/crypto"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
@@ -59,11 +60,12 @@ type cursorRequest struct {
type MessageHandler struct {
vault *crypto.KeyResolver
stores store.Stores
hub *events.Hub
}
// NewMessageHandler creates a new message handler.
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores}
func NewMessageHandler(vault *crypto.KeyResolver, stores store.Stores, hub *events.Hub) *MessageHandler {
return &MessageHandler{vault: vault, stores: stores, hub: hub}
}
// ── List Messages (flat, all branches) ──────
@@ -358,7 +360,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
// ── Resolve model + provider ──
comp := NewCompletionHandler(h.vault, h.stores)
comp := NewCompletionHandler(h.vault, h.stores, h.hub)
var presetSystemPrompt string
model := req.Model
@@ -449,13 +451,14 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
}
// Attach tool definitions (same as normal completion)
if caps.ToolCalling && tools.HasTools() {
provReq.Tools = comp.buildToolDefs()
hasBrowserTools := h.hub != nil && h.hub.IsConnected(userID)
if caps.ToolCalling && (tools.HasTools() || hasBrowserTools) {
provReq.Tools = comp.buildToolDefs(c.Request.Context(), userID, hasBrowserTools)
}
// ── Stream the response (shared loop handles tools, reasoning, SSE) ──
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID)
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, userID, channelID, h.hub)
// Persist as sibling (regen) with tool activity
if result.Content != "" {

View File

@@ -0,0 +1,178 @@
package handlers
import (
"context"
"encoding/json"
"log"
"os"
"path/filepath"
"git.gobha.me/xcaliber/chat-switchboard/models"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// builtinManifest is the on-disk manifest.json shape.
type builtinManifest struct {
ID string `json:"id"`
Name string `json:"name"`
Version string `json:"version"`
Tier string `json:"tier"`
Author string `json:"author"`
Description string `json:"description"`
Permissions json.RawMessage `json:"permissions"`
Tools json.RawMessage `json:"tools"`
Surfaces json.RawMessage `json:"surfaces"`
Settings json.RawMessage `json:"settings"`
}
// SeedBuiltinExtensions scans a directory for builtin extension bundles
// (each subdirectory contains manifest.json + script.js) and upserts them
// into the extensions table as system extensions.
//
// Layout:
//
// extensions/builtin/
// mermaid-renderer/
// manifest.json
// script.js
//
// Idempotent: skips extensions whose version matches what's already installed,
// updates those with a newer version, creates new ones.
func SeedBuiltinExtensions(stores store.Stores, extensionsDir string) {
if stores.Extensions == nil {
return
}
entries, err := os.ReadDir(extensionsDir)
if err != nil {
if os.IsNotExist(err) {
log.Printf(" 📦 No builtin extensions directory at %s (skipping)", extensionsDir)
return
}
log.Printf("⚠ Failed to read extensions directory %s: %v", extensionsDir, err)
return
}
ctx := context.Background()
seeded, updated, skipped := 0, 0, 0
for _, entry := range entries {
if !entry.IsDir() {
continue
}
extDir := filepath.Join(extensionsDir, entry.Name())
manifestPath := filepath.Join(extDir, "manifest.json")
scriptPath := filepath.Join(extDir, "script.js")
// Read manifest
manifestData, err := os.ReadFile(manifestPath)
if err != nil {
log.Printf("⚠ Skipping %s: no manifest.json: %v", entry.Name(), err)
continue
}
var manifest builtinManifest
if err := json.Unmarshal(manifestData, &manifest); err != nil {
log.Printf("⚠ Skipping %s: invalid manifest.json: %v", entry.Name(), err)
continue
}
if manifest.ID == "" {
log.Printf("⚠ Skipping %s: manifest missing 'id'", entry.Name())
continue
}
// Read script (optional — some extensions might be manifest-only)
var script string
if scriptData, err := os.ReadFile(scriptPath); err == nil {
script = string(scriptData)
}
// Build the full manifest JSONB with _script inlined
fullManifest := buildFullManifest(manifestData, script)
// Check if already installed
existing, err := stores.Extensions.GetByExtID(ctx, manifest.ID)
if err == nil {
// Already exists — check version
if existing.Version == manifest.Version {
skipped++
continue
}
// Version changed — update
existing.Name = manifest.Name
existing.Version = manifest.Version
existing.Description = manifest.Description
existing.Author = manifest.Author
existing.Manifest = fullManifest
existing.IsSystem = true
existing.IsEnabled = true
if err := stores.Extensions.Update(ctx, existing.ID, existing); err != nil {
log.Printf("⚠ Failed to update builtin extension %s: %v", manifest.ID, err)
continue
}
updated++
continue
}
// New extension — create
tier := manifest.Tier
if tier == "" {
tier = models.ExtTierBrowser
}
ext := &models.Extension{
ExtID: manifest.ID,
Name: manifest.Name,
Version: manifest.Version,
Tier: tier,
Description: manifest.Description,
Author: manifest.Author,
Manifest: fullManifest,
IsSystem: true,
IsEnabled: true,
Scope: "global",
}
if err := stores.Extensions.Create(ctx, ext); err != nil {
log.Printf("⚠ Failed to seed builtin extension %s: %v", manifest.ID, err)
continue
}
seeded++
}
if seeded+updated > 0 {
log.Printf(" 📦 Builtin extensions: %d seeded, %d updated, %d unchanged", seeded, updated, skipped)
} else if skipped > 0 {
log.Printf(" 📦 Builtin extensions: %d up to date", skipped)
}
}
// buildFullManifest merges the raw manifest JSON with an inline _script field.
func buildFullManifest(rawManifest []byte, script string) json.RawMessage {
if script == "" {
return json.RawMessage(rawManifest)
}
// Parse manifest as generic map, add _script, re-serialize
var m map[string]json.RawMessage
if err := json.Unmarshal(rawManifest, &m); err != nil {
// Fallback: just return raw manifest
return json.RawMessage(rawManifest)
}
scriptJSON, err := json.Marshal(script)
if err != nil {
return json.RawMessage(rawManifest)
}
m["_script"] = json.RawMessage(scriptJSON)
result, err := json.Marshal(m)
if err != nil {
return json.RawMessage(rawManifest)
}
return json.RawMessage(result)
}

View File

@@ -1,13 +1,17 @@
package handlers
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"fmt"
"log"
"net/http"
"time"
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -40,6 +44,7 @@ func streamWithToolLoop(
cfg providers.ProviderConfig,
req *providers.CompletionRequest,
model, userID, channelID string,
hub *events.Hub,
) streamResult {
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
@@ -171,8 +176,25 @@ func streamWithToolLoop(
Arguments: tc.Function.Arguments,
}
log.Printf("🔧 Executing tool: %s (call %s)", call.Name, call.ID)
toolResult := tools.ExecuteCall(c.Request.Context(), execCtx, call)
var toolResult tools.ToolResult
// Check if tool is registered locally (server-side)
if tools.Get(call.Name) != nil {
log.Printf("🔧 Executing tool (server): %s (call %s)", call.Name, call.ID)
toolResult = tools.ExecuteCall(c.Request.Context(), execCtx, call)
} else if hub != nil && hub.IsConnected(userID) {
// Route to browser via WebSocket bridge
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
toolResult = executeBrowserTool(hub, userID, call)
} else {
log.Printf("⚠️ Unknown tool: %s (call %s)", call.Name, call.ID)
toolResult = tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"unknown tool or browser not connected"}`,
IsError: true,
}
}
// Notify client about tool result
resultJSON, _ := json.Marshal(map[string]interface{}{
@@ -214,3 +236,68 @@ func streamWithToolLoop(
return result
}
const browserToolTimeout = 30 * time.Second
// executeBrowserTool sends a tool call to the user's browser via WebSocket
// and blocks until a result is returned or the timeout elapses.
func executeBrowserTool(hub *events.Hub, userID string, call tools.ToolCall) tools.ToolResult {
b := make([]byte, 16)
rand.Read(b)
callID := hex.EncodeToString(b)
// Publish tool.call event to the user's browser
payload, _ := json.Marshal(map[string]interface{}{
"call_id": callID,
"tool": call.Name,
"arguments": json.RawMessage(call.Arguments),
})
hub.SendToUser(userID, events.Event{
Label: "tool.call." + callID,
Payload: payload,
Ts: time.Now().UnixMilli(),
})
// Block until browser sends tool.result.{callID} or timeout
event, ok := hub.GetBus().WaitFor("tool.result."+callID, browserToolTimeout)
if !ok {
log.Printf("⚠️ Browser tool %s timed out after %v (call %s)", call.Name, browserToolTimeout, callID)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"browser tool timed out (30s) — browser may be disconnected"}`,
IsError: true,
}
}
// Parse result from the browser event payload
var result struct {
CallID string `json:"call_id"`
Result string `json:"result"`
Error string `json:"error"`
}
if err := json.Unmarshal(event.Payload, &result); err != nil {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"invalid result from browser tool"}`,
IsError: true,
}
}
if result.Error != "" {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Error,
IsError: true,
}
}
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: result.Result,
}
}