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:
@@ -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
|
||||
|
||||
215
server/handlers/ext_db_schema.go
Normal file
215
server/handlers/ext_db_schema.go
Normal 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)
|
||||
}
|
||||
350
server/handlers/ext_db_schema_test.go
Normal file
350
server/handlers/ext_db_schema_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
166
server/handlers/ext_tools_test.go
Normal file
166
server/handlers/ext_tools_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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"})
|
||||
|
||||
@@ -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 != "" {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 == "" {
|
||||
|
||||
Reference in New Issue
Block a user