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,6 +25,7 @@ import (
"git.gobha.me/xcaliber/chat-switchboard/notifications"
"git.gobha.me/xcaliber/chat-switchboard/providers"
"git.gobha.me/xcaliber/chat-switchboard/routing"
"git.gobha.me/xcaliber/chat-switchboard/sandbox"
"git.gobha.me/xcaliber/chat-switchboard/storage"
"git.gobha.me/xcaliber/chat-switchboard/store"
"git.gobha.me/xcaliber/chat-switchboard/tools"
@@ -57,6 +58,7 @@ type CompletionHandler struct {
healthStore HealthStatusQuerier // health status queries for routing (v0.22.2, nil = disabled)
router *routing.Evaluator // routing policy evaluator (v0.22.2, nil = disabled)
filterChain *filters.Chain // pre-completion filter chain (v0.29.0, nil = disabled)
runner *sandbox.Runner // v0.29.2: Starlark extension tool dispatch (nil = disabled)
}
// HealthRecorder is the interface for recording provider call outcomes.
@@ -101,6 +103,16 @@ func (h *CompletionHandler) SetFilterChain(fc *filters.Chain) {
h.filterChain = fc
}
// SetRunner attaches the Starlark sandbox runner for extension tool dispatch (v0.29.2).
func (h *CompletionHandler) SetRunner(r *sandbox.Runner) {
h.runner = r
}
// buildExtToolMap returns the extension tool map for the current user.
func (h *CompletionHandler) buildExtToolMap(ctx context.Context, userID string) map[string]*store.PackageRegistration {
return BuildExtToolMap(ctx, h.stores, userID)
}
// evaluateRouting applies routing policies to select the best provider config
// for this request. Returns the winning config details and a routing decision
// for observability. If routing is disabled or no policies match, returns
@@ -846,7 +858,8 @@ func (h *CompletionHandler) multiModelStream(
}
// Stream this model's response using the shared streaming core
result := streamModelResponse(c, provider, providerCfg, &provReq, model, providerID, target.DisplayName, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health)
multiExtTools := h.buildExtToolMap(c.Request.Context(), userID)
result := streamModelResponse(c, provider, providerCfg, &provReq, model, providerID, target.DisplayName, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health, h.runner, multiExtTools)
// Persist assistant message with model attribution
if result.Content != "" {
@@ -955,7 +968,8 @@ func (h *CompletionHandler) streamCompletion(
hooks.PreRequest(cfg, &req)
}
result := streamWithToolLoop(c, provider, cfg, &req, model, providerID, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health)
extTools := h.buildExtToolMap(c.Request.Context(), userID)
result := streamWithToolLoop(c, provider, cfg, &req, model, providerID, userID, channelID, personaID, workspaceID, configID, teamID, h.hub, h.health, h.runner, extTools)
// Persist assistant response
if result.Content != "" {
@@ -995,6 +1009,7 @@ func (h *CompletionHandler) syncCompletion(
hooks.PreRequest(cfg, &req)
}
extTools := h.buildExtToolMap(c.Request.Context(), userID)
result := CoreToolLoop(c.Request.Context(), LoopConfig{
Provider: provider,
Cfg: cfg,
@@ -1013,6 +1028,8 @@ func (h *CompletionHandler) syncCompletion(
ConfigID: configID,
Budget: LoopBudget{},
Streaming: false,
Runner: h.runner,
ExtTools: extTools,
}, accumSink{})
// Provider error

View File

@@ -0,0 +1,215 @@
package handlers
// ext_db_schema.go — v0.29.2
//
// DDL generation and lifecycle management for extension-owned database tables.
// Tables are namespaced as ext_{pkg_slug}_{logical_name} and tracked in the
// ext_data_tables catalog. CreateExtTables is called on install;
// DropExtTables is called on uninstall.
import (
"context"
"database/sql"
"fmt"
"log"
"regexp"
"strings"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// validSchemaIdentifier matches safe SQL identifiers for table and column names.
var validSchemaIdentifier = regexp.MustCompile(`^[a-z][a-z0-9_]{0,62}$`)
// TableDef describes a single extension-owned table as declared in a manifest.
type TableDef struct {
Columns map[string]string // colName → manifest type ("text","int","real","bool","timestamp")
Indexes [][]string // each inner slice is an ordered list of column names for one index
}
// ParseDBTables extracts the "db_tables" key from a package manifest map.
// Returns the map of logicalName→TableDef and ok=true when the key is present
// and produces at least one valid table definition.
func ParseDBTables(manifest map[string]any) (map[string]TableDef, bool) {
raw, ok := manifest["db_tables"]
if !ok {
return nil, false
}
tablesRaw, ok := raw.(map[string]any)
if !ok || len(tablesRaw) == 0 {
return nil, false
}
tables := make(map[string]TableDef, len(tablesRaw))
for name, defRaw := range tablesRaw {
if !validSchemaIdentifier.MatchString(name) {
log.Printf("[ext-db] skipping invalid table name %q", name)
continue
}
defMap, ok := defRaw.(map[string]any)
if !ok {
continue
}
td := TableDef{Columns: map[string]string{}}
// Parse columns map.
if colsRaw, ok := defMap["columns"].(map[string]any); ok {
for col, typ := range colsRaw {
if !validSchemaIdentifier.MatchString(col) {
log.Printf("[ext-db] skipping invalid column name %q in table %q", col, name)
continue
}
if t, ok := typ.(string); ok {
td.Columns[col] = t
}
}
}
// Parse indexes array.
if idxsRaw, ok := defMap["indexes"].([]any); ok {
for _, idxRaw := range idxsRaw {
if idxArr, ok := idxRaw.([]any); ok {
var cols []string
for _, c := range idxArr {
if s, ok := c.(string); ok && validSchemaIdentifier.MatchString(s) {
cols = append(cols, s)
}
}
if len(cols) > 0 {
td.Indexes = append(td.Indexes, cols)
}
}
}
}
tables[name] = td
}
if len(tables) == 0 {
return nil, false
}
return tables, true
}
// mapColType maps a manifest type string to a SQL column type for the given dialect.
func mapColType(typStr string, isPostgres bool) string {
switch strings.ToLower(typStr) {
case "int", "integer":
return "INTEGER"
case "real", "float":
return "REAL"
case "bool", "boolean":
if isPostgres {
return "BOOLEAN"
}
return "INTEGER"
case "timestamp":
if isPostgres {
return "TIMESTAMPTZ"
}
return "TEXT"
default: // "text" and anything unrecognized
return "TEXT"
}
}
// extPhysicalTable builds the namespaced physical table name for a package's logical table.
// e.g. packageID="my-ext", logicalName="logs" → "ext_my_ext_logs"
func extPhysicalTable(packageID, logicalName string) string {
slug := strings.ReplaceAll(packageID, "-", "_")
return "ext_" + slug + "_" + logicalName
}
// createdAtColDef returns the dialect-correct DDL fragment for the created_at column.
func createdAtColDef(isPostgres bool) string {
if isPostgres {
return "created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()"
}
return "created_at TEXT NOT NULL DEFAULT (datetime('now'))"
}
// CreateExtTables generates dialect-correct DDL and executes it for all tables
// in the provided map. Each successfully created table is registered in the
// ext_data_tables catalog via stores.ExtData.
//
// Errors from individual table creation are returned immediately (fail-fast).
// Index creation failures are logged but non-fatal.
// Catalog registration failures are logged but non-fatal.
func CreateExtTables(
ctx context.Context,
db *sql.DB,
isPostgres bool,
stores store.Stores,
packageID string,
tables map[string]TableDef,
) error {
if db == nil {
return nil
}
for logicalName, td := range tables {
physical := extPhysicalTable(packageID, logicalName)
// Build column list: id first, user columns, created_at last.
cols := []string{"id TEXT PRIMARY KEY"}
for col, typ := range td.Columns {
cols = append(cols, col+" "+mapColType(typ, isPostgres))
}
cols = append(cols, createdAtColDef(isPostgres))
ddl := fmt.Sprintf("CREATE TABLE IF NOT EXISTS %s (\n\t%s\n)",
physical, strings.Join(cols, ",\n\t"))
if _, err := db.ExecContext(ctx, ddl); err != nil {
return fmt.Errorf("create ext table %s: %w", physical, err)
}
// Create declared indexes.
for i, idxCols := range td.Indexes {
idxName := fmt.Sprintf("idx_%s_%d", physical, i)
idxDDL := fmt.Sprintf("CREATE INDEX IF NOT EXISTS %s ON %s (%s)",
idxName, physical, strings.Join(idxCols, ", "))
if _, err := db.ExecContext(ctx, idxDDL); err != nil {
log.Printf("[ext-db] index create failed (%s): %v", idxName, err)
}
}
// Record logical name in catalog.
if stores.ExtData != nil {
if err := stores.ExtData.RegisterTable(ctx, packageID, logicalName); err != nil {
log.Printf("[ext-db] catalog register failed (%s/%s): %v", packageID, logicalName, err)
}
}
log.Printf("[ext-db] created table %s for package %s", physical, packageID)
}
return nil
}
// DropExtTables drops all physical tables registered for a package and removes
// their entries from the ext_data_tables catalog.
//
// Table drop errors are logged but non-fatal; the catalog is cleared regardless.
func DropExtTables(
ctx context.Context,
db *sql.DB,
stores store.Stores,
packageID string,
) error {
if db == nil || stores.ExtData == nil {
return nil
}
tables, err := stores.ExtData.ListTables(ctx, packageID)
if err != nil {
return fmt.Errorf("list ext tables for %s: %w", packageID, err)
}
for _, logicalName := range tables {
physical := extPhysicalTable(packageID, logicalName)
if _, err := db.ExecContext(ctx, "DROP TABLE IF EXISTS "+physical); err != nil {
log.Printf("[ext-db] drop table %s failed: %v", physical, err)
} else {
log.Printf("[ext-db] dropped table %s for package %s", physical, packageID)
}
}
return stores.ExtData.DeletePackageTables(ctx, packageID)
}

View File

@@ -0,0 +1,350 @@
package handlers
import (
"context"
"database/sql"
"strings"
"testing"
_ "modernc.org/sqlite"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
// ── mock ExtDataStore ────────────────────────────────────────────────────────
// memExtDataStore is an in-memory ExtDataStore for testing.
type memExtDataStore struct {
tables map[string][]string // packageID → []logicalName
}
func newMemExtDataStore() *memExtDataStore {
return &memExtDataStore{tables: map[string][]string{}}
}
func (m *memExtDataStore) RegisterTable(_ context.Context, packageID, tableName string) error {
m.tables[packageID] = append(m.tables[packageID], tableName)
return nil
}
func (m *memExtDataStore) ListTables(_ context.Context, packageID string) ([]string, error) {
return m.tables[packageID], nil
}
func (m *memExtDataStore) DeletePackageTables(_ context.Context, packageID string) error {
delete(m.tables, packageID)
return nil
}
// ── helpers ──────────────────────────────────────────────────────────────────
func newSchemaTestDB(t *testing.T) *sql.DB {
t.Helper()
db, err := sql.Open("sqlite", ":memory:")
if err != nil {
t.Fatalf("open db: %v", err)
}
t.Cleanup(func() { db.Close() })
return db
}
func storesWithExtData(m *memExtDataStore) store.Stores {
return store.Stores{ExtData: m}
}
// ── ParseDBTables ────────────────────────────────────────────────────────────
func TestParseDBTables_Valid(t *testing.T) {
manifest := map[string]any{
"db_tables": map[string]any{
"logs": map[string]any{
"columns": map[string]any{
"message": "text",
"count": "int",
},
"indexes": []any{[]any{"message"}},
},
},
}
tables, ok := ParseDBTables(manifest)
if !ok {
t.Fatal("expected ok=true")
}
if _, has := tables["logs"]; !has {
t.Fatal("expected 'logs' in parsed tables")
}
if tables["logs"].Columns["message"] != "text" {
t.Errorf("expected column message=text, got %q", tables["logs"].Columns["message"])
}
if len(tables["logs"].Indexes) != 1 {
t.Errorf("expected 1 index, got %d", len(tables["logs"].Indexes))
}
}
func TestParseDBTables_Missing(t *testing.T) {
_, ok := ParseDBTables(map[string]any{"title": "hi"})
if ok {
t.Fatal("expected ok=false when db_tables absent")
}
}
func TestParseDBTables_InvalidTableName(t *testing.T) {
manifest := map[string]any{
"db_tables": map[string]any{
"bad name": map[string]any{},
"good_name": map[string]any{"columns": map[string]any{"x": "text"}},
},
}
tables, ok := ParseDBTables(manifest)
if !ok {
t.Fatal("expected ok=true (good_name is valid)")
}
if _, has := tables["bad name"]; has {
t.Error("invalid table name should be skipped")
}
if _, has := tables["good_name"]; !has {
t.Error("valid table name should be present")
}
}
func TestParseDBTables_InvalidColumnName(t *testing.T) {
manifest := map[string]any{
"db_tables": map[string]any{
"events": map[string]any{
"columns": map[string]any{
"bad col": "text",
"ok_col": "text",
},
},
},
}
tables, ok := ParseDBTables(manifest)
if !ok {
t.Fatal("expected ok=true")
}
if _, has := tables["events"].Columns["bad col"]; has {
t.Error("invalid column name should be skipped")
}
if _, has := tables["events"].Columns["ok_col"]; !has {
t.Error("valid column name should be present")
}
}
// ── mapColType ───────────────────────────────────────────────────────────────
func TestMapColType_SQLite(t *testing.T) {
cases := []struct{ in, want string }{
{"text", "TEXT"},
{"int", "INTEGER"},
{"integer", "INTEGER"},
{"real", "REAL"},
{"float", "REAL"},
{"bool", "INTEGER"},
{"boolean", "INTEGER"},
{"timestamp", "TEXT"},
{"unknown", "TEXT"},
}
for _, c := range cases {
got := mapColType(c.in, false)
if got != c.want {
t.Errorf("mapColType(%q, sqlite) = %q, want %q", c.in, got, c.want)
}
}
}
func TestMapColType_Postgres(t *testing.T) {
if mapColType("bool", true) != "BOOLEAN" {
t.Error("expected BOOLEAN for bool in postgres")
}
if mapColType("timestamp", true) != "TIMESTAMPTZ" {
t.Error("expected TIMESTAMPTZ for timestamp in postgres")
}
if mapColType("int", true) != "INTEGER" {
t.Error("expected INTEGER for int in postgres")
}
}
// ── extPhysicalTable ─────────────────────────────────────────────────────────
func TestExtPhysicalTable(t *testing.T) {
got := extPhysicalTable("my-ext", "logs")
if got != "ext_my_ext_logs" {
t.Errorf("got %q, want ext_my_ext_logs", got)
}
got = extPhysicalTable("simple", "events")
if got != "ext_simple_events" {
t.Errorf("got %q, want ext_simple_events", got)
}
}
// ── CreateExtTables ──────────────────────────────────────────────────────────
func TestCreateExtTables_CreatesTable(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{
"logs": {
Columns: map[string]string{"message": "text", "count": "int"},
},
}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "test-pkg", tables); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
// Verify table exists by inserting a row.
_, err := db.ExecContext(ctx,
`INSERT INTO ext_test_pkg_logs (id, message, count) VALUES ('1', 'hello', 42)`)
if err != nil {
t.Errorf("table not created or wrong schema: %v", err)
}
}
func TestCreateExtTables_WithIndexes(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{
"events": {
Columns: map[string]string{"user_id": "text", "kind": "text"},
Indexes: [][]string{{"user_id"}, {"user_id", "kind"}},
},
}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "idx-pkg", tables); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
// Verify indexes exist via sqlite_master.
rows, err := db.QueryContext(ctx,
`SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='ext_idx_pkg_events'`)
if err != nil {
t.Fatalf("query indexes: %v", err)
}
defer rows.Close()
var idxNames []string
for rows.Next() {
var name string
rows.Scan(&name)
idxNames = append(idxNames, name)
}
if len(idxNames) < 2 {
t.Errorf("expected at least 2 indexes, got %d: %v", len(idxNames), idxNames)
}
}
func TestCreateExtTables_CatalogRegistration(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{
"logs": {Columns: map[string]string{"msg": "text"}},
"errors": {Columns: map[string]string{"detail": "text"}},
}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "cat-pkg", tables); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
registered := m.tables["cat-pkg"]
if len(registered) != 2 {
t.Errorf("expected 2 registered tables, got %d: %v", len(registered), registered)
}
}
func TestCreateExtTables_NilDBIsNoop(t *testing.T) {
m := newMemExtDataStore()
ctx := context.Background()
// Should not panic or error.
err := CreateExtTables(ctx, nil, false, storesWithExtData(m), "pkg", map[string]TableDef{
"t": {Columns: map[string]string{"x": "text"}},
})
if err != nil {
t.Errorf("expected nil error for nil db, got: %v", err)
}
}
// ── DropExtTables ─────────────────────────────────────────────────────────────
func TestDropExtTables_DropsAndClearsCatalog(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
// Create table first.
tables := map[string]TableDef{
"items": {Columns: map[string]string{"name": "text"}},
}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "drop-pkg", tables); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
// Drop.
if err := DropExtTables(ctx, db, storesWithExtData(m), "drop-pkg"); err != nil {
t.Fatalf("DropExtTables: %v", err)
}
// Table should be gone.
var count int
db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name='ext_drop_pkg_items'`,
).Scan(&count)
if count != 0 {
t.Error("expected table to be dropped, still present")
}
// Catalog should be empty.
if len(m.tables["drop-pkg"]) != 0 {
t.Errorf("expected empty catalog, got: %v", m.tables["drop-pkg"])
}
}
func TestDropExtTables_MultipleTables(t *testing.T) {
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{
"alpha": {Columns: map[string]string{"v": "text"}},
"beta": {Columns: map[string]string{"v": "text"}},
}
CreateExtTables(ctx, db, false, storesWithExtData(m), "multi-pkg", tables)
DropExtTables(ctx, db, storesWithExtData(m), "multi-pkg")
for _, name := range []string{"ext_multi_pkg_alpha", "ext_multi_pkg_beta"} {
var count int
db.QueryRowContext(ctx,
`SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name=?`, name,
).Scan(&count)
if count != 0 {
t.Errorf("expected %s to be dropped", name)
}
}
}
func TestCreateExtTables_DDLContainsAutoColumns(t *testing.T) {
// Verify that the DDL-generated table has 'id' and 'created_at' columns
// even when no columns are declared in the manifest.
db := newSchemaTestDB(t)
m := newMemExtDataStore()
ctx := context.Background()
tables := map[string]TableDef{"empty": {Columns: map[string]string{}}}
if err := CreateExtTables(ctx, db, false, storesWithExtData(m), "auto-pkg", tables); err != nil {
t.Fatalf("CreateExtTables: %v", err)
}
// Insert with just id — should work (created_at has default).
_, err := db.ExecContext(ctx, `INSERT INTO ext_auto_pkg_empty (id) VALUES ('x')`)
if err != nil {
t.Errorf("expected auto-columns (id, created_at) to exist: %v", err)
}
// Verify created_at was populated.
var ca string
db.QueryRowContext(ctx, `SELECT created_at FROM ext_auto_pkg_empty WHERE id='x'`).Scan(&ca)
if strings.TrimSpace(ca) == "" {
t.Error("expected created_at to be populated by default")
}
}

View File

@@ -0,0 +1,166 @@
package handlers
import (
"encoding/json"
"testing"
"go.starlark.net/starlark"
)
// ── jsonToStarlark ────────────────────────────────────────────────────────────
func TestJSONToStarlark_String(t *testing.T) {
v := jsonToStarlark("hello")
s, ok := v.(starlark.String)
if !ok || string(s) != "hello" {
t.Errorf("expected starlark.String(hello), got %v (%T)", v, v)
}
}
func TestJSONToStarlark_Int(t *testing.T) {
v := jsonToStarlark(float64(42))
i, ok := v.(starlark.Int)
if !ok {
t.Fatalf("expected starlark.Int, got %T", v)
}
n, _ := i.Int64()
if n != 42 {
t.Errorf("expected 42, got %d", n)
}
}
func TestJSONToStarlark_Float(t *testing.T) {
v := jsonToStarlark(3.14)
if _, ok := v.(starlark.Float); !ok {
t.Errorf("expected starlark.Float, got %T", v)
}
}
func TestJSONToStarlark_Bool(t *testing.T) {
if jsonToStarlark(true) != starlark.True {
t.Error("expected starlark.True")
}
if jsonToStarlark(false) != starlark.False {
t.Error("expected starlark.False")
}
}
func TestJSONToStarlark_Nil(t *testing.T) {
if jsonToStarlark(nil) != starlark.None {
t.Error("expected starlark.None for nil")
}
}
func TestJSONToStarlark_Dict(t *testing.T) {
v := jsonToStarlark(map[string]interface{}{"key": "val"})
d, ok := v.(*starlark.Dict)
if !ok {
t.Fatalf("expected *starlark.Dict, got %T", v)
}
got, found, _ := d.Get(starlark.String("key"))
if !found || string(got.(starlark.String)) != "val" {
t.Errorf("expected key=val in dict, got %v", got)
}
}
func TestJSONToStarlark_List(t *testing.T) {
v := jsonToStarlark([]interface{}{"a", "b"})
l, ok := v.(*starlark.List)
if !ok {
t.Fatalf("expected *starlark.List, got %T", v)
}
if l.Len() != 2 {
t.Errorf("expected 2 elements, got %d", l.Len())
}
}
// ── starlarkValueToGo ─────────────────────────────────────────────────────────
func TestStarlarkValueToGo_String(t *testing.T) {
v := starlarkValueToGo(starlark.String("hi"))
if v != "hi" {
t.Errorf("expected 'hi', got %v", v)
}
}
func TestStarlarkValueToGo_Int(t *testing.T) {
v := starlarkValueToGo(starlark.MakeInt(7))
if v != int64(7) {
t.Errorf("expected int64(7), got %v (%T)", v, v)
}
}
func TestStarlarkValueToGo_Bool(t *testing.T) {
if starlarkValueToGo(starlark.True) != true {
t.Error("expected true")
}
}
func TestStarlarkValueToGo_None(t *testing.T) {
if starlarkValueToGo(starlark.None) != nil {
t.Error("expected nil for starlark.None")
}
}
func TestStarlarkValueToGo_Dict(t *testing.T) {
d := starlark.NewDict(1)
_ = d.SetKey(starlark.String("x"), starlark.MakeInt(1))
v := starlarkValueToGo(d)
m, ok := v.(map[string]interface{})
if !ok {
t.Fatalf("expected map, got %T", v)
}
if m["x"] != int64(1) {
t.Errorf("expected x=1, got %v", m["x"])
}
}
func TestStarlarkValueToGo_List(t *testing.T) {
l := starlark.NewList([]starlark.Value{starlark.String("a"), starlark.String("b")})
v := starlarkValueToGo(l)
arr, ok := v.([]interface{})
if !ok {
t.Fatalf("expected []interface{}, got %T", v)
}
if len(arr) != 2 || arr[0] != "a" {
t.Errorf("unexpected list: %v", arr)
}
}
// ── RoundTrip: JSON → Starlark → Go → JSON ───────────────────────────────────
func TestRoundTrip_JSONToStarlarkToJSON(t *testing.T) {
// Simulate what executeExtensionTool does: parse args, pass as Starlark, convert result back.
argsJSON := `{"query": "hello", "limit": 10, "active": true}`
var raw map[string]interface{}
if err := json.Unmarshal([]byte(argsJSON), &raw); err != nil {
t.Fatalf("unmarshal: %v", err)
}
sv := jsonToStarlark(raw)
got := starlarkValueToGo(sv)
out, err := json.Marshal(got)
if err != nil {
t.Fatalf("marshal: %v", err)
}
// Verify key fields present
var result map[string]interface{}
if err := json.Unmarshal(out, &result); err != nil {
t.Fatalf("unmarshal result: %v", err)
}
if result["query"] != "hello" {
t.Errorf("expected query=hello, got %v", result["query"])
}
if result["active"] != true {
t.Errorf("expected active=true, got %v", result["active"])
}
}
// ── BuildExtToolMap ───────────────────────────────────────────────────────────
func TestBuildExtToolMap_Empty(t *testing.T) {
// nil Packages store → returns nil (no panic)
result := BuildExtToolMap(nil, storesWithExtData(newMemExtDataStore()), "user1")
if result != nil && len(result) != 0 {
t.Errorf("expected empty map for stores without Packages, got %v", result)
}
}

View File

@@ -210,6 +210,13 @@ func (h *ExtensionHandler) AdminInstallExtension(c *gin.Context) {
// If permissions are declared, package moves to pending_review.
SyncManifestPermissions(c, h.stores, pkg.ID, manifestMap)
// v0.29.2: Create namespaced DB tables declared in the manifest.
if tables, ok := ParseDBTables(manifestMap); ok {
if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkg.ID, tables); err != nil {
log.Printf("[ext-db] schema create failed for %s: %v", pkg.ID, err)
}
}
c.JSON(201, gin.H{"data": pkg})
}
@@ -370,6 +377,11 @@ func (h *ExtensionHandler) AdminUninstallExtension(c *gin.Context) {
return
}
// v0.29.2: Drop namespaced DB tables before removing the package record.
if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
}
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
if err == sql.ErrNoRows {
c.JSON(400, gin.H{"error": "core packages cannot be deleted"})

View File

@@ -478,7 +478,7 @@ func (h *MessageHandler) Regenerate(c *gin.Context) {
hooks.PreRequest(providerCfg, &provReq)
}
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, msgTeamID, h.hub, comp.health)
result := streamWithToolLoop(c, provider, providerCfg, &provReq, model, providerID, userID, channelID, personaID, workspaceID, configID, msgTeamID, h.hub, comp.health, comp.runner, comp.buildExtToolMap(c.Request.Context(), userID))
// Persist as sibling (regen) with tool activity
if result.Content != "" {

View File

@@ -13,6 +13,7 @@ import (
"github.com/gin-gonic/gin"
"git.gobha.me/xcaliber/chat-switchboard/database"
"git.gobha.me/xcaliber/chat-switchboard/store"
)
@@ -117,6 +118,11 @@ func (h *PackageHandler) DeletePackage(c *gin.Context) {
return
}
// v0.29.2: Drop namespaced DB tables before removing the package record.
if err := DropExtTables(c.Request.Context(), database.DB, h.stores, id); err != nil {
log.Printf("[ext-db] schema drop failed for %s: %v", id, err)
}
if err := h.stores.Packages.Delete(c.Request.Context(), id); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to delete package"})
return
@@ -358,6 +364,13 @@ func (h *PackageHandler) InstallPackage(c *gin.Context) {
log.Printf("[packages] Failed to update package metadata for %s: %v", pkgID, err)
}
// v0.29.2: Create namespaced DB tables declared in the manifest.
if tables, ok := ParseDBTables(manifest); ok {
if err := CreateExtTables(c.Request.Context(), database.DB, database.IsPostgres(), h.stores, pkgID, tables); err != nil {
log.Printf("[ext-db] schema create failed for %s: %v", pkgID, err)
}
}
c.JSON(http.StatusOK, gin.H{
"id": pkgID,
"title": title,

View File

@@ -180,15 +180,25 @@ func BuildToolDefs(
})
}
// Append browser-defined tool schemas from extensions
if includeBrowser && stores.Packages != nil {
// Append browser-defined and starlark-tier tool schemas from extensions.
// v0.29.2: starlark tools are always included (not gated on browser connection).
if stores.Packages != nil {
pkgs, err := stores.Packages.ListForUser(ctx, userID)
if err != nil {
log.Printf("⚠️ Failed to load extensions for tools: %v", err)
return defs
}
for _, pkg := range pkgs {
if pkg.Tier != "browser" {
// Browser tools only when a browser client is connected.
if pkg.Tier == "browser" && !includeBrowser {
continue
}
// Only browser and starlark tiers expose tools this way.
if pkg.Tier != "browser" && pkg.Tier != "starlark" {
continue
}
// Starlark packages must be active to expose tools.
if pkg.Tier == "starlark" && pkg.Status != "active" {
continue
}
var manifest struct {
@@ -196,7 +206,6 @@ func BuildToolDefs(
Name string `json:"name"`
Description string `json:"description"`
Parameters json.RawMessage `json:"parameters"`
Tier string `json:"tier"`
} `json:"tools"`
}
if err := json.Unmarshal(marshalManifest(pkg.Manifest), &manifest); err != nil {
@@ -243,6 +252,39 @@ func BuildToolDefs(
return defs
}
// BuildExtToolMap returns a map of toolName → PackageRegistration for all
// active starlark-tier extensions that declare tools in their manifest.
// Used by the tool loop to dispatch matched calls to on_tool_call.
func BuildExtToolMap(ctx context.Context, stores store.Stores, userID string) map[string]*store.PackageRegistration {
if stores.Packages == nil {
return nil
}
pkgs, err := stores.Packages.ListForUser(ctx, userID)
if err != nil {
return nil
}
result := make(map[string]*store.PackageRegistration)
for i, pkg := range pkgs {
if pkg.Tier != "starlark" || pkg.Status != "active" {
continue
}
var manifest struct {
Tools []struct {
Name string `json:"name"`
} `json:"tools"`
}
if json.Unmarshal(marshalManifest(pkg.Manifest), &manifest) != nil {
continue
}
for _, t := range manifest.Tools {
if t.Name != "" {
result[t.Name] = &pkgs[i].PackageRegistration
}
}
}
return result
}
// FilterToolDefsByGrants applies an additional allowlist to tool defs.
// Used by the task scheduler to enforce task-level tool grants on top
// of persona-level grants. Passing nil or empty grants returns defs unchanged.

View File

@@ -13,6 +13,8 @@ import (
"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"
)
@@ -49,6 +51,8 @@ func streamWithToolLoop(
model, providerType, userID, channelID, personaID, workspaceID, configID, teamID string,
hub *events.Hub,
health HealthRecorder,
runner *sandbox.Runner,
extTools map[string]*store.PackageRegistration,
) LoopResult {
// Set SSE headers
c.Header("Content-Type", "text/event-stream")
@@ -77,6 +81,8 @@ func streamWithToolLoop(
ConfigID: configID,
Budget: LoopBudget{},
Streaming: true,
Runner: runner,
ExtTools: extTools,
}, sink)
}
@@ -93,6 +99,8 @@ func streamModelResponse(
model, providerType, displayName, userID, channelID, personaID, workspaceID, configID, teamID string,
hub *events.Hub,
health HealthRecorder,
runner *sandbox.Runner,
extTools map[string]*store.PackageRegistration,
) LoopResult {
sink := newSSEModelSink(c, model, displayName)
@@ -114,6 +122,8 @@ func streamModelResponse(
ConfigID: configID,
Budget: LoopBudget{},
Streaming: true,
Runner: runner,
ExtTools: extTools,
}, sink)
}

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 == "" {