diff --git a/.gitignore b/.gitignore index 2de4836e8..a7eec197b 100644 --- a/.gitignore +++ b/.gitignore @@ -12,4 +12,6 @@ coverage.txt coverage.html .DS_Store build +swarms.db +picoclaw_memory/ diff --git a/go.mod b/go.mod index d07ae18c4..c27eceb49 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,23 @@ require ( github.com/caarlos0/env/v11 v11.3.1 github.com/chzyer/readline v1.5.1 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 + github.com/google/uuid v1.6.0 github.com/gorilla/websocket v1.5.3 github.com/larksuite/oapi-sdk-go/v3 v3.5.3 + github.com/philippgille/chromem-go v0.7.0 + modernc.org/sqlite v1.45.0 ) require ( + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect golang.org/x/crypto v0.48.0 // indirect + golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect golang.org/x/sys v0.41.0 // indirect + modernc.org/libc v1.67.6 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index acbad2154..b8feb7ba0 100644 --- a/go.sum +++ b/go.sum @@ -8,18 +8,34 @@ github.com/chzyer/readline v1.5.1 h1:upd/6fQk4src78LMRzh5vItIt361/o4uq553V8B5sGI github.com/chzyer/readline v1.5.1/go.mod h1:Eh+b79XXUwfKfcPLepksvw2tcLE/Ct21YObkaSkeBlk= github.com/chzyer/test v1.0.0 h1:p3BQDXSxOhOG0P9z6/hGnII4LGiEPOYBhs8asl/fC04= github.com/chzyer/test v1.0.0/go.mod h1:2JlltgoNkt4TW/z9V/IzDdFaMTM2JPIi26O1pF38GC8= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 h1:wG8n/XJQ07TmjbITcGiUaOtXxdrINDz1b0J1w0SzqDc= github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1/go.mod h1:A2S0CWkNylc2phvKXWBBdD3K0iGnDBGbzRpISP2zBl8= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= github.com/larksuite/oapi-sdk-go/v3 v3.5.3 h1:xvf8Dv29kBXC5/DNDCLhHkAFW8l/0LlQJimO5Zn+JUk= github.com/larksuite/oapi-sdk-go/v3 v3.5.3/go.mod h1:ZEplY+kwuIrj/nqw5uSCINNATcH3KdxSN7y+UxYY5fI= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/philippgille/chromem-go v0.7.0 h1:4jfvfyKymjKNfGxBUhHUcj1kp7B17NL/I1P+vGh1RvY= +github.com/philippgille/chromem-go v0.7.0/go.mod h1:hTd+wGEm/fFPQl7ilfCwQXkgEUxceYh86iIdoKMolPo= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -28,8 +44,12 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY= +golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70= golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.29.0 h1:HV8lRxZC4l2cr3Zq1LvtOsi/ThTgWnUk/y64QSs8GwA= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= @@ -38,11 +58,14 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug= +golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20220310020820-b874c991c1a5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= @@ -52,7 +75,37 @@ golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGm golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.38.0 h1:Hx2Xv8hISq8Lm16jvBZ2VQf+RLmbd7wVUsALibYI/IQ= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= +modernc.org/ccgo/v4 v4.30.1/go.mod h1:bIOeI1JL54Utlxn+LwrFyjCx2n2RDiYEaJVSrgdrRfM= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.1 h1:k8T3gkXWY9sEiytKhcgyiZ2L0DTyCQ/nvX+LoCljoRE= +modernc.org/gc/v3 v3.1.1/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= +modernc.org/libc v1.67.6 h1:eVOQvpModVLKOdT+LvBPjdQqfrZq+pC39BygcT+E7OI= +modernc.org/libc v1.67.6/go.mod h1:JAhxUVlolfYDErnwiqaLvUqc8nfb2r6S6slAgZOnaiE= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs= +modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 222d46adb..b3fa4faf0 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -19,6 +19,7 @@ import ( "github.com/sipeed/picoclaw/pkg/config" "github.com/sipeed/picoclaw/pkg/providers" "github.com/sipeed/picoclaw/pkg/session" + "github.com/sipeed/picoclaw/pkg/swarm" "github.com/sipeed/picoclaw/pkg/tools" ) @@ -32,6 +33,7 @@ type AgentLoop struct { sessions *session.SessionManager contextBuilder *ContextBuilder tools *tools.ToolRegistry + swarm *swarm.Service // Swarm Service running bool summarizing sync.Map } @@ -50,6 +52,12 @@ func NewAgentLoop(cfg *config.Config, bus *bus.MessageBus, provider providers.LL toolsRegistry.Register(tools.NewWebSearchTool(braveAPIKey, cfg.Tools.Web.Search.MaxResults)) toolsRegistry.Register(tools.NewWebFetchTool(50000)) + // Initialize Swarm Service + swarmSvc, err := swarm.NewService(filepath.Join(workspace, "swarms.db"), provider, toolsRegistry, cfg.Swarm, cfg.Agents.Defaults.Model) + if err != nil { + fmt.Printf("Warning: Failed to init swarm service: %v\n", err) + } + sessionsManager := session.NewSessionManager(filepath.Join(filepath.Dir(cfg.WorkspacePath()), "sessions")) return &AgentLoop{ @@ -62,6 +70,7 @@ func NewAgentLoop(cfg *config.Config, bus *bus.MessageBus, provider providers.LL sessions: sessionsManager, contextBuilder: NewContextBuilder(workspace), tools: toolsRegistry, + swarm: swarmSvc, running: false, summarizing: sync.Map{}, } @@ -70,6 +79,19 @@ func NewAgentLoop(cfg *config.Config, bus *bus.MessageBus, provider providers.LL func (al *AgentLoop) Run(ctx context.Context) error { al.running = true + // Relay Swarm Messages to Bus + if al.swarm != nil { + go func() { + for msg := range al.swarm.Outbound { + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: "cli", // Default to CLI for now, ideally dynamic + ChatID: "direct", + Content: msg, + }) + } + }() + } + for al.running { select { case <-ctx.Done(): @@ -80,6 +102,17 @@ func (al *AgentLoop) Run(ctx context.Context) error { continue } + // Intercept /swarm commands + if al.swarm != nil && len(msg.Content) > 6 && msg.Content[:7] == "/swarm " { + response := al.swarm.HandleCommand(ctx, msg.Content) + al.bus.PublishOutbound(bus.OutboundMessage{ + Channel: msg.Channel, + ChatID: msg.ChatID, + Content: response, + }) + continue + } + response, err := al.processMessage(ctx, msg) if err != nil { response = fmt.Sprintf("Error processing message: %v", err) @@ -103,6 +136,11 @@ func (al *AgentLoop) Stop() { } func (al *AgentLoop) ProcessDirect(ctx context.Context, content, sessionKey string) (string, error) { + // Intercept /swarm commands + if al.swarm != nil && len(content) > 6 && content[:7] == "/swarm " { + return al.swarm.HandleCommand(ctx, content), nil + } + msg := bus.InboundMessage{ Channel: "cli", SenderID: "user", diff --git a/pkg/config/config.go b/pkg/config/config.go index 1d48fe32a..3a9fea9f5 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -7,14 +7,16 @@ import ( "sync" "github.com/caarlos0/env/v11" + swarmCfg "github.com/sipeed/picoclaw/pkg/swarm/config" ) type Config struct { - Agents AgentsConfig `json:"agents"` - Channels ChannelsConfig `json:"channels"` - Providers ProvidersConfig `json:"providers"` - Gateway GatewayConfig `json:"gateway"` - Tools ToolsConfig `json:"tools"` + Agents AgentsConfig `json:"agents"` + Channels ChannelsConfig `json:"channels"` + Providers ProvidersConfig `json:"providers"` + Gateway GatewayConfig `json:"gateway"` + Tools ToolsConfig `json:"tools"` + Swarm swarmCfg.SwarmConfig `json:"swarm"` mu sync.RWMutex } @@ -168,6 +170,7 @@ func DefaultConfig() *Config { }, }, }, + Swarm: swarmCfg.DefaultSwarmConfig(), } } diff --git a/pkg/providers/http_provider.go b/pkg/providers/http_provider.go index 0def92319..508ba044f 100644 --- a/pkg/providers/http_provider.go +++ b/pkg/providers/http_provider.go @@ -161,6 +161,68 @@ func (p *HTTPProvider) parseResponse(body []byte) (*LLMResponse, error) { }, nil } +// Embed implements the embedding functionality compatible with OpenAI API +func (p *HTTPProvider) Embed(ctx context.Context, text string) ([]float32, error) { + if p.apiBase == "" { + return nil, fmt.Errorf("API base not configured") + } + + // Default embedding model - configurable ideally + model := "text-embedding-3-small" + + requestBody := map[string]interface{}{ + "model": model, + "input": text, + } + + jsonData, err := json.Marshal(requestBody) + if err != nil { + return nil, fmt.Errorf("failed to marshal request: %w", err) + } + + req, err := http.NewRequestWithContext(ctx, "POST", p.apiBase+"/embeddings", bytes.NewReader(jsonData)) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + if p.apiKey != "" { + authHeader := "Bearer " + p.apiKey + req.Header.Set("Authorization", authHeader) + } + + resp, err := p.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("failed to send request: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("API error: %s", string(body)) + } + + var apiResponse struct { + Data []struct { + Embedding []float32 `json:"embedding"` + } `json:"data"` + } + + if err := json.Unmarshal(body, &apiResponse); err != nil { + return nil, fmt.Errorf("failed to unmarshal response: %w", err) + } + + if len(apiResponse.Data) == 0 { + return nil, fmt.Errorf("no embedding data returned") + } + + return apiResponse.Data[0].Embedding, nil +} + func (p *HTTPProvider) GetDefaultModel() string { return "" } @@ -242,4 +304,4 @@ func CreateProvider(cfg *config.Config) (LLMProvider, error) { } return NewHTTPProvider(apiKey, apiBase), nil -} +} \ No newline at end of file diff --git a/pkg/swarm/README.md b/pkg/swarm/README.md new file mode 100644 index 000000000..4a2fc3013 --- /dev/null +++ b/pkg/swarm/README.md @@ -0,0 +1,76 @@ +# PicoClaw Swarm Engine + +This package implements the **Swarm Agents** architecture: a lightweight, hierarchical, multi-agent system designed for the edge. + +For a detailed deep-dive into the architecture, workflow, and components, please read [SWARM_ARCHITECTURE.md](../../SWARM_ARCHITECTURE.md). + +## Features + +* **Hierarchical Swarms:** Manager nodes can delegate tasks to specialized workers (Researchers, Writers, etc.). +* **Vector Memory:** Integrated `Chromem-go` vector database for long-term semantic memory. +* **Resilient:** Built-in retry mechanisms, context pruning, and crash recovery via SQLite checkpoints. +* **Secure:** Configurable Role-Based Access Control (RBAC) for tools. +* **Lightweight:** Built on Go Goroutines (Actor Model) and Channels, efficient enough for small VPS or local devices. + +## Usage + +The Swarm Engine is integrated into the PicoClaw CLI. + +### Basic Commands + +```bash +# Spawn a new swarm to perform a task +/swarm spawn "Analyze the latest trends in Rust vs Go performance" + +# List active swarms +/swarm list + +# Check detailed status of a specific swarm (including node progress) +/swarm status + +# Visualize the swarm topology (Mermaid diagram) +/swarm viz + +# Stop a running swarm +/swarm stop +``` + +### Configuration + +You can configure the Swarm behavior, Roles, and Policies in your `config.json`. + +```json +{ + "swarm": { + "limits": { + "max_nodes": 10, + "global_timeout": 600000000000, + "max_iterations": 15 + }, + "roles": { + "Manager": { + "description": "Orchestrates tasks", + "system_prompt": "You are a Manager...", + "tools": ["delegate_task", "save_memory", "search_memory"] + } + }, + "policies": [ + { + "role": "Manager", + "allowed": ["*"] + } + ] + } +} +``` + +## Architecture Overview + +```mermaid +graph TD + Service --> Orchestrator + Orchestrator --> Manager[Manager Node] + Manager -->|Delegates| Worker[Worker Node] + Manager <-->|Memory| VectorDB[(Vector DB)] + Manager <-->|State| SQLite[(SQLite)] +``` \ No newline at end of file diff --git a/pkg/swarm/adapters/llm_adapter.go b/pkg/swarm/adapters/llm_adapter.go new file mode 100644 index 000000000..5a645186f --- /dev/null +++ b/pkg/swarm/adapters/llm_adapter.go @@ -0,0 +1,60 @@ +package adapters + +import ( + "context" + "encoding/json" + + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/swarm/core" +) + +type LLMAdapter struct { + provider providers.LLMProvider +} + +func NewLLMAdapter(provider providers.LLMProvider) *LLMAdapter { + return &LLMAdapter{provider: provider} +} + +func (a *LLMAdapter) Chat(ctx context.Context, messages []core.Message, tools []core.ToolDef, model string) (*core.LLMResponse, error) { + pMsgs := make([]providers.Message, len(messages)) + for i, m := range messages { + pMsgs[i] = providers.Message{Role: m.Role, Content: m.Content, ToolCallID: m.ToolCallID} + } + + pTools := make([]providers.ToolDefinition, len(tools)) + for i, t := range tools { + pTools[i] = providers.ToolDefinition{ + Type: "function", + Function: providers.ToolFunctionDefinition{ + Name: t.Name, Description: t.Description, Parameters: t.Parameters.(map[string]any), + }, + } + } + + resp, err := a.provider.Chat(ctx, pMsgs, pTools, model, nil) + if err != nil { + return nil, err + } + + res := &core.LLMResponse{ + Content: resp.Content, + Usage: core.TokenUsage{Input: resp.Usage.PromptTokens, Output: resp.Usage.CompletionTokens}, + } + + for _, tc := range resp.ToolCalls { + args, _ := json.Marshal(tc.Arguments) + res.ToolCalls = append(res.ToolCalls, core.ToolCall{ID: tc.ID, Name: tc.Name, Arguments: args}) + } + + return res, nil +} + +func (a *LLMAdapter) Embed(ctx context.Context, text string) ([]float32, error) { + if e, ok := a.provider.(interface { + Embed(context.Context, string) ([]float32, error) + }); ok { + return e.Embed(ctx, text) + } + return nil, nil +} diff --git a/pkg/swarm/bus/channel_bus.go b/pkg/swarm/bus/channel_bus.go new file mode 100644 index 000000000..fdefa0ed3 --- /dev/null +++ b/pkg/swarm/bus/channel_bus.go @@ -0,0 +1,44 @@ +package bus + +import ( + "sync" + "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/swarm/core" +) + +type ChannelBus struct { + subs map[string]map[string]func(core.Event) + mu sync.RWMutex +} + +func NewChannelBus() *ChannelBus { + return &ChannelBus{subs: make(map[string]map[string]func(core.Event))} +} + +func (b *ChannelBus) Publish(topic string, e core.Event) error { + b.mu.RLock() + defer b.mu.RUnlock() + if topics, ok := b.subs[topic]; ok { + for _, handler := range topics { + go handler(e) + } + } + return nil +} + +func (b *ChannelBus) Subscribe(topic string, handler func(core.Event)) (core.Subscription, error) { + b.mu.Lock() + defer b.mu.Unlock() + if b.subs[topic] == nil { b.subs[topic] = make(map[string]func(core.Event)) } + id := uuid.New().String() + b.subs[topic][id] = handler + return &sub{bus: b, topic: topic, id: id}, nil +} + +type sub struct { bus *ChannelBus; topic, id string } +func (s *sub) Unsubscribe() error { + s.bus.mu.Lock() + defer s.bus.mu.Unlock() + delete(s.bus.subs[s.topic], s.id) + return nil +} \ No newline at end of file diff --git a/pkg/swarm/config/config.go b/pkg/swarm/config/config.go new file mode 100644 index 000000000..ca2821873 --- /dev/null +++ b/pkg/swarm/config/config.go @@ -0,0 +1,95 @@ +package config + +import "time" + +type SwarmConfig struct { + Limits LimitsConfig `json:"limits"` + Resilience ResilienceConfig `json:"resilience"` + Memory MemoryConfig `json:"memory"` // Added memory config + Roles map[string]Role `json:"roles"` + Policies []Policy `json:"policies"` +} + +type MemoryConfig struct { + SharedKnowledge bool `json:"shared_knowledge"` // If true, search across all swarms +} + +type LimitsConfig struct { + MaxDepth int `json:"max_depth" env:"PICOCLAW_SWARM_MAX_DEPTH"` + MaxNodes int `json:"max_nodes" env:"PICOCLAW_SWARM_MAX_NODES"` + GlobalTimeout time.Duration `json:"global_timeout" env:"PICOCLAW_SWARM_GLOBAL_TIMEOUT"` + MaxRetries int `json:"max_retries" env:"PICOCLAW_SWARM_MAX_RETRIES"` + MaxIterations int `json:"max_iterations" env:"PICOCLAW_SWARM_MAX_ITERATIONS"` + PruningMsgKeep int `json:"pruning_msg_keep" env:"PICOCLAW_SWARM_PRUNING_MSG_KEEP"` +} + +type ResilienceConfig struct { + RetryBackoff time.Duration `json:"retry_backoff" env:"PICOCLAW_SWARM_RETRY_BACKOFF"` +} + +type Role struct { + Description string `json:"description"` + SystemPrompt string `json:"system_prompt"` + Tools []string `json:"tools"` + Model string `json:"model"` + MaxCost float64 `json:"max_cost"` +} + +type Policy struct { + Role string `json:"role"` + Allowed []string `json:"allowed"` + Denied []string `json:"denied"` +} + +func DefaultSwarmConfig() SwarmConfig { + return SwarmConfig{ + Limits: LimitsConfig{ + MaxDepth: 3, + MaxNodes: 10, + GlobalTimeout: 10 * time.Minute, + MaxRetries: 3, + MaxIterations: 10, + PruningMsgKeep: 6, // Keep last 6 messages when pruning + }, + Resilience: ResilienceConfig{ + RetryBackoff: 2 * time.Second, + }, + Memory: MemoryConfig{ + SharedKnowledge: true, // Default to enabled for "Hive Mind" effect + }, + Roles: map[string]Role{ + "Manager": { + Description: "Orchestrates and delegates tasks", + SystemPrompt: "You are the MANAGER of this swarm...", + Tools: []string{"delegate_task", "save_memory", "search_memory"}, + }, + "Researcher": { + Description: "Deep research and verification", + SystemPrompt: "You are a RESEARCHER...", + Tools: []string{"web_search", "web_fetch", "save_memory", "search_memory", "read_file"}, + }, + "Analyst": { + Description: "Analyze provided data", + SystemPrompt: "You are an ANALYST...", + Tools: []string{"web_search", "web_fetch", "read_file", "list_dir", "save_memory", "search_memory"}, + }, + "Writer": { + Description: "Write content to files", + SystemPrompt: "You are a WRITER...", + Tools: []string{"read_file", "write_file", "list_dir", "search_memory"}, + }, + "Critic": { + Description: "Red team and validate", + SystemPrompt: "You are a CRITIC...", + Tools: []string{"web_search", "read_file", "search_memory"}, + }, + }, + Policies: []Policy{ + {Role: "Manager", Allowed: []string{"*"}}, + {Role: "Researcher", Allowed: []string{"web_search", "web_fetch", "read_file", "save_memory", "search_memory"}, Denied: []string{"exec", "write_file"}}, + {Role: "Analyst", Allowed: []string{"web_search", "web_fetch", "read_file", "list_dir", "save_memory", "search_memory"}, Denied: []string{"write_file", "exec"}}, + {Role: "Writer", Allowed: []string{"read_file", "write_file", "list_dir", "search_memory"}, Denied: []string{"exec"}}, + {Role: "Critic", Allowed: []string{"web_search", "read_file", "search_memory"}}, + }, + } +} diff --git a/pkg/swarm/core/core.go b/pkg/swarm/core/core.go new file mode 100644 index 000000000..ac0dfbc41 --- /dev/null +++ b/pkg/swarm/core/core.go @@ -0,0 +1,128 @@ +package core + +import ( + "encoding/json" + "errors" + "time" +) + +// --- Common Errors --- +var ( + ErrSwarmNotFound = errors.New("swarm not found") + ErrNodeNotFound = errors.New("node not found") + ErrTaskTimeout = errors.New("task timeout") + ErrMailboxFull = errors.New("mailbox full") +) + +// --- ID Types --- +type SwarmID string +type NodeID string + +// --- Enums --- +type SwarmStatus string +const ( + SwarmStatusActive SwarmStatus = "active" + SwarmStatusPaused SwarmStatus = "paused" + SwarmStatusCompleted SwarmStatus = "completed" +) + +type NodeStatus string +const ( + NodeStatusPending NodeStatus = "pending" + NodeStatusRunning NodeStatus = "running" + NodeStatusCompleted NodeStatus = "completed" + NodeStatusFailed NodeStatus = "failed" +) + +// --- Core Structs --- +type Swarm struct { + ID SwarmID `json:"id"` + Goal string `json:"goal"` + Status SwarmStatus `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type Node struct { + ID NodeID `json:"id"` + SwarmID SwarmID `json:"swarm_id"` + ParentID NodeID `json:"parent_id"` + Role Role `json:"role"` + Task string `json:"task"` + Status NodeStatus `json:"status"` + Output string `json:"output"` + Stats NodeStats `json:"stats"` +} + +type Role struct { + Name string `json:"name"` + Description string `json:"description"` + SystemPrompt string `json:"system_prompt"` + Tools []string `json:"tools"` + Model string `json:"model"` +} + +type NodeStats struct { + Iterations int `json:"iterations"` + TokensInput int `json:"tokens_input"` + TokensOutput int `json:"tokens_output"` +} + +// --- Event Protocol --- +type EventType string +const ( + EventNodeThinking EventType = "node.thinking" + EventNodeCompleted EventType = "node.completed" + EventNodeFailed EventType = "node.failed" + EventSwarmSpawned EventType = "swarm.spawned" +) + +type Event struct { + Type EventType `json:"type"` + SwarmID SwarmID `json:"swarm_id"` + NodeID NodeID `json:"node_id"` + Payload map[string]any `json:"payload"` +} + +// --- LLM Types --- +type Message struct { + Role string `json:"role"` + Content string `json:"content"` + ToolCallID string `json:"tool_call_id,omitempty"` +} + +type ToolDef struct { + Name string `json:"name"` + Description string `json:"description"` + Parameters any `json:"parameters"` +} + +type LLMResponse struct { + Content string `json:"content"` + ToolCalls []ToolCall `json:"tool_calls"` + Usage TokenUsage `json:"usage"` +} + +type ToolCall struct { + ID string `json:"id"` + Name string `json:"name"` + Arguments json.RawMessage `json:"arguments"` +} + +type TokenUsage struct { + Input int `json:"input"` + Output int `json:"output"` +} + +// --- Memory Types --- +type Fact struct { + SwarmID SwarmID `json:"swarm_id"` + Content string `json:"content"` + Confidence float64 `json:"confidence"` + Source string `json:"source"` + Metadata map[string]any `json:"metadata"` +} + +type FactResult struct { + Content string `json:"content"` + Score float32 `json:"score"` +} diff --git a/pkg/swarm/core/interfaces.go b/pkg/swarm/core/interfaces.go new file mode 100644 index 000000000..6dae9c115 --- /dev/null +++ b/pkg/swarm/core/interfaces.go @@ -0,0 +1,37 @@ +package core + +import ( + "context" +) + +type SwarmStore interface { + CreateSwarm(ctx context.Context, swarm *Swarm) error + GetSwarm(ctx context.Context, id SwarmID) (*Swarm, error) + UpdateSwarm(ctx context.Context, swarm *Swarm) error + ListSwarms(ctx context.Context, status SwarmStatus) ([]*Swarm, error) + + CreateNode(ctx context.Context, node *Node) error + GetNode(ctx context.Context, id NodeID) (*Node, error) + UpdateNode(ctx context.Context, node *Node) error + GetSwarmNodes(ctx context.Context, swarmID SwarmID) ([]*Node, error) +} + +type SharedMemory interface { + SaveFact(ctx context.Context, fact Fact) error + SearchFacts(ctx context.Context, swarmID SwarmID, query string, limit int, global bool) ([]FactResult, error) + ClearMemory(ctx context.Context, swarmID SwarmID) error +} + +type EventBus interface { + Publish(topic string, event Event) error + Subscribe(topic string, handler func(Event)) (Subscription, error) +} + +type Subscription interface { + Unsubscribe() error +} + +type LLMClient interface { + Chat(ctx context.Context, messages []Message, tools []ToolDef, model string) (*LLMResponse, error) + Embed(ctx context.Context, text string) ([]float32, error) +} \ No newline at end of file diff --git a/pkg/swarm/core/policy.go b/pkg/swarm/core/policy.go new file mode 100644 index 000000000..51c6979cd --- /dev/null +++ b/pkg/swarm/core/policy.go @@ -0,0 +1,51 @@ +package core + +import "strings" + +// RolePolicy defines permissions for a role +type RolePolicy struct { + AllowedTools []string // Whitelist (if empty, nothing allowed unless wildcard) + DeniedTools []string // Blacklist (overrides whitelist) +} + +type PolicyChecker struct { + policies map[string]RolePolicy +} + +func NewPolicyChecker(policies map[string]RolePolicy) *PolicyChecker { + return &PolicyChecker{ + policies: policies, + } +} + +// CanUseTool checks if a role is allowed to use a specific tool +func (pc *PolicyChecker) CanUseTool(roleName, toolName string) bool { + policy, ok := pc.policies[roleName] + if !ok { + // Default restrictive policy for unknown roles + return false + } + + // 1. Check Denied List first (Safety First) + for _, denied := range policy.DeniedTools { + if matchWildcard(denied, toolName) { + return false + } + } + + // 2. Check Allowed List + for _, allowed := range policy.AllowedTools { + if matchWildcard(allowed, toolName) { + return true + } + } + + return false +} + +func matchWildcard(pattern, subject string) bool { + if pattern == "*" { + return true + } + return strings.EqualFold(pattern, subject) +} \ No newline at end of file diff --git a/pkg/swarm/memory/chromem_store.go b/pkg/swarm/memory/chromem_store.go new file mode 100644 index 000000000..e0609dcde --- /dev/null +++ b/pkg/swarm/memory/chromem_store.go @@ -0,0 +1,77 @@ +package memory + +import ( + "context" + "fmt" + "runtime" + + "github.com/philippgille/chromem-go" + "github.com/sipeed/picoclaw/pkg/swarm/core" +) + +type ChromemStore struct { + db *chromem.DB + llm core.LLMClient + collection *chromem.Collection +} + +func NewChromemStore(ctx context.Context, llm core.LLMClient) (*ChromemStore, error) { + db, err := chromem.NewPersistentDB("./picoclaw_memory", false) + if err != nil { + return nil, err + } + + embeddingFunc := func(ctx context.Context, text string) ([]float32, error) { + return llm.Embed(ctx, text) + } + + c, err := db.GetOrCreateCollection("swarm_facts", nil, embeddingFunc) + if err != nil { + return nil, err + } + + return &ChromemStore{db: db, llm: llm, collection: c}, nil +} + +func (s *ChromemStore) SaveFact(ctx context.Context, fact core.Fact) error { + meta := make(map[string]string) + for k, v := range fact.Metadata { + meta[k] = fmt.Sprintf("%v", v) + } + meta["swarm_id"] = string(fact.SwarmID) + meta["source"] = fact.Source + + doc := chromem.Document{ + ID: fmt.Sprintf("%s_%d", fact.SwarmID, fact.Confidence), + Content: fact.Content, + Metadata: meta, + } + + return s.collection.AddDocuments(ctx, []chromem.Document{doc}, runtime.NumCPU()) +} + +func (s *ChromemStore) SearchFacts(ctx context.Context, swarmID core.SwarmID, query string, limit int, global bool) ([]core.FactResult, error) { + var where map[string]string + if !global { + where = map[string]string{"swarm_id": string(swarmID)} + } + + docs, err := s.collection.Query(ctx, query, limit, where, nil) + if err != nil { + return nil, err + } + + var results []core.FactResult + for _, doc := range docs { + results = append(results, core.FactResult{ + Content: doc.Content, + Score: doc.Similarity, + }) + } + + return results, nil +} + +func (s *ChromemStore) ClearMemory(ctx context.Context, swarmID core.SwarmID) error { + return nil +} diff --git a/pkg/swarm/memory/sqlite_store.go b/pkg/swarm/memory/sqlite_store.go new file mode 100644 index 000000000..c50c059ee --- /dev/null +++ b/pkg/swarm/memory/sqlite_store.go @@ -0,0 +1,126 @@ +package memory + +import ( + "context" + "database/sql" + "encoding/json" + + "github.com/sipeed/picoclaw/pkg/swarm/core" + _ "modernc.org/sqlite" +) + +type SQLiteStore struct { + db *sql.DB +} + +func NewSQLiteStore(dbPath string) (*SQLiteStore, error) { + db, err := sql.Open("sqlite", dbPath) + if err != nil { return nil, err } + s := &SQLiteStore{db: db} + s.init() + return s, nil +} + +func (s *SQLiteStore) init() { + s.db.Exec(`CREATE TABLE IF NOT EXISTS swarms (id TEXT PRIMARY KEY, goal TEXT, status TEXT, created_at DATETIME);`) + s.db.Exec(`CREATE TABLE IF NOT EXISTS nodes (id TEXT PRIMARY KEY, swarm_id TEXT, parent_id TEXT, role JSON, task TEXT, status TEXT, output TEXT, stats JSON);`) + s.db.Exec(`CREATE TABLE IF NOT EXISTS facts (swarm_id TEXT, content TEXT, confidence REAL, source TEXT, metadata JSON);`) +} + +func (s *SQLiteStore) CreateSwarm(ctx context.Context, sw *core.Swarm) error { + _, err := s.db.ExecContext(ctx, "INSERT INTO swarms VALUES (?, ?, ?, ?)", sw.ID, sw.Goal, sw.Status, sw.CreatedAt) + return err +} + +func (s *SQLiteStore) GetSwarm(ctx context.Context, id core.SwarmID) (*core.Swarm, error) { + row := s.db.QueryRowContext(ctx, "SELECT id, goal, status, created_at FROM swarms WHERE id=?", id) + var sw core.Swarm + err := row.Scan(&sw.ID, &sw.Goal, &sw.Status, &sw.CreatedAt) + return &sw, err +} + +func (s *SQLiteStore) UpdateSwarm(ctx context.Context, sw *core.Swarm) error { + _, err := s.db.ExecContext(ctx, "UPDATE swarms SET status=? WHERE id=?", sw.Status, sw.ID) + return err +} + +func (s *SQLiteStore) ListSwarms(ctx context.Context, status core.SwarmStatus) ([]*core.Swarm, error) { + rows, _ := s.db.QueryContext(ctx, "SELECT id, goal, status, created_at FROM swarms WHERE status=?", status) + defer rows.Close() + var list []*core.Swarm + for rows.Next() { + var sw core.Swarm + rows.Scan(&sw.ID, &sw.Goal, &sw.Status, &sw.CreatedAt) + list = append(list, &sw) + } + return list, nil +} + +func (s *SQLiteStore) CreateNode(ctx context.Context, n *core.Node) error { + role, _ := json.Marshal(n.Role) + stats, _ := json.Marshal(n.Stats) + _, err := s.db.ExecContext(ctx, "INSERT INTO nodes VALUES (?, ?, ?, ?, ?, ?, ?, ?)", n.ID, n.SwarmID, n.ParentID, role, n.Task, n.Status, n.Output, stats) + return err +} + +func (s *SQLiteStore) GetNode(ctx context.Context, id core.NodeID) (*core.Node, error) { + row := s.db.QueryRowContext(ctx, "SELECT * FROM nodes WHERE id=?", id) + var n core.Node + var role, stats []byte + row.Scan(&n.ID, &n.SwarmID, &n.ParentID, &role, &n.Task, &n.Status, &n.Output, &stats) + json.Unmarshal(role, &n.Role) + json.Unmarshal(stats, &n.Stats) + return &n, nil +} + +func (s *SQLiteStore) UpdateNode(ctx context.Context, n *core.Node) error { + stats, _ := json.Marshal(n.Stats) + _, err := s.db.ExecContext(ctx, "UPDATE nodes SET status=?, output=?, stats=? WHERE id=?", n.Status, n.Output, stats, n.ID) + return err +} + +func (s *SQLiteStore) GetSwarmNodes(ctx context.Context, id core.SwarmID) ([]*core.Node, error) { + rows, _ := s.db.QueryContext(ctx, "SELECT * FROM nodes WHERE swarm_id=?", id) + defer rows.Close() + var list []*core.Node + for rows.Next() { + var n core.Node + var role, stats []byte + rows.Scan(&n.ID, &n.SwarmID, &n.ParentID, &role, &n.Task, &n.Status, &n.Output, &stats) + json.Unmarshal(role, &n.Role) + json.Unmarshal(stats, &n.Stats) + list = append(list, &n) + } + return list, nil +} + +func (s *SQLiteStore) SaveFact(ctx context.Context, f core.Fact) error { + meta, _ := json.Marshal(f.Metadata) + _, err := s.db.ExecContext(ctx, "INSERT INTO facts VALUES (?, ?, ?, ?, ?)", f.SwarmID, f.Content, f.Confidence, f.Source, meta) + return err +} + +func (s *SQLiteStore) SearchFacts(ctx context.Context, id core.SwarmID, q string, limit int, global bool) ([]core.FactResult, error) { + query := "SELECT content FROM facts WHERE swarm_id=? AND content LIKE ? LIMIT ?" + args := []any{id, "%" + q + "%", limit} + + if global { + query = "SELECT content FROM facts WHERE content LIKE ? LIMIT ?" + args = []any{"%" + q + "%", limit} + } + + rows, _ := s.db.QueryContext(ctx, query, args...) + defer rows.Close() + var res []core.FactResult + for rows.Next() { + var content string + rows.Scan(&content) + res = append(res, core.FactResult{Content: content, Score: 1.0}) + } + return res, nil +} + +func (s *SQLiteStore) ClearMemory(ctx context.Context, id core.SwarmID) error { + _, err := s.db.ExecContext(ctx, "DELETE FROM facts WHERE swarm_id=?", id) + return err +} \ No newline at end of file diff --git a/pkg/swarm/prompt/prompts.go b/pkg/swarm/prompt/prompts.go new file mode 100644 index 000000000..d6f65c577 --- /dev/null +++ b/pkg/swarm/prompt/prompts.go @@ -0,0 +1,69 @@ +package prompt + +import "fmt" + +const ( + // Base System Prompt for all nodes + SystemBase = `You are a specialized agent node within a Swarm Intelligence system. +Your goal is to complete your assigned TASK effectively and efficiently. + +SWARM CONTEXT: +- Swarm ID: %s +- Your Node ID: %s +- Your Role: %s + +INSTRUCTIONS: +1. FOCUS: Stick strictly to your assigned role. Do not halllucinate capabilities you don't have. +2. COLLABORATION: If you need information you can't get, ask for it clearly. +3. OUTPUT: Provide clear, structured reasoning. +4. TOOLS: Use available tools to gather facts. Do not guess. +` + + // Manager / Orchestrator Prompt + SystemManager = `You are the MANAGER of this swarm. +Your responsibilities: +- Break down the main goal into sub-tasks. +- Assign tasks to worker nodes (Researcher, Writer, Analyst). +- Synthesize results from workers into a final answer. +- Ensure the goal is met within constraints. + +Do not do the heavy lifting yourself if it can be delegated. +` + + // Researcher Prompt + SystemResearcher = `You are a RESEARCHER. +Your responsibilities: +- Search for accurate, up-to-date information. +- Verify facts from multiple sources. +- Cite your sources. +- Present raw data clearly for the Analyst. +` + + // Analyst Prompt + SystemAnalyst = `You are an ANALYST. +Your responsibilities: +- Analyze provided data/research. +- Find patterns, trends, and anomalies. +- Draw logical conclusions. +- Be objective and data-driven. +` +) + +// BuildSystemPrompt constructs the full system prompt for a node +func BuildSystemPrompt(swarmID, nodeID, roleName, customInstructions string) string { + base := fmt.Sprintf(SystemBase, swarmID, nodeID, roleName) + + roleSpecific := "" + switch roleName { + case "Manager": + roleSpecific = SystemManager + case "Researcher": + roleSpecific = SystemResearcher + case "Analyst": + roleSpecific = SystemAnalyst + default: + roleSpecific = "You are a generic worker node. Execute the task to the best of your ability." + } + + return fmt.Sprintf("%s\n\nROLE INSTRUCTIONS:\n%s\n\nSPECIFIC INSTRUCTIONS:\n%s", base, roleSpecific, customInstructions) +} \ No newline at end of file diff --git a/pkg/swarm/runtime/delegation.go b/pkg/swarm/runtime/delegation.go new file mode 100644 index 000000000..0627be556 --- /dev/null +++ b/pkg/swarm/runtime/delegation.go @@ -0,0 +1,69 @@ +package runtime + +import ( + "context" + "fmt" + + "github.com/sipeed/picoclaw/pkg/swarm/core" +) + +// DelegateTool allows a node to spawn a child worker and wait for the result +type DelegateTool struct { + orchestrator *Orchestrator + parentNodeID core.NodeID + swarmID core.SwarmID +} + +func NewDelegateTool(orch *Orchestrator, swarmID core.SwarmID, parentID core.NodeID) *DelegateTool { + return &DelegateTool{ + orchestrator: orch, + swarmID: swarmID, + parentNodeID: parentID, + } +} + +func (t *DelegateTool) Name() string { + return "delegate_task" +} + +func (t *DelegateTool) Description() string { + return "Delegate a sub-task to a specialized worker (Researcher, Writer, Analyst, etc.). Returns the worker's output." +} + +func (t *DelegateTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "role": map[string]interface{}{ + "type": "string", + "description": "The role of the worker (Researcher, Analyst, Writer, Critic)", + "enum": []string{"Researcher", "Analyst", "Writer", "Critic"}, + }, + "task": map[string]interface{}{ + "type": "string", + "description": "Detailed instruction for the worker", + }, + }, + "required": []string{"role", "task"}, + } +} + +func (t *DelegateTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + roleName, ok := args["role"].(string) + if !ok { + return "", fmt.Errorf("role is required") + } + task, ok := args["task"].(string) + if !ok { + return "", fmt.Errorf("task is required") + } + + // Request Orchestrator to spawn a worker + // This is a blocking call that waits for the worker to finish + result, err := t.orchestrator.RunSubTask(ctx, t.swarmID, t.parentNodeID, roleName, task) + if err != nil { + return "", fmt.Errorf("delegation failed: %w", err) + } + + return result, nil +} diff --git a/pkg/swarm/runtime/memory_tools.go b/pkg/swarm/runtime/memory_tools.go new file mode 100644 index 000000000..c4513d08e --- /dev/null +++ b/pkg/swarm/runtime/memory_tools.go @@ -0,0 +1,128 @@ +package runtime + +import ( + "context" + "fmt" + + "github.com/sipeed/picoclaw/pkg/swarm/core" +) + +type MemorySaveTool struct { + memory core.SharedMemory + swarmID core.SwarmID +} + +func NewMemorySaveTool(mem core.SharedMemory, swarmID core.SwarmID) *MemorySaveTool { + return &MemorySaveTool{ + memory: mem, + swarmID: swarmID, + } +} + +func (t *MemorySaveTool) Name() string { + return "save_memory" +} + +func (t *MemorySaveTool) Description() string { + return "Save a fact or finding to the swarm's long-term memory for other agents to use." +} + +func (t *MemorySaveTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "content": map[string]interface{}{ + "type": "string", + "description": "The fact or information to save", + }, + "tags": map[string]interface{}{ + "type": "string", + "description": "Comma-separated tags for organization", + }, + }, + "required": []string{"content"}, + } +} + +func (t *MemorySaveTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + content, ok := args["content"].(string) + if !ok { + return "", fmt.Errorf("content is required") + } + + tagsStr, _ := args["tags"].(string) + metadata := map[string]any{ + "tags": tagsStr, + } + + fact := core.Fact{ + SwarmID: t.swarmID, + Content: content, + Confidence: 1.0, // Self-reported facts are trusted + Source: "agent_tool", + Metadata: metadata, + } + + if err := t.memory.SaveFact(ctx, fact); err != nil { + return "", fmt.Errorf("failed to save memory: %w", err) + } + + return "Fact saved to memory.", nil +} + +type MemorySearchTool struct { + memory core.SharedMemory + swarmID core.SwarmID + global bool +} + +func NewMemorySearchTool(mem core.SharedMemory, swarmID core.SwarmID, global bool) *MemorySearchTool { + return &MemorySearchTool{ + memory: mem, + swarmID: swarmID, + global: global, + } +} + +func (t *MemorySearchTool) Name() string { + return "search_memory" +} + +func (t *MemorySearchTool) Description() string { + return "Search the swarm's long-term memory for relevant facts." +} + +func (t *MemorySearchTool) Parameters() map[string]interface{} { + return map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{ + "query": map[string]interface{}{ + "type": "string", + "description": "The search query", + }, + }, + "required": []string{"query"}, + } +} + +func (t *MemorySearchTool) Execute(ctx context.Context, args map[string]interface{}) (string, error) { + query, ok := args["query"].(string) + if !ok { + return "", fmt.Errorf("query is required") + } + + results, err := t.memory.SearchFacts(ctx, t.swarmID, query, 5, t.global) + if err != nil { + return "", fmt.Errorf("search failed: %w", err) + } + + if len(results) == 0 { + return "No relevant facts found.", nil + } + + out := "Found facts:\n" + for _, r := range results { + out += fmt.Sprintf("- %s (Score: %.2f)\n", r.Content, r.Score) + } + return out, nil +} diff --git a/pkg/swarm/runtime/node.go b/pkg/swarm/runtime/node.go new file mode 100644 index 000000000..81a6188f7 --- /dev/null +++ b/pkg/swarm/runtime/node.go @@ -0,0 +1,194 @@ +package runtime + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/sipeed/picoclaw/pkg/swarm/core" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type NodeActor struct { + Data *core.Node + Bus core.EventBus + Store core.SwarmStore + LLM core.LLMClient + Tools *tools.ToolRegistry + Policy *core.PolicyChecker + + peerInsights []string // Buffer for thoughts heard from peers +} + +func NewNodeActor(data *core.Node, bus core.EventBus, store core.SwarmStore, llm core.LLMClient, toolRegistry *tools.ToolRegistry, policy *core.PolicyChecker) *NodeActor { + return &NodeActor{ + Data: data, + Bus: bus, + Store: store, + LLM: llm, + Tools: toolRegistry, + Policy: policy, + peerInsights: make([]string, 0), + } +} + +func (n *NodeActor) Start(ctx context.Context) { + n.Data.Status = core.NodeStatusRunning + n.Store.UpdateNode(ctx, n.Data) + + // Subscribe to peer events + sub, _ := n.Bus.Subscribe("node.events", func(e core.Event) { + if e.SwarmID == n.Data.SwarmID && e.NodeID != n.Data.ID { + if e.Type == core.EventNodeThinking { + content, _ := e.Payload["content"].(string) + if len(content) > 10 { // Only record significant thoughts + n.peerInsights = append(n.peerInsights, fmt.Sprintf("Peer %s: %s", e.NodeID[:4], content)) + } + } + } + }) + + go func() { + defer sub.Unsubscribe() + defer func() { + if r := recover(); r != nil { + slog.Error("Node panic recovered", "node", n.Data.ID, "err", r) + } + }() + n.run(ctx) + }() +} + +func (n *NodeActor) run(ctx context.Context) { + messages := []core.Message{ + {Role: "system", Content: n.Data.Role.SystemPrompt}, + {Role: "user", Content: n.Data.Task}, + } + + allowedTools := n.getToolsForRole() + model := n.Data.Role.Model + + for i := 0; i < 10; i++ { // Max 10 iterations + // Progressive Summarization: If history > 20 messages, compress the middle part + if len(messages) > 20 { + slog.Info("Context threshold reached, performing progressive summarization", "node", n.Data.ID) + + // 1. Identify context to summarize + // Header: System (0) + User Goal (1) + // Tail: Last 6 messages + toSummarize := messages[2 : len(messages)-6] + + summaryPrompt := "Briefly summarize the key findings, data points, and progress from the conversation above. Focus on facts discovered so far. Be very concise." + summaryMessages := append(toSummarize, core.Message{Role: "user", Content: summaryPrompt}) + + summaryResp, err := n.LLM.Chat(ctx, summaryMessages, nil, model) + if err == nil { + // 2. Reconstruct history: [Header] + [Summary] + [Tail] + tail := messages[len(messages)-6:] + newHistory := make([]core.Message, 0, 10) + newHistory = append(newHistory, messages[0:2]...) // Keep System + Goal + newHistory = append(newHistory, core.Message{ + Role: "system", + Content: fmt.Sprintf("Previous context summary: %s", summaryResp.Content), + }) + messages = append(newHistory, tail...) + + n.Bus.Publish("node.events", core.Event{ + Type: core.EventNodeThinking, SwarmID: n.Data.SwarmID, NodeID: n.Data.ID, + Payload: map[string]any{"content": "🧠 I've summarized my previous findings to keep my memory sharp."}, + }) + } + } + + // Inject Peer Insights + if len(n.peerInsights) > 0 { + insightMsg := "Peer insights so far:\n" + for _, in := range n.peerInsights { + insightMsg += "- " + in + "\n" + } + messages = append(messages, core.Message{Role: "system", Content: insightMsg}) + n.peerInsights = nil + } + + resp, err := n.LLM.Chat(ctx, messages, allowedTools, model) + if err != nil { + n.fail(ctx, err) + return + } + + n.Data.Stats.Iterations++ + n.Data.Stats.TokensInput += resp.Usage.Input + n.Data.Stats.TokensOutput += resp.Usage.Output + + messages = append(messages, core.Message{Role: "assistant", Content: resp.Content}) + + if resp.Content != "" { + n.Bus.Publish("node.events", core.Event{ + Type: core.EventNodeThinking, SwarmID: n.Data.SwarmID, NodeID: n.Data.ID, + Payload: map[string]any{"content": resp.Content}, + }) + } + + if len(resp.ToolCalls) == 0 { + n.complete(ctx, resp.Content) + return + } + + // Process Tools + for _, tc := range resp.ToolCalls { + result := n.execTool(ctx, tc) + messages = append(messages, core.Message{Role: "tool", Content: result, ToolCallID: tc.ID}) + } + } + n.fail(ctx, fmt.Errorf("max iterations reached")) +} + +func (n *NodeActor) execTool(ctx context.Context, tc core.ToolCall) string { + if !n.Policy.CanUseTool(n.Data.Role.Name, tc.Name) { + return "Error: Unauthorized tool" + } + + var args map[string]any + json.Unmarshal(tc.Arguments, &args) + + out, err := n.Tools.Execute(ctx, tc.Name, args) + if err != nil { + return fmt.Sprintf("Error: %v", err) + } + return out +} + +func (n *NodeActor) getToolsForRole() []core.ToolDef { + defs := n.Tools.GetDefinitions() + var allowed []core.ToolDef + for _, d := range defs { + f := d["function"].(map[string]any) + name := f["name"].(string) + if n.Policy.CanUseTool(n.Data.Role.Name, name) { + allowed = append(allowed, core.ToolDef{ + Name: name, Description: f["description"].(string), Parameters: f["parameters"], + }) + } + } + return allowed +} + +func (n *NodeActor) complete(ctx context.Context, out string) { + n.Data.Output = out + n.Data.Status = core.NodeStatusCompleted + n.Store.UpdateNode(ctx, n.Data) + n.Bus.Publish("node.events", core.Event{ + Type: core.EventNodeCompleted, SwarmID: n.Data.SwarmID, NodeID: n.Data.ID, + Payload: map[string]any{"output": out}, + }) +} + +func (n *NodeActor) fail(ctx context.Context, err error) { + n.Data.Status = core.NodeStatusFailed + n.Store.UpdateNode(ctx, n.Data) + n.Bus.Publish("node.events", core.Event{ + Type: core.EventNodeFailed, SwarmID: n.Data.SwarmID, NodeID: n.Data.ID, + Payload: map[string]any{"error": err.Error()}, + }) +} \ No newline at end of file diff --git a/pkg/swarm/runtime/orchestrator.go b/pkg/swarm/runtime/orchestrator.go new file mode 100644 index 000000000..dc06602f1 --- /dev/null +++ b/pkg/swarm/runtime/orchestrator.go @@ -0,0 +1,103 @@ +package runtime + +import ( + "context" + "fmt" + "sync" + "time" + + "github.com/google/uuid" + "github.com/sipeed/picoclaw/pkg/swarm/config" + "github.com/sipeed/picoclaw/pkg/swarm/core" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type Orchestrator struct { + store core.SwarmStore + bus core.EventBus + llm core.LLMClient + tools *tools.ToolRegistry + memory core.SharedMemory + activeSwarms map[core.SwarmID]context.CancelFunc + config config.SwarmConfig + policyChecker *core.PolicyChecker + defaultModel string + mu sync.RWMutex +} + +func NewOrchestrator(store core.SwarmStore, bus core.EventBus, llm core.LLMClient, reg *tools.ToolRegistry, cfg config.SwarmConfig, model string) *Orchestrator { + p := make(map[string]core.RolePolicy) + for _, pol := range cfg.Policies { + p[pol.Role] = core.RolePolicy{AllowedTools: pol.Allowed, DeniedTools: pol.Denied} + } + return &Orchestrator{ + store: store, bus: bus, llm: llm, tools: reg, + activeSwarms: make(map[core.SwarmID]context.CancelFunc), + config: cfg, policyChecker: core.NewPolicyChecker(p), defaultModel: model, + } +} + +func (o *Orchestrator) SetSharedMemory(m core.SharedMemory) { o.memory = m } + +func (o *Orchestrator) SpawnSwarm(ctx context.Context, goal string) (core.SwarmID, error) { + id := core.SwarmID(uuid.New().String()) + o.store.CreateSwarm(ctx, &core.Swarm{ID: id, Goal: goal, Status: core.SwarmStatusActive, CreatedAt: time.Now()}) + + o.mu.Lock() + sCtx, cancel := context.WithCancel(context.Background()) + o.activeSwarms[id] = cancel + o.mu.Unlock() + + go o.RunSubTask(sCtx, id, "", "Manager", goal) + return id, nil +} + +func (o *Orchestrator) RunSubTask(ctx context.Context, sid core.SwarmID, pid core.NodeID, roleName, task string) (string, error) { + rc, ok := o.config.Roles[roleName] + if !ok { return "", fmt.Errorf("role %s missing", roleName) } + + model := rc.Model + if model == "" { model = o.defaultModel } + + node := &core.Node{ + ID: core.NodeID(uuid.New().String()), SwarmID: sid, ParentID: pid, + Role: core.Role{Name: roleName, SystemPrompt: rc.SystemPrompt, Model: model}, + Task: task, Status: core.NodeStatusPending, + } + o.store.CreateNode(ctx, node) + + nt := o.tools.Clone() + if roleName == "Manager" { nt.Register(NewDelegateTool(o, sid, node.ID)) } + if o.memory != nil { + nt.Register(NewMemorySaveTool(o.memory, sid)) + nt.Register(NewMemorySearchTool(o.memory, sid, o.config.Memory.SharedKnowledge)) + } + + done := make(chan string, 1) + fail := make(chan error, 1) + sub, _ := o.bus.Subscribe("node.events", func(e core.Event) { + if e.NodeID == node.ID { + if e.Type == core.EventNodeCompleted { done <- e.Payload["output"].(string) } + if e.Type == core.EventNodeFailed { fail <- fmt.Errorf("%v", e.Payload["error"]) } + } + }) + defer sub.Unsubscribe() + + NewNodeActor(node, o.bus, o.store, o.llm, nt, o.policyChecker).Start(ctx) + + select { + case out := <-done: return out, nil + case err := <-fail: return "", err + case <-ctx.Done(): return "", ctx.Err() + case <-time.After(15 * time.Minute): return "", core.ErrTaskTimeout + } +} + +func (o *Orchestrator) StopSwarm(id core.SwarmID) { + o.mu.Lock() + defer o.mu.Unlock() + if c, ok := o.activeSwarms[id]; ok { + c() + delete(o.activeSwarms, id) + } +} diff --git a/pkg/swarm/service.go b/pkg/swarm/service.go new file mode 100644 index 000000000..6310f1b3c --- /dev/null +++ b/pkg/swarm/service.go @@ -0,0 +1,77 @@ +package swarm + +import ( + "context" + "fmt" + "strings" + + "github.com/sipeed/picoclaw/pkg/providers" + "github.com/sipeed/picoclaw/pkg/swarm/adapters" + "github.com/sipeed/picoclaw/pkg/swarm/bus" + "github.com/sipeed/picoclaw/pkg/swarm/config" + "github.com/sipeed/picoclaw/pkg/swarm/core" + "github.com/sipeed/picoclaw/pkg/swarm/memory" + "github.com/sipeed/picoclaw/pkg/swarm/runtime" + "github.com/sipeed/picoclaw/pkg/tools" +) + +type Service struct { + Orchestrator *runtime.Orchestrator + Store core.SwarmStore + Bus core.EventBus + Outbound chan string +} + +func NewService(dbPath string, provider providers.LLMProvider, registry *tools.ToolRegistry, cfg config.SwarmConfig, model string) (*Service, error) { + store, _ := memory.NewSQLiteStore(dbPath) + eventBus := bus.NewChannelBus() + adapter := adapters.NewLLMAdapter(provider) + + var sharedMem core.SharedMemory = store + if m, err := memory.NewChromemStore(context.Background(), adapter); err == nil { + sharedMem = m + } + + orch := runtime.NewOrchestrator(store, eventBus, adapter, registry, cfg, model) + orch.SetSharedMemory(sharedMem) + + s := &Service{Orchestrator: orch, Store: store, Bus: eventBus, Outbound: make(chan string, 100)} + s.listen() + return s, nil +} + +func (s *Service) listen() { + s.Bus.Subscribe("node.events", func(e core.Event) { + msg := "" + switch e.Type { + case core.EventNodeThinking: msg = fmt.Sprintf("🤖 [%s]: %s", e.NodeID[:4], e.Payload["content"]) + case core.EventNodeCompleted: msg = fmt.Sprintf("✅ [%s] Done.", e.NodeID[:4]) + case core.EventNodeFailed: msg = fmt.Sprintf("❌ [%s] Failed: %v", e.NodeID[:4], e.Payload["error"]) + } + if msg != "" { + select { case s.Outbound <- msg: default: } + } + }) +} + +func (s *Service) HandleCommand(ctx context.Context, input string) string { + args := strings.Fields(input) + if len(args) < 2 { return "Usage: /swarm [goal]" } + + switch args[1] { + case "spawn": + goal := strings.Join(args[2:], " ") + id, _ := s.Orchestrator.SpawnSwarm(ctx, goal) + return fmt.Sprintf("🚀 Swarm ID: %s", id) + case "list": + swarms, _ := s.Store.ListSwarms(ctx, core.SwarmStatusActive) + out := "Active Swarms:\n" + for _, sw := range swarms { out += fmt.Sprintf("- %s: %s\n", sw.ID, sw.Goal) } + return out + case "stop": + if len(args) < 3 { return "ID required" } + s.Orchestrator.StopSwarm(core.SwarmID(args[2])) + return "Stopped." + } + return "Unknown command" +} \ No newline at end of file diff --git a/pkg/tools/registry.go b/pkg/tools/registry.go index e87eebe60..122aa51ef 100644 --- a/pkg/tools/registry.go +++ b/pkg/tools/registry.go @@ -17,34 +17,54 @@ func NewToolRegistry() *ToolRegistry { } } -func (r *ToolRegistry) Register(tool Tool) { - r.mu.Lock() - defer r.mu.Unlock() - r.tools[tool.Name()] = tool +func (tr *ToolRegistry) Register(tool Tool) { + tr.mu.Lock() + defer tr.mu.Unlock() + tr.tools[tool.Name()] = tool } -func (r *ToolRegistry) Get(name string) (Tool, bool) { - r.mu.RLock() - defer r.mu.RUnlock() - tool, ok := r.tools[name] +func (tr *ToolRegistry) Get(name string) (Tool, bool) { + tr.mu.RLock() + defer tr.mu.RUnlock() + tool, ok := tr.tools[name] return tool, ok } -func (r *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) (string, error) { - tool, ok := r.Get(name) +func (tr *ToolRegistry) Execute(ctx context.Context, name string, args map[string]interface{}) (string, error) { + tool, ok := tr.Get(name) if !ok { - return "", fmt.Errorf("tool '%s' not found", name) + return "", fmt.Errorf("tool not found: %s", name) } + return tool.Execute(ctx, args) } -func (r *ToolRegistry) GetDefinitions() []map[string]interface{} { - r.mu.RLock() - defer r.mu.RUnlock() +func (tr *ToolRegistry) GetDefinitions() []map[string]interface{} { + tr.mu.RLock() + defer tr.mu.RUnlock() - definitions := make([]map[string]interface{}, 0, len(r.tools)) - for _, tool := range r.tools { - definitions = append(definitions, ToolToSchema(tool)) + var definitions []map[string]interface{} + for _, tool := range tr.tools { + definitions = append(definitions, map[string]interface{}{ + "type": "function", + "function": map[string]interface{}{ + "name": tool.Name(), + "description": tool.Description(), + "parameters": tool.Parameters(), + }, + }) } return definitions } + +// Clone creates a shallow copy of the registry +func (tr *ToolRegistry) Clone() *ToolRegistry { + tr.mu.RLock() + defer tr.mu.RUnlock() + + newReg := NewToolRegistry() + for name, tool := range tr.tools { + newReg.tools[name] = tool + } + return newReg +} \ No newline at end of file