Changeset 0.29.2 (#197)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-17 22:31:34 +00:00
committed by xcaliber
parent d4de84f3f1
commit 115004a3ab
35 changed files with 2285 additions and 48 deletions

View File

@@ -25,9 +25,12 @@ import (
"time"
"github.com/gin-gonic/gin"
"go.starlark.net/starlark"
"git.gobha.me/xcaliber/chat-switchboard/events"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
)
@@ -79,6 +82,9 @@ type LoopConfig struct {
ConfigID string
Budget LoopBudget
Streaming bool // true = StreamCompletion, false = ChatCompletion
// v0.29.2: extension tool dispatch (nil = no extension tools)
Runner *sandbox.Runner
ExtTools map[string]*store.PackageRegistration // toolName → package
}
// ── Sink Interface ─────────────────────────────
@@ -398,6 +404,12 @@ func executeToolCall(ctx context.Context, lcfg LoopConfig, call tools.ToolCall)
return result
}
// v0.29.2: Starlark extension tool
if pkg, ok := lcfg.ExtTools[call.Name]; ok && lcfg.Runner != nil {
log.Printf("🔧 Executing tool (extension): %s (call %s, pkg %s)", call.Name, call.ID, pkg.ID)
return executeExtensionTool(ctx, lcfg, pkg, call)
}
// Browser bridge (only available when hub is connected)
if lcfg.Hub != nil && lcfg.Hub.IsConnected(lcfg.ExecCtx.UserID) {
log.Printf("🔧 Executing tool (browser): %s (call %s)", call.Name, call.ID)
@@ -414,6 +426,132 @@ func executeToolCall(ctx context.Context, lcfg LoopConfig, call tools.ToolCall)
}
}
// executeExtensionTool calls a starlark extension's on_tool_call entry point
// and serializes the return value to a JSON string for the tool result.
func executeExtensionTool(ctx context.Context, lcfg LoopConfig, pkg *store.PackageRegistration, call tools.ToolCall) tools.ToolResult {
// Parse JSON arguments into a Starlark dict.
var rawArgs map[string]interface{}
if call.Arguments != "" {
if err := json.Unmarshal([]byte(call.Arguments), &rawArgs); err != nil {
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: `{"error":"invalid tool arguments JSON"}`,
IsError: true,
}
}
}
// Build the call dict passed to on_tool_call(call).
callDict := starlark.NewDict(3)
_ = callDict.SetKey(starlark.String("tool_name"), starlark.String(call.Name))
_ = callDict.SetKey(starlark.String("tool_call_id"), starlark.String(call.ID))
_ = callDict.SetKey(starlark.String("arguments"), jsonToStarlark(rawArgs))
rc := &sandbox.RunContext{
UserID: lcfg.ExecCtx.UserID,
ChannelID: lcfg.ExecCtx.ChannelID,
}
val, output, err := lcfg.Runner.CallEntryPoint(ctx, pkg, "on_tool_call",
starlark.Tuple{callDict}, nil, rc)
if output != "" {
log.Printf(" 🔧 ext tool %s print: %s", pkg.ID, output)
}
if err != nil {
log.Printf("⚠️ ext tool %s on_tool_call error: %v", pkg.ID, err)
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: fmt.Sprintf(`{"error":%q}`, err.Error()),
IsError: true,
}
}
// Serialize the Starlark return value to JSON.
content, jsonErr := json.Marshal(starlarkValueToGo(val))
if jsonErr != nil {
content = []byte(fmt.Sprintf(`{"error":%q}`, jsonErr.Error()))
}
return tools.ToolResult{
ToolCallID: call.ID,
Name: call.Name,
Content: string(content),
}
}
// jsonToStarlark recursively converts a decoded-JSON value (map/slice/scalar)
// to its Starlark equivalent.
func jsonToStarlark(v interface{}) starlark.Value {
if v == nil {
return starlark.None
}
switch val := v.(type) {
case map[string]interface{}:
d := starlark.NewDict(len(val))
for k, v := range val {
_ = d.SetKey(starlark.String(k), jsonToStarlark(v))
}
return d
case []interface{}:
elems := make([]starlark.Value, len(val))
for i, v := range val {
elems[i] = jsonToStarlark(v)
}
return starlark.NewList(elems)
case string:
return starlark.String(val)
case float64:
if val == float64(int64(val)) {
return starlark.MakeInt64(int64(val))
}
return starlark.Float(val)
case bool:
return starlark.Bool(val)
default:
return starlark.String(fmt.Sprintf("%v", val))
}
}
// starlarkValueToGo recursively converts a Starlark value to a Go value
// suitable for json.Marshal.
func starlarkValueToGo(v starlark.Value) interface{} {
if v == nil || v == starlark.None {
return nil
}
switch val := v.(type) {
case starlark.String:
return string(val)
case starlark.Int:
i64, ok := val.Int64()
if ok {
return i64
}
return val.String()
case starlark.Float:
return float64(val)
case starlark.Bool:
return bool(val)
case *starlark.Dict:
m := make(map[string]interface{}, val.Len())
for _, item := range val.Items() {
k, ok := starlark.AsString(item[0])
if !ok {
k = item[0].String()
}
m[k] = starlarkValueToGo(item[1])
}
return m
case *starlark.List:
list := make([]interface{}, val.Len())
for i := 0; i < val.Len(); i++ {
list[i] = starlarkValueToGo(val.Index(i))
}
return list
default:
return v.String()
}
}
// emitWorkspaceEvent sends workspace.file.changed when a write tool succeeds.
func emitWorkspaceEvent(lcfg LoopConfig, call tools.ToolCall, result tools.ToolResult) {
if lcfg.Hub == nil || result.IsError || lcfg.ExecCtx.WorkspaceID == "" {