From 4109a141f9925c1585f599d4f5ec1193539f118c Mon Sep 17 00:00:00 2001 From: Acbox Date: Fri, 6 Mar 2026 16:48:18 +0800 Subject: [PATCH] feat: move all tools from `@memoh/agent` into built-in mcp --- agent/src/models.ts | 3 - agent/src/modules/chat.ts | 38 ++- cmd/agent/main.go | 10 +- go.mod | 7 + go.sum | 57 ++++ internal/conversation/flow/resolver.go | 2 - internal/conversation/types.go | 1 - internal/handlers/mcp_tools.go | 3 + internal/mcp/providers/skill/provider.go | 72 ++++ internal/mcp/providers/subagent/provider.go | 356 ++++++++++++++++++++ internal/mcp/providers/webfetch/provider.go | 220 ++++++++++++ internal/mcp/tool_types.go | 1 + packages/agent/src/agent.ts | 39 ++- packages/agent/src/tools/index.ts | 38 --- packages/agent/src/tools/skill.ts | 28 -- packages/agent/src/tools/subagent.ts | 105 ------ packages/agent/src/tools/web.ts | 139 -------- packages/agent/src/types/agent.ts | 14 +- packages/agent/src/utils/headers.ts | 9 +- packages/agent/src/utils/index.ts | 1 - packages/agent/src/utils/subagent.ts | 131 ------- 21 files changed, 789 insertions(+), 485 deletions(-) create mode 100644 internal/mcp/providers/skill/provider.go create mode 100644 internal/mcp/providers/subagent/provider.go create mode 100644 internal/mcp/providers/webfetch/provider.go delete mode 100644 packages/agent/src/tools/skill.ts delete mode 100644 packages/agent/src/tools/subagent.ts delete mode 100644 packages/agent/src/tools/web.ts delete mode 100644 packages/agent/src/utils/subagent.ts diff --git a/agent/src/models.ts b/agent/src/models.ts index 186ac47b..acbfc180 100644 --- a/agent/src/models.ts +++ b/agent/src/models.ts @@ -1,5 +1,4 @@ import z from 'zod' -import { allActions } from '@memoh/agent' export const AgentSkillModel = z.object({ name: z.string().min(1, 'Skill name is required'), @@ -26,8 +25,6 @@ export const ModelConfigModel = z.object({ reasoning: ReasoningConfigModel, }) -export const AllowedActionModel = z.enum(allActions) - export const IdentityContextModel = z.object({ botId: z.string().min(1, 'Bot ID is required'), containerId: z.string().min(1, 'Container ID is required'), diff --git a/agent/src/modules/chat.ts b/agent/src/modules/chat.ts index 636b444c..62f3f5a3 100644 --- a/agent/src/modules/chat.ts +++ b/agent/src/modules/chat.ts @@ -1,9 +1,9 @@ import { Elysia } from 'elysia' import z from 'zod' -import { createAgent, ModelConfig, allActions } from '@memoh/agent' +import { createAgent, ModelConfig } from '@memoh/agent' import { createAuthFetcher, getBaseUrl } from '../index' import { bearerMiddleware } from '../middlewares/bearer' -import { AgentSkillModel, AllowedActionModel, AttachmentModel, HeartbeatModel, IdentityContextModel, InboxItemModel, LoopDetectionModel, MCPConnectionModel, ModelConfigModel, ScheduleModel } from '../models' +import { AgentSkillModel, AttachmentModel, HeartbeatModel, IdentityContextModel, InboxItemModel, LoopDetectionModel, MCPConnectionModel, ModelConfigModel, ScheduleModel } from '../models' import { sseChunked } from '../utils/sse' const AgentModel = z.object({ @@ -11,7 +11,6 @@ const AgentModel = z.object({ activeContextTime: z.number(), channels: z.array(z.string()), currentChannel: z.string(), - allowedActions: z.array(AllowedActionModel).optional().default(allActions), messages: z.array(z.any()), usableSkills: z.array(AgentSkillModel).optional().default([]), skills: z.array(z.string()), @@ -36,7 +35,6 @@ export const chatModule = new Elysia({ prefix: '/chat' }) activeContextTime: body.activeContextTime, channels: body.channels, currentChannel: body.currentChannel, - allowedActions: body.allowedActions, identity: body.identity, auth, skills: body.usableSkills, @@ -68,7 +66,6 @@ export const chatModule = new Elysia({ prefix: '/chat' }) activeContextTime: body.activeContextTime, channels: body.channels, currentChannel: body.currentChannel, - allowedActions: body.allowedActions, identity: body.identity, auth, skills: body.usableSkills, @@ -157,3 +154,34 @@ export const chatModule = new Elysia({ prefix: '/chat' }) heartbeat: HeartbeatModel, }), }) + .post('/subagent', async ({ body, bearer }) => { + console.log('subagent', body) + const auth = { + bearer: bearer!, + baseUrl: getBaseUrl(), + } + const authFetcher = createAuthFetcher(auth) + const { askAsSubagent } = createAgent({ + model: body.model as ModelConfig, + identity: body.identity, + auth, + isSubagent: true, + loopDetection: body.loopDetection, + }, authFetcher) + return askAsSubagent({ + messages: body.messages, + input: body.query, + name: body.name, + description: body.description, + }) + }, { + body: z.object({ + model: ModelConfigModel, + identity: IdentityContextModel, + messages: z.array(z.any()).optional().default([]), + query: z.string(), + name: z.string(), + description: z.string(), + loopDetection: LoopDetectionModel, + }), + }) diff --git a/cmd/agent/main.go b/cmd/agent/main.go index fe4e86f0..80c8f00a 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -56,7 +56,10 @@ import ( mcpmemory "github.com/memohai/memoh/internal/mcp/providers/memory" mcpmessage "github.com/memohai/memoh/internal/mcp/providers/message" mcpschedule "github.com/memohai/memoh/internal/mcp/providers/schedule" + mcpskill "github.com/memohai/memoh/internal/mcp/providers/skill" + mcpsubagent "github.com/memohai/memoh/internal/mcp/providers/subagent" mcpweb "github.com/memohai/memoh/internal/mcp/providers/web" + mcpwebfetch "github.com/memohai/memoh/internal/mcp/providers/webfetch" mcpfederation "github.com/memohai/memoh/internal/mcp/sources/federation" "github.com/memohai/memoh/internal/media" memprovider "github.com/memohai/memoh/internal/memory/provider" @@ -450,7 +453,7 @@ func provideOAuthService(log *slog.Logger, queries *dbsqlc.Queries, cfg config.C return mcp.NewOAuthService(log, queries, callbackURL) } -func provideToolGatewayService(log *slog.Logger, _ config.Config, channelManager *channel.Manager, registry *channel.Registry, routeService *route.DBService, scheduleService *schedule.Service, _ *conversation.Service, _ *accounts.Service, settingsService *settings.Service, searchProviderService *searchproviders.Service, manager *mcp.Manager, containerdHandler *handlers.ContainerdHandler, mcpConnService *mcp.ConnectionService, mediaService *media.Service, inboxService *inbox.Service, memoryRegistry *memprovider.Registry, emailService *emailpkg.Service, emailManager *emailpkg.Manager, fedGateway *handlers.MCPFederationGateway, oauthService *mcp.OAuthService) *mcp.ToolGatewayService { +func provideToolGatewayService(log *slog.Logger, cfg config.Config, channelManager *channel.Manager, registry *channel.Registry, routeService *route.DBService, scheduleService *schedule.Service, _ *conversation.Service, _ *accounts.Service, settingsService *settings.Service, searchProviderService *searchproviders.Service, manager *mcp.Manager, containerdHandler *handlers.ContainerdHandler, mcpConnService *mcp.ConnectionService, mediaService *media.Service, inboxService *inbox.Service, memoryRegistry *memprovider.Registry, emailService *emailpkg.Service, emailManager *emailpkg.Manager, fedGateway *handlers.MCPFederationGateway, oauthService *mcp.OAuthService, subagentService *subagent.Service, modelsService *models.Service, queries *dbsqlc.Queries) *mcp.ToolGatewayService { fedGateway.SetOAuthService(oauthService) var assetResolver mcpmessage.AssetResolver if mediaService != nil { @@ -465,10 +468,13 @@ func provideToolGatewayService(log *slog.Logger, _ config.Config, channelManager fsExec := mcpcontainer.NewExecutor(log, manager, config.DefaultDataMount) fedSource := mcpfederation.NewSource(log, fedGateway, mcpConnService) emailExec := mcpemail.NewExecutor(log, emailService, emailManager) + webFetchExec := mcpwebfetch.NewExecutor(log) + subagentExec := mcpsubagent.NewExecutor(log, subagentService, settingsService, modelsService, queries, cfg.AgentGateway.BaseURL()) + skillExec := mcpskill.NewExecutor(log) svc := mcp.NewToolGatewayService( log, - []mcp.ToolExecutor{messageExec, contactsExec, scheduleExec, memoryExec, webExec, fsExec, inboxExec, emailExec}, + []mcp.ToolExecutor{messageExec, contactsExec, scheduleExec, memoryExec, webExec, fsExec, inboxExec, emailExec, webFetchExec, subagentExec, skillExec}, []mcp.ToolSource{fedSource}, ) containerdHandler.SetToolGatewayService(svc) diff --git a/go.mod b/go.mod index 7371edc2..6fbf6b72 100644 --- a/go.mod +++ b/go.mod @@ -4,12 +4,14 @@ go 1.25.2 require ( github.com/BurntSushi/toml v1.6.0 + github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 github.com/bwmarrin/discordgo v0.29.0 github.com/containerd/containerd/api v1.10.0 github.com/containerd/containerd/v2 v2.2.1 github.com/containerd/errdefs v1.0.0 github.com/containerd/go-cni v1.1.13 github.com/emersion/go-imap/v2 v2.0.0-beta.8 + github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 github.com/go-telegram-bot-api/telegram-bot-api/v5 v5.5.1 github.com/golang-jwt/jwt/v5 v5.3.1 github.com/golang-migrate/migrate/v4 v4.19.1 @@ -37,9 +39,12 @@ require ( require ( cyphar.com/go-pathrs v0.2.3 // indirect + github.com/JohannesKaufmann/dom v0.2.0 // indirect github.com/KyleBanks/depth v1.2.1 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/Microsoft/hcsshim v0.14.0-rc.1 // indirect + github.com/andybalholm/cascadia v1.3.3 // indirect + github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/containerd/cgroups/v3 v3.1.2 // indirect github.com/containerd/continuity v0.4.5 // indirect @@ -71,7 +76,9 @@ require ( github.com/go-openapi/swag/stringutils v0.25.4 // indirect github.com/go-openapi/swag/typeutils v0.25.4 // indirect github.com/go-openapi/swag/yamlutils v0.25.4 // indirect + github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c // indirect github.com/gogo/protobuf v1.3.2 // indirect + github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f // indirect github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/google/jsonschema-go v0.4.2 // indirect diff --git a/go.sum b/go.sum index 7411e95a..ab9176a8 100644 --- a/go.sum +++ b/go.sum @@ -8,12 +8,20 @@ github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg6 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk= github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= +github.com/JohannesKaufmann/dom v0.2.0 h1:1bragmEb19K8lHAqgFgqCpiPCFEZMTXzOIEjuxkUfLQ= +github.com/JohannesKaufmann/dom v0.2.0/go.mod h1:57iSUl5RKric4bUkgos4zu6Xt5LMHUnw3TF1l5CbGZo= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0 h1:mklaPbT4f/EiDr1Q+zPrEt9lgKAkVrIBtWf33d9GpVA= +github.com/JohannesKaufmann/html-to-markdown/v2 v2.5.0/go.mod h1:D56Cl9r8M5i3UwAchE+LlLc5hPN3kJtdZNVJn06lSHU= github.com/KyleBanks/depth v1.2.1 h1:5h8fQADFrWtarTdtDudMmGsC7GPbOAu6RVB3ffsVFHc= github.com/KyleBanks/depth v1.2.1/go.mod h1:jzSb9d0L43HxTQfT+oSA1EEp2q+ne2uh6XgeJcm8brE= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= github.com/Microsoft/hcsshim v0.14.0-rc.1 h1:qAPXKwGOkVn8LlqgBN8GS0bxZ83hOJpcjxzmlQKxKsQ= github.com/Microsoft/hcsshim v0.14.0-rc.1/go.mod h1:hTKFGbnDtQb1wHiOWv4v0eN+7boSWAHyK/tNAaYZL0c= +github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA= +github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw= github.com/bwmarrin/discordgo v0.29.0 h1:FmWeXFaKUwrcL3Cx65c20bTRW+vOb6k8AnaP+EgjDno= github.com/bwmarrin/discordgo v0.29.0/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU= @@ -112,12 +120,18 @@ github.com/go-openapi/testify/enable/yaml/v2 v2.0.2 h1:0+Y41Pz1NkbTHz8NngxTuAXxE github.com/go-openapi/testify/enable/yaml/v2 v2.0.2/go.mod h1:kme83333GCtJQHXQ8UKX3IBZu6z8T5Dvy5+CW3NLUUg= github.com/go-openapi/testify/v2 v2.0.2 h1:X999g3jeLcoY8qctY/c/Z8iBHTbwLz7R2WXd6Ub6wls= github.com/go-openapi/testify/v2 v2.0.2/go.mod h1:HCPmvFFnheKK2BuwSA0TbbdxJ3I16pjwMkYkP4Ywn54= +github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c h1:wpkoddUomPfHiOziHZixGO5ZBS73cKqVzZipfrLmO1w= +github.com/go-shiori/dom v0.0.0-20230515143342-73569d674e1c/go.mod h1:oVDCh3qjJMLVUSILBRwrm+Bc6RNXGZYtoh9xdvf1ffM= +github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0 h1:A3B75Yp163FAIf9nLlFMl4pwIj+T3uKxfI7mbvvY2Ls= +github.com/go-shiori/go-readability v0.0.0-20251205110129-5db1dc9836f0/go.mod h1:suxK0Wpz4BM3/2+z1mnOVTIWHDiMCIOGoKDCRumSsk0= github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 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/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f h1:3BSP1Tbs2djlpprl7wCLuiqMaUh5SJkkzI2gDs+FgLs= +github.com/gogs/chardet v0.0.0-20211120154057-b7413eaefb8f/go.mod h1:Pcatq5tYkCW2Q6yrR2VRHlbHpZ/R4/7qyL1TCF7vl14= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= @@ -144,6 +158,7 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= @@ -194,6 +209,7 @@ github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHP github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= 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/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk= github.com/memohai/acgo v0.0.0-20260221232113-babac0d6acd7 h1:beehwOQperqGWj4m4EhcPhnSZKtDiuHK/7ZMoTPaQjw= github.com/memohai/acgo v0.0.0-20260221232113-babac0d6acd7/go.mod h1:OvmxM7JmnXBmwJWWVqtreL3HSHSKuzPbtbhlg5MvBg0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= @@ -248,6 +264,7 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA= github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= @@ -255,10 +272,15 @@ github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7 github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sasha-s/go-deadlock v0.3.6 h1:TR7sfOnZ7x00tWPfD397Peodt57KzMDo+9Ae9rMiUmw= github.com/sasha-s/go-deadlock v0.3.6/go.mod h1:CUqNyyvMxTyjFqDT7MRg9mb4Dv/btmGTqSR+rky/UXo= +github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= +github.com/sebdah/goldie/v2 v2.8.0 h1:dZb9wR8q5++oplmEiJT+U/5KyotVD+HNGCAc5gNr8rc= +github.com/sebdah/goldie/v2 v2.8.0/go.mod h1:oZ9fp0+se1eapSRjfYbsV/0Hqhbuu3bJVvKI/NNtssI= github.com/segmentio/asm v1.1.3 h1:WM03sfUOENvvKexOLp+pCqgb/WDjsi7EK8gIsICtzhc= github.com/segmentio/asm v1.1.3/go.mod h1:Ld3L4ZXGNcSLRg4JBsZ3//1+f/TjYl0Mzen/DQy1EJg= github.com/segmentio/encoding v0.5.3 h1:OjMgICtcSFuNvQCdwqMCv9Tg7lEOXGwm1J5RPQccx6w= github.com/segmentio/encoding v0.5.3/go.mod h1:HS1ZKa3kSN32ZHVZ7ZLPLXWvOVIiZtyJnO1gPH1sKt0= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= +github.com/sergi/go-diff v1.4.0/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= @@ -292,6 +314,8 @@ github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT0 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= +github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA= +github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0= go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= @@ -325,6 +349,10 @@ golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8U golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= +golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= +golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 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-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= @@ -337,6 +365,9 @@ 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.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -351,6 +382,11 @@ golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwY golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= +golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= +golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= @@ -363,6 +399,10 @@ golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJ golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -375,16 +415,31 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= +golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= +golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= @@ -399,6 +454,8 @@ golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roY golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/tools v0.42.0 h1:uNgphsn75Tdz5Ji2q36v/nsFSfR/9BRFvqhGBaJGd5k= golang.org/x/tools v0.42.0/go.mod h1:Ma6lCIwGZvHK6XtgbswSoWroEkhugApmsXyrUmBhfr0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= diff --git a/internal/conversation/flow/resolver.go b/internal/conversation/flow/resolver.go index 3d6e7f7e..6ff720b9 100644 --- a/internal/conversation/flow/resolver.go +++ b/internal/conversation/flow/resolver.go @@ -187,7 +187,6 @@ type gatewayRequest struct { ActiveContextTime int `json:"activeContextTime"` Channels []string `json:"channels"` CurrentChannel string `json:"currentChannel"` - AllowedActions []string `json:"allowedActions,omitempty"` Messages []conversation.ModelMessage `json:"messages"` Skills []string `json:"skills"` UsableSkills []gatewaySkill `json:"usableSkills"` @@ -456,7 +455,6 @@ func (r *Resolver) resolve(ctx context.Context, req conversation.ChatRequest) (r ActiveContextTime: maxCtx, Channels: nonNilStrings(req.Channels), CurrentChannel: req.CurrentChannel, - AllowedActions: req.AllowedActions, Messages: nonNilModelMessages(messages), Skills: nonNilStrings(skills), UsableSkills: usableSkills, diff --git a/internal/conversation/types.go b/internal/conversation/types.go index 7f07387d..972cfc5a 100644 --- a/internal/conversation/types.go +++ b/internal/conversation/types.go @@ -241,7 +241,6 @@ type ChatRequest struct { CurrentChannel string `json:"current_channel,omitempty"` Messages []ModelMessage `json:"messages,omitempty"` Skills []string `json:"skills,omitempty"` - AllowedActions []string `json:"allowed_actions,omitempty"` Attachments []ChatAttachment `json:"attachments,omitempty"` } diff --git a/internal/handlers/mcp_tools.go b/internal/handlers/mcp_tools.go index a75d0fef..7e27410e 100644 --- a/internal/handlers/mcp_tools.go +++ b/internal/handlers/mcp_tools.go @@ -19,6 +19,7 @@ const ( headerSessionToken = "X-Memoh-Session-Token" //nolint:gosec // G101: this is an HTTP header name, not a hardcoded credential headerCurrentPlatform = "X-Memoh-Current-Platform" headerReplyTarget = "X-Memoh-Reply-Target" + headerIsSubagent = "X-Memoh-Is-Subagent" ) func (h *ContainerdHandler) SetToolGatewayService(service *mcpgw.ToolGatewayService) { @@ -230,6 +231,7 @@ func (*ContainerdHandler) buildToolSessionContext(c echo.Context, botID string) channelIdentityID = strings.TrimSpace(ctxIdentityID) } } + isSubagent := strings.EqualFold(strings.TrimSpace(c.Request().Header.Get(headerIsSubagent)), "true") return mcpgw.ToolSessionContext{ BotID: strings.TrimSpace(botID), ChatID: strings.TrimSpace(botID), @@ -237,5 +239,6 @@ func (*ContainerdHandler) buildToolSessionContext(c echo.Context, botID string) SessionToken: strings.TrimSpace(c.Request().Header.Get(headerSessionToken)), CurrentPlatform: strings.TrimSpace(c.Request().Header.Get(headerCurrentPlatform)), ReplyTarget: strings.TrimSpace(c.Request().Header.Get(headerReplyTarget)), + IsSubagent: isSubagent, } } diff --git a/internal/mcp/providers/skill/provider.go b/internal/mcp/providers/skill/provider.go new file mode 100644 index 00000000..932022b6 --- /dev/null +++ b/internal/mcp/providers/skill/provider.go @@ -0,0 +1,72 @@ +package skill + +import ( + "context" + "log/slog" + + mcpgw "github.com/memohai/memoh/internal/mcp" +) + +const ( + toolUseSkill = "use_skill" +) + +type Executor struct { + logger *slog.Logger +} + +func NewExecutor(log *slog.Logger) *Executor { + if log == nil { + log = slog.Default() + } + return &Executor{ + logger: log.With(slog.String("provider", "skill_tool")), + } +} + +func (e *Executor) ListTools(_ context.Context, session mcpgw.ToolSessionContext) ([]mcpgw.ToolDescriptor, error) { + if session.IsSubagent { + return []mcpgw.ToolDescriptor{}, nil + } + return []mcpgw.ToolDescriptor{ + { + Name: toolUseSkill, + Description: "Use a skill if you think it is relevant to the current task", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "skillName": map[string]any{ + "type": "string", + "description": "The name of the skill to use", + }, + "reason": map[string]any{ + "type": "string", + "description": "The reason why you think this skill is relevant to the current task", + }, + }, + "required": []string{"skillName", "reason"}, + }, + }, + }, nil +} + +func (e *Executor) CallTool(_ context.Context, session mcpgw.ToolSessionContext, toolName string, arguments map[string]any) (map[string]any, error) { + if toolName != toolUseSkill { + return nil, mcpgw.ErrToolNotFound + } + if session.IsSubagent { + return mcpgw.BuildToolErrorResult("skill tools are not available in subagent context"), nil + } + + skillName := mcpgw.StringArg(arguments, "skillName") + reason := mcpgw.StringArg(arguments, "reason") + if skillName == "" { + return mcpgw.BuildToolErrorResult("skillName is required"), nil + } + + return mcpgw.BuildToolSuccessResult(map[string]any{ + "success": true, + "skillName": skillName, + "reason": reason, + }), nil +} diff --git a/internal/mcp/providers/subagent/provider.go b/internal/mcp/providers/subagent/provider.go new file mode 100644 index 00000000..1cae1a7d --- /dev/null +++ b/internal/mcp/providers/subagent/provider.go @@ -0,0 +1,356 @@ +package subagent + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" + "time" + + mcpgw "github.com/memohai/memoh/internal/mcp" + "github.com/memohai/memoh/internal/models" + "github.com/memohai/memoh/internal/settings" + subagentsvc "github.com/memohai/memoh/internal/subagent" + + "github.com/memohai/memoh/internal/db/sqlc" +) + +const ( + toolListSubagents = "list_subagents" + toolDeleteSubagent = "delete_subagent" + toolQuerySubagent = "query_subagent" + + gatewayTimeout = 120 * time.Second +) + +type Executor struct { + logger *slog.Logger + service *subagentsvc.Service + settings *settings.Service + models *models.Service + queries *sqlc.Queries + gatewayBaseURL string + httpClient *http.Client +} + +func NewExecutor( + log *slog.Logger, + service *subagentsvc.Service, + settingsSvc *settings.Service, + modelsSvc *models.Service, + queries *sqlc.Queries, + gatewayBaseURL string, +) *Executor { + if log == nil { + log = slog.Default() + } + return &Executor{ + logger: log.With(slog.String("provider", "subagent_tool")), + service: service, + settings: settingsSvc, + models: modelsSvc, + queries: queries, + gatewayBaseURL: strings.TrimRight(gatewayBaseURL, "/"), + httpClient: &http.Client{Timeout: gatewayTimeout}, + } +} + +func (e *Executor) ListTools(_ context.Context, session mcpgw.ToolSessionContext) ([]mcpgw.ToolDescriptor, error) { + if e.service == nil { + return []mcpgw.ToolDescriptor{}, nil + } + if session.IsSubagent { + return []mcpgw.ToolDescriptor{}, nil + } + return []mcpgw.ToolDescriptor{ + { + Name: toolListSubagents, + Description: "List subagents for current bot", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{}, + }, + }, + { + Name: toolDeleteSubagent, + Description: "Delete a subagent by id", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "id": map[string]any{"type": "string", "description": "Subagent ID"}, + }, + "required": []string{"id"}, + }, + }, + { + Name: toolQuerySubagent, + Description: "Query a subagent. If the subagent does not exist it will be created automatically.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "name": map[string]any{"type": "string", "description": "The name of the subagent"}, + "description": map[string]any{"type": "string", "description": "A short description of the subagent purpose (used when creating)"}, + "query": map[string]any{"type": "string", "description": "The prompt to ask the subagent to do."}, + }, + "required": []string{"name", "description", "query"}, + }, + }, + }, nil +} + +func (e *Executor) CallTool(ctx context.Context, session mcpgw.ToolSessionContext, toolName string, arguments map[string]any) (map[string]any, error) { + if e.service == nil { + return mcpgw.BuildToolErrorResult("subagent service not available"), nil + } + if session.IsSubagent { + return mcpgw.BuildToolErrorResult("subagent tools are not available in subagent context"), nil + } + botID := strings.TrimSpace(session.BotID) + if botID == "" { + return mcpgw.BuildToolErrorResult("bot_id is required"), nil + } + + switch toolName { + case toolListSubagents: + return e.callList(ctx, botID) + case toolDeleteSubagent: + return e.callDelete(ctx, arguments) + case toolQuerySubagent: + return e.callQuery(ctx, session, botID, arguments) + default: + return nil, mcpgw.ErrToolNotFound + } +} + +func (e *Executor) callList(ctx context.Context, botID string) (map[string]any, error) { + items, err := e.service.List(ctx, botID) + if err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + result := make([]map[string]any, 0, len(items)) + for _, item := range items { + result = append(result, map[string]any{ + "id": item.ID, + "name": item.Name, + "description": item.Description, + }) + } + return mcpgw.BuildToolSuccessResult(map[string]any{"items": result}), nil +} + +func (e *Executor) callDelete(ctx context.Context, arguments map[string]any) (map[string]any, error) { + id := mcpgw.StringArg(arguments, "id") + if id == "" { + return mcpgw.BuildToolErrorResult("id is required"), nil + } + if err := e.service.Delete(ctx, id); err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + return mcpgw.BuildToolSuccessResult(map[string]any{"success": true}), nil +} + +func (e *Executor) callQuery(ctx context.Context, session mcpgw.ToolSessionContext, botID string, arguments map[string]any) (map[string]any, error) { + name := mcpgw.StringArg(arguments, "name") + description := mcpgw.StringArg(arguments, "description") + query := mcpgw.StringArg(arguments, "query") + if name == "" || description == "" || query == "" { + return mcpgw.BuildToolErrorResult("name, description, and query are required"), nil + } + + target, err := e.service.GetOrCreate(ctx, botID, subagentsvc.CreateRequest{ + Name: name, + Description: description, + }) + if err != nil { + return mcpgw.BuildToolErrorResult(fmt.Sprintf("failed to get or create subagent: %v", err)), nil + } + + modelCfg, provider, err := e.resolveModel(ctx, botID) + if err != nil { + return mcpgw.BuildToolErrorResult(fmt.Sprintf("failed to resolve model: %v", err)), nil + } + + gwResp, err := e.postSubagent(ctx, session, subagentGatewayRequest{ + Model: subagentModelConfig{ + ModelID: modelCfg.ModelID, + ClientType: string(modelCfg.ClientType), + Input: modelCfg.InputModalities, + APIKey: provider.ApiKey, + BaseURL: provider.BaseUrl, + }, + Identity: subagentIdentity{ + BotID: botID, + ChannelIdentityID: session.ChannelIdentityID, + CurrentPlatform: session.CurrentPlatform, + SessionToken: session.SessionToken, + }, + Messages: target.Messages, + Query: query, + Name: name, + Desc: description, + }) + if err != nil { + return mcpgw.BuildToolErrorResult(fmt.Sprintf("subagent query failed: %v", err)), nil + } + + updatedMessages := append(target.Messages, gwResp.Messages...) + usage := mergeUsage(target.Usage, gwResp.Usage) + if _, err := e.service.UpdateContext(ctx, target.ID, subagentsvc.UpdateContextRequest{ + Messages: updatedMessages, + Usage: usage, + }); err != nil { + e.logger.Warn("failed to persist subagent context", slog.String("subagent_id", target.ID), slog.Any("error", err)) + } + + resultContent := gwResp.Text + if resultContent == "" && len(gwResp.Messages) > 0 { + last := gwResp.Messages[len(gwResp.Messages)-1] + if content, ok := last["content"]; ok { + resultContent = fmt.Sprintf("%v", content) + } + } + + return mcpgw.BuildToolSuccessResult(map[string]any{ + "success": true, + "result": resultContent, + }), nil +} + +func (e *Executor) resolveModel(ctx context.Context, botID string) (models.GetResponse, sqlc.LlmProvider, error) { + if e.settings == nil || e.models == nil || e.queries == nil { + return models.GetResponse{}, sqlc.LlmProvider{}, fmt.Errorf("model resolution services not configured") + } + botSettings, err := e.settings.GetBot(ctx, botID) + if err != nil { + return models.GetResponse{}, sqlc.LlmProvider{}, err + } + chatModelID := strings.TrimSpace(botSettings.ChatModelID) + if chatModelID == "" { + return models.GetResponse{}, sqlc.LlmProvider{}, fmt.Errorf("no chat model configured for bot") + } + model, err := e.models.GetByID(ctx, chatModelID) + if err != nil { + return models.GetResponse{}, sqlc.LlmProvider{}, err + } + provider, err := models.FetchProviderByID(ctx, e.queries, model.LlmProviderID) + if err != nil { + return models.GetResponse{}, sqlc.LlmProvider{}, err + } + return model, provider, nil +} + +// --- gateway types --- + +type subagentModelConfig struct { + ModelID string `json:"modelId"` + ClientType string `json:"clientType"` + Input []string `json:"input"` + APIKey string `json:"apiKey"` //nolint:gosec // forwarded to agent gateway + BaseURL string `json:"baseUrl"` +} + +type subagentIdentity struct { + BotID string `json:"botId"` + ChannelIdentityID string `json:"channelIdentityId"` + CurrentPlatform string `json:"currentPlatform,omitempty"` + SessionToken string `json:"sessionToken,omitempty"` //nolint:gosec // session token forwarded to agent gateway +} + +type subagentGatewayRequest struct { + Model subagentModelConfig `json:"model"` + Identity subagentIdentity `json:"identity"` + Messages []map[string]any `json:"messages"` + Query string `json:"query"` + Name string `json:"name"` + Desc string `json:"description"` +} + +type subagentGatewayResponse struct { + Messages []map[string]any `json:"messages"` + Text string `json:"text,omitempty"` + Usage json.RawMessage `json:"usage,omitempty"` +} + +func (e *Executor) postSubagent(ctx context.Context, session mcpgw.ToolSessionContext, payload subagentGatewayRequest) (subagentGatewayResponse, error) { + url := e.gatewayBaseURL + "/chat/subagent" + body, err := json.Marshal(payload) + if err != nil { + return subagentGatewayResponse{}, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return subagentGatewayResponse{}, err + } + req.Header.Set("Content-Type", "application/json") + if token := strings.TrimSpace(session.SessionToken); token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + + resp, err := e.httpClient.Do(req) //nolint:gosec // URL is from operator-configured agent gateway + if err != nil { + return subagentGatewayResponse{}, err + } + defer func() { _ = resp.Body.Close() }() + + respBody, err := io.ReadAll(resp.Body) + if err != nil { + return subagentGatewayResponse{}, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + detail := string(respBody) + if len(detail) > 300 { + detail = detail[:300] + } + return subagentGatewayResponse{}, fmt.Errorf("agent gateway error (HTTP %d): %s", resp.StatusCode, strings.TrimSpace(detail)) + } + + var parsed subagentGatewayResponse + if err := json.Unmarshal(respBody, &parsed); err != nil { + return subagentGatewayResponse{}, fmt.Errorf("failed to parse gateway response: %w", err) + } + return parsed, nil +} + +func mergeUsage(existing map[string]any, delta json.RawMessage) map[string]any { + if existing == nil { + existing = map[string]any{} + } + if len(delta) == 0 { + return existing + } + var deltaMap map[string]any + if err := json.Unmarshal(delta, &deltaMap); err != nil { + return existing + } + for key, val := range deltaMap { + if num, ok := toFloat64(val); ok { + if prev, ok := toFloat64(existing[key]); ok { + existing[key] = prev + num + } else { + existing[key] = num + } + } + } + return existing +} + +func toFloat64(v any) (float64, bool) { + switch n := v.(type) { + case float64: + return n, true + case int: + return float64(n), true + case int64: + return float64(n), true + case json.Number: + f, err := n.Float64() + return f, err == nil + default: + return 0, false + } +} diff --git a/internal/mcp/providers/webfetch/provider.go b/internal/mcp/providers/webfetch/provider.go new file mode 100644 index 00000000..cc8d95ee --- /dev/null +++ b/internal/mcp/providers/webfetch/provider.go @@ -0,0 +1,220 @@ +package webfetch + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "net/url" + "strings" + "time" + + readability "github.com/go-shiori/go-readability" + + htmltomarkdown "github.com/JohannesKaufmann/html-to-markdown/v2" + + mcpgw "github.com/memohai/memoh/internal/mcp" +) + +const ( + toolWebFetch = "web_fetch" + maxTextContent = 10000 + fetchTimeout = 30 * time.Second + userAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36" +) + +type Executor struct { + logger *slog.Logger + client *http.Client +} + +func NewExecutor(log *slog.Logger) *Executor { + if log == nil { + log = slog.Default() + } + return &Executor{ + logger: log.With(slog.String("provider", "webfetch_tool")), + client: &http.Client{Timeout: fetchTimeout}, + } +} + +func (e *Executor) ListTools(_ context.Context, _ mcpgw.ToolSessionContext) ([]mcpgw.ToolDescriptor, error) { + return []mcpgw.ToolDescriptor{ + { + Name: toolWebFetch, + Description: "Fetch a URL and convert the response to readable content. Supports HTML (converts to Markdown), JSON, XML, and plain text formats.", + InputSchema: map[string]any{ + "type": "object", + "properties": map[string]any{ + "url": map[string]any{ + "type": "string", + "description": "The URL to fetch", + }, + "format": map[string]any{ + "type": "string", + "enum": []string{"auto", "markdown", "json", "xml", "text"}, + "description": "Output format (default: auto - detects from content type)", + }, + }, + "required": []string{"url"}, + }, + }, + }, nil +} + +func (e *Executor) CallTool(ctx context.Context, _ mcpgw.ToolSessionContext, toolName string, arguments map[string]any) (map[string]any, error) { + if toolName != toolWebFetch { + return nil, mcpgw.ErrToolNotFound + } + + rawURL := strings.TrimSpace(mcpgw.StringArg(arguments, "url")) + if rawURL == "" { + return mcpgw.BuildToolErrorResult("url is required"), nil + } + format := strings.TrimSpace(mcpgw.StringArg(arguments, "format")) + if format == "" { + format = "auto" + } + + return e.callWebFetch(ctx, rawURL, format) +} + +func (e *Executor) callWebFetch(ctx context.Context, rawURL, format string) (map[string]any, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return mcpgw.BuildToolErrorResult(fmt.Sprintf("invalid url: %v", err)), nil + } + req.Header.Set("User-Agent", userAgent) + + resp, err := e.client.Do(req) //nolint:gosec // intentionally fetches user-specified URLs + if err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return mcpgw.BuildToolErrorResult(fmt.Sprintf("HTTP error: %d %s", resp.StatusCode, resp.Status)), nil + } + + contentType := resp.Header.Get("Content-Type") + detected := format + if format == "auto" { + detected = detectFormat(contentType) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return mcpgw.BuildToolErrorResult(err.Error()), nil + } + + switch detected { + case "json": + return e.processJSON(rawURL, contentType, body) + case "xml": + return e.processXML(rawURL, contentType, body) + case "markdown": + return e.processHTML(rawURL, contentType, body) + default: + return e.processText(rawURL, contentType, body) + } +} + +func detectFormat(contentType string) string { + ct := strings.ToLower(contentType) + switch { + case strings.Contains(ct, "application/json"): + return "json" + case strings.Contains(ct, "application/xml"), strings.Contains(ct, "text/xml"): + return "xml" + case strings.Contains(ct, "text/html"): + return "markdown" + default: + return "text" + } +} + +func (e *Executor) processJSON(fetchedURL, contentType string, body []byte) (map[string]any, error) { + var data any + if err := json.Unmarshal(body, &data); err != nil { + return mcpgw.BuildToolErrorResult("Failed to parse JSON"), nil + } + return mcpgw.BuildToolSuccessResult(map[string]any{ + "success": true, + "url": fetchedURL, + "format": "json", + "contentType": contentType, + "data": data, + }), nil +} + +func (e *Executor) processXML(fetchedURL, contentType string, body []byte) (map[string]any, error) { + content := string(body) + if len(content) > maxTextContent { + content = content[:maxTextContent] + } + return mcpgw.BuildToolSuccessResult(map[string]any{ + "success": true, + "url": fetchedURL, + "format": "xml", + "contentType": contentType, + "content": content, + }), nil +} + +func (e *Executor) processHTML(fetchedURL, contentType string, body []byte) (map[string]any, error) { + parsed, err := url.Parse(fetchedURL) + if err != nil { + parsed = &url.URL{} + } + + article, err := readability.FromReader(strings.NewReader(string(body)), parsed) + if err != nil { + return mcpgw.BuildToolErrorResult(fmt.Sprintf("Failed to extract readable content from HTML: %v", err)), nil + } + + if strings.TrimSpace(article.Content) == "" { + return mcpgw.BuildToolErrorResult("Failed to extract readable content from HTML"), nil + } + + markdown, err := htmltomarkdown.ConvertString(article.Content) + if err != nil { + e.logger.Warn("html-to-markdown conversion failed, falling back to text", slog.Any("error", err)) + markdown = article.TextContent + } + + textPreview := article.TextContent + if len(textPreview) > 500 { + textPreview = textPreview[:500] + } + + return mcpgw.BuildToolSuccessResult(map[string]any{ + "success": true, + "url": fetchedURL, + "format": "markdown", + "contentType": contentType, + "title": article.Title, + "byline": article.Byline, + "excerpt": article.Excerpt, + "content": markdown, + "textContent": textPreview, + "length": article.Length, + }), nil +} + +func (e *Executor) processText(fetchedURL, contentType string, body []byte) (map[string]any, error) { + content := string(body) + length := len(content) + if length > maxTextContent { + content = content[:maxTextContent] + } + return mcpgw.BuildToolSuccessResult(map[string]any{ + "success": true, + "url": fetchedURL, + "format": "text", + "contentType": contentType, + "content": content, + "length": length, + }), nil +} diff --git a/internal/mcp/tool_types.go b/internal/mcp/tool_types.go index a89b0d10..c06bfb90 100644 --- a/internal/mcp/tool_types.go +++ b/internal/mcp/tool_types.go @@ -17,6 +17,7 @@ type ToolSessionContext struct { SessionToken string `json:"-"` CurrentPlatform string ReplyTarget string + IsSubagent bool } // ToolDescriptor is the MCP tools/list item shape used by the gateway. diff --git a/packages/agent/src/agent.ts b/packages/agent/src/agent.ts index 67f67046..75430f05 100644 --- a/packages/agent/src/agent.ts +++ b/packages/agent/src/agent.ts @@ -14,7 +14,6 @@ import { AgentParams, AgentSkill, AgentStreamAction, - allActions, Heartbeat, MCPConnection, Schedule, @@ -32,7 +31,6 @@ import { } from './utils/attachments' import type { GatewayInputAttachment } from './types/attachment' import { getMCPTools } from './tools/mcp' -import { getTools } from './tools' import { buildIdentityHeaders } from './utils/headers' import { createFS } from './utils' import { createTextLoopGuard, createTextLoopProbeBuffer } from './sential' @@ -95,7 +93,6 @@ export const createAgent = ( model: modelConfig, activeContextTime = 24 * 60, language = 'Same as the user input', - allowedActions = allActions, channels = [], skills = [], mcpConnections = [], @@ -109,6 +106,7 @@ export const createAgent = ( auth, inbox = [], loopDetection = { enabled: false }, + isSubagent = false, }: AgentParams, fetch: AuthFetcher, ) => { @@ -179,7 +177,7 @@ export const createAgent = ( close: async () => {}, } } - const headers = buildIdentityHeaders(identity, auth) + const headers = buildIdentityHeaders(identity, auth, { isSubagent }) const builtins: MCPConnection[] = [ { type: 'http', @@ -196,15 +194,8 @@ export const createAgent = ( botId, }, ) - const tools = getTools(allowedActions, { - fetch, - model: modelConfig, - identity, - auth, - enableSkill, - }) return { - tools: { ...mcpTools, ...tools } as ToolSet, + tools: mcpTools as ToolSet, close: closeMCP, } } @@ -282,16 +273,27 @@ export const createAgent = ( ...(providerOptions && { providerOptions }), stopWhen: stepCountIs(Infinity), prepareStep, - ...(loopDetectionEnabled && { - onStepFinish: ({ text }: { text: string }) => { + onStepFinish: ({ text, toolResults }: { text: string; toolResults: Array<{ toolName: string; result: unknown }> }) => { + if (loopDetectionEnabled) { if (shouldAbortForToolLoop) { throw new Error(TOOL_LOOP_DETECTED_ABORT_MESSAGE) } if (inspectTextLoop) { inspectTextLoop(text) } - }, - }), + } + if (toolResults) { + for (const tr of toolResults) { + if (tr.toolName === 'use_skill') { + const result = tr.result as Record | undefined + const skillName = typeof result?.skillName === 'string' ? result.skillName : '' + if (skillName) { + enableSkill(skillName) + } + } + } + } + }, tools: guardedTools, }) } catch (error) { @@ -661,6 +663,11 @@ export const createAgent = ( result: chunk.output, metadata: chunk, } + if (chunk.toolName === 'use_skill') { + const res = chunk.output as Record | undefined + const sn = typeof res?.skillName === 'string' ? res.skillName : '' + if (sn) enableSkill(sn) + } if (shouldAbortForToolLoop) { throw new Error(TOOL_LOOP_DETECTED_ABORT_MESSAGE) } diff --git a/packages/agent/src/tools/index.ts b/packages/agent/src/tools/index.ts index ed1659a2..75e700c2 100644 --- a/packages/agent/src/tools/index.ts +++ b/packages/agent/src/tools/index.ts @@ -1,39 +1 @@ -import { AuthFetcher } from '../types' -import { AgentAction, AgentAuthContext, IdentityContext, ModelConfig } from '../types' -import { ToolSet } from 'ai' -import { getWebTools } from './web' -import { getSubagentTools } from './subagent' -import { getSkillTools } from './skill' - -export interface ToolsParams { - fetch: AuthFetcher - model: ModelConfig - identity: IdentityContext - auth: AgentAuthContext - enableSkill: (skill: string) => void -} - -export const getTools = ( - actions: AgentAction[], - { fetch, model, identity, auth, enableSkill }: ToolsParams -) => { - const tools: ToolSet = {} - if (actions.includes(AgentAction.Web)) { - const webTools = getWebTools() - Object.assign(tools, webTools) - } - if (actions.includes(AgentAction.Subagent)) { - const subagentTools = getSubagentTools({ fetch, model, identity, auth }) - Object.assign(tools, subagentTools) - } - if (actions.includes(AgentAction.Skill)) { - const skillTools = getSkillTools({ useSkill: enableSkill }) - Object.assign(tools, skillTools) - } - return tools -} - -export * from './web' -export * from './subagent' -export * from './skill' export * from './mcp' diff --git a/packages/agent/src/tools/skill.ts b/packages/agent/src/tools/skill.ts deleted file mode 100644 index d911b841..00000000 --- a/packages/agent/src/tools/skill.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { tool } from 'ai' -import { z } from 'zod' - -interface SkillToolParams { - useSkill: (skill: string) => void -} - -export const getSkillTools = ({ useSkill }: SkillToolParams) => { - const useSkillTool = tool({ - description: 'Use a skill if you think it is relevant to the current task', - inputSchema: z.object({ - skillName: z.string().describe('The name of the skill to use'), - reason: z.string().describe('The reason why you think this skill is relevant to the current task'), - }), - execute: async ({ skillName, reason }) => { - useSkill(skillName) - return { - success: true, - skillName, - reason, - } - }, - }) - - return { - 'use_skill': useSkillTool, - } -} \ No newline at end of file diff --git a/packages/agent/src/tools/subagent.ts b/packages/agent/src/tools/subagent.ts deleted file mode 100644 index c6237c82..00000000 --- a/packages/agent/src/tools/subagent.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { tool, type ModelMessage } from 'ai' -import { z } from 'zod' -import { createAgent } from '../agent' -import type { ModelConfig, AgentAuthContext, AuthFetcher } from '../types' -import { AgentAction, type IdentityContext } from '../types/agent' -import { - createSubagentClient, - toSubagentUsage, - addUsage, -} from '../utils/subagent' - -export interface SubagentToolParams { - fetch: AuthFetcher - model: ModelConfig - identity: IdentityContext - auth: AgentAuthContext -} - -export const getSubagentTools = ({ fetch, model, identity, auth }: SubagentToolParams) => { - const botId = identity.botId.trim() - const client = createSubagentClient(fetch, botId) - - const listSubagents = tool({ - description: 'List subagents for current user', - inputSchema: z.object({}), - execute: async () => { - if (!botId) { - throw new Error('bot_id is required') - } - return client.list() - }, - }) - - const deleteSubagent = tool({ - description: 'Delete a subagent by id', - inputSchema: z.object({ - id: z.string().describe('Subagent ID'), - }), - execute: async ({ id }) => { - if (!botId) { - throw new Error('bot_id is required') - } - return client.remove(id) - }, - }) - - const querySubagent = tool({ - description: 'Query a subagent. If the subagent does not exist it will be created automatically.', - inputSchema: z.object({ - name: z.string().describe('The name of the subagent'), - description: z.string().describe('A short description of the subagent purpose (used when creating)'), - query: z.string().describe('The prompt to ask the subagent to do.'), - }), - execute: async ({ name, description, query }) => { - if (!botId) { - throw new Error('bot_id is required') - } - - // Get or create the subagent - const target = await client.getOrCreate({ name, description }) - - // Load persisted context (messages + usage) - const ctx = await client.getContext(target.id) - const contextMessages = (Array.isArray(ctx.messages) ? ctx.messages : []) as ModelMessage[] - const existingUsage = toSubagentUsage(ctx.usage) - - // Create a scoped agent instance for the subagent - const { askAsSubagent } = createAgent({ - model, - allowedActions: [AgentAction.Web], - identity, - auth, - }, fetch) - - const result = await askAsSubagent({ - messages: contextMessages, - input: query, - name: target.name, - description: target.description, - }) - - // Accumulate usage - const newUsage = addUsage(existingUsage, result.usage) - - // Persist updated messages + usage - const updatedMessages = [...contextMessages, ...result.messages] - await client.updateContext( - target.id, - updatedMessages as Record[], - newUsage, - ) - - return { - success: true, - result: result.messages[result.messages.length - 1].content, - } - }, - }) - - return { - 'list_subagents': listSubagents, - 'delete_subagent': deleteSubagent, - 'query_subagent': querySubagent, - } -} diff --git a/packages/agent/src/tools/web.ts b/packages/agent/src/tools/web.ts deleted file mode 100644 index 1d63113e..00000000 --- a/packages/agent/src/tools/web.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { tool } from 'ai' -import { z } from 'zod' -import { Readability } from '@mozilla/readability' -import { parseHTML } from 'linkedom' -import TurndownService from 'turndown' - -const turndownService = new TurndownService() - -export const getWebTools = () => { - const webFetch = tool({ - description: 'Fetch a URL and convert the response to readable content. Supports HTML (converts to Markdown), JSON, XML, and plain text formats.', - inputSchema: z.object({ - url: z.string().describe('The URL to fetch'), - format: z.enum(['auto', 'markdown', 'json', 'xml', 'text']).optional().describe('Output format (default: auto - detects from content type)'), - }), - execute: async ({ url, format = 'auto' }) => { - try { - const response = await fetch(url, { - headers: { - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36', - }, - }) - - if (!response.ok) { - throw new Error(`HTTP error: ${response.status} ${response.statusText}`) - } - - const contentType = response.headers.get('content-type') || '' - let detectedFormat = format - - // Auto-detect format from content type - if (format === 'auto') { - if (contentType.includes('application/json')) { - detectedFormat = 'json' - } else if (contentType.includes('application/xml') || contentType.includes('text/xml')) { - detectedFormat = 'xml' - } else if (contentType.includes('text/html')) { - detectedFormat = 'markdown' - } else { - detectedFormat = 'text' - } - } - - const content = await response.text() - - // Process based on format - switch (detectedFormat) { - case 'json': { - try { - const jsonData = JSON.parse(content) - return { - success: true, - url, - format: 'json', - contentType, - data: jsonData, - } - } catch { - return { - success: false, - error: 'Failed to parse JSON', - url, - } - } - } - - case 'xml': { - return { - success: true, - url, - format: 'xml', - contentType, - content, - } - } - - case 'markdown': { - try { - const { document } = parseHTML(content) - const reader = new Readability(document as unknown as Document) - const article = reader.parse() - - if (!article || !article.content) { - return { - success: false, - error: 'Failed to extract readable content from HTML', - url, - } - } - - const markdown = turndownService.turndown(article.content) - - return { - success: true, - url, - format: 'markdown', - contentType, - title: article.title, - byline: article.byline, - excerpt: article.excerpt, - content: markdown, - textContent: article.textContent?.substring(0, 500), // First 500 chars as preview - length: article.length, - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to process HTML', - url, - } - } - } - - case 'text': - default: { - return { - success: true, - url, - format: 'text', - contentType, - content: content.substring(0, 10000), // Limit to 10KB - length: content.length, - } - } - } - } catch (error) { - return { - success: false, - error: error instanceof Error ? error.message : 'Unknown error occurred', - url, - } - } - }, - }) - - return { - 'web_fetch': webFetch, - } -} diff --git a/packages/agent/src/types/agent.ts b/packages/agent/src/types/agent.ts index 83948907..1fdb53c0 100644 --- a/packages/agent/src/types/agent.ts +++ b/packages/agent/src/types/agent.ts @@ -18,18 +18,6 @@ export interface AgentAuthContext { baseUrl: string } -export enum AgentAction { - Web = 'web', - Message = 'message', - Contact = 'contact', - Subagent = 'subagent', - Schedule = 'schedule', - Skill = 'skill', - Memory = 'memory', -} - -export const allActions = Object.values(AgentAction) - export interface InboxItem { id: string source: string @@ -46,7 +34,6 @@ export interface AgentParams { model: ModelConfig language?: string activeContextTime?: number - allowedActions?: AgentAction[] mcpConnections?: MCPConnection[] channels?: string[] currentChannel?: string @@ -55,6 +42,7 @@ export interface AgentParams { skills?: AgentSkill[] inbox?: InboxItem[] loopDetection?: LoopDetectionConfig + isSubagent?: boolean } export interface AgentInput { diff --git a/packages/agent/src/utils/headers.ts b/packages/agent/src/utils/headers.ts index ea1c6ae4..2340e6b6 100644 --- a/packages/agent/src/utils/headers.ts +++ b/packages/agent/src/utils/headers.ts @@ -1,6 +1,10 @@ import { AgentAuthContext, IdentityContext } from '../types' -export const buildIdentityHeaders = (identity: IdentityContext, auth: AgentAuthContext) => { +export interface BuildHeadersOptions { + isSubagent?: boolean +} + +export const buildIdentityHeaders = (identity: IdentityContext, auth: AgentAuthContext, options?: BuildHeadersOptions) => { const headers: Record = { Authorization: `Bearer ${auth.bearer}`, } @@ -13,5 +17,8 @@ export const buildIdentityHeaders = (identity: IdentityContext, auth: AgentAuthC if (identity.currentPlatform) { headers['X-Memoh-Current-Platform'] = identity.currentPlatform } + if (options?.isSubagent) { + headers['X-Memoh-Is-Subagent'] = 'true' + } return headers } \ No newline at end of file diff --git a/packages/agent/src/utils/index.ts b/packages/agent/src/utils/index.ts index cb616254..78e6a76a 100644 --- a/packages/agent/src/utils/index.ts +++ b/packages/agent/src/utils/index.ts @@ -1,4 +1,3 @@ export * from './attachments' export * from './fs' export * from './headers' -export * from './subagent' diff --git a/packages/agent/src/utils/subagent.ts b/packages/agent/src/utils/subagent.ts deleted file mode 100644 index 3175bd15..00000000 --- a/packages/agent/src/utils/subagent.ts +++ /dev/null @@ -1,131 +0,0 @@ -import type { AuthFetcher } from '../types' -import type { LanguageModelUsage } from 'ai' - -// --------------------------------------------------------------------------- -// Types -// --------------------------------------------------------------------------- - -export interface SubagentItem { - id: string - name: string - description: string - bot_id: string - messages: Record[] - metadata: Record - skills: string[] - usage: SubagentUsage - created_at: string - updated_at: string - deleted: boolean - deleted_at?: string -} - -export interface SubagentUsage { - inputTokens: number - outputTokens: number - totalTokens: number -} - -export interface SubagentListResponse { - items: SubagentItem[] -} - -export interface SubagentContextResponse { - messages: Record[] - usage: SubagentUsage -} - -// --------------------------------------------------------------------------- -// Usage helpers -// --------------------------------------------------------------------------- - -const emptyUsage: SubagentUsage = { - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, -} - -export const toSubagentUsage = (raw: unknown): SubagentUsage => { - if (!raw || typeof raw !== 'object') return { ...emptyUsage } - const obj = raw as Record - return { - inputTokens: typeof obj.inputTokens === 'number' ? obj.inputTokens : 0, - outputTokens: typeof obj.outputTokens === 'number' ? obj.outputTokens : 0, - totalTokens: typeof obj.totalTokens === 'number' ? obj.totalTokens : 0, - } -} - -export const addUsage = ( - existing: SubagentUsage, - delta: LanguageModelUsage, -): SubagentUsage => ({ - inputTokens: existing.inputTokens + (delta.inputTokens ?? 0), - outputTokens: existing.outputTokens + (delta.outputTokens ?? 0), - totalTokens: existing.totalTokens + (delta.totalTokens ?? 0), -}) - -// --------------------------------------------------------------------------- -// Client factory -// --------------------------------------------------------------------------- - -export const createSubagentClient = (fetch: AuthFetcher, botId: string) => { - const base = `/bots/${botId}/subagents` - - const list = async (): Promise => { - const res = await fetch(base, { method: 'GET' }) - return res.json() as Promise - } - - const create = async (params: { - name: string - description: string - }): Promise => { - const res = await fetch(base, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(params), - }) - return res.json() as Promise - } - - const get = async (id: string): Promise => { - const res = await fetch(`${base}/${id}`, { method: 'GET' }) - return res.json() as Promise - } - - const getContext = async (id: string): Promise => { - const res = await fetch(`${base}/${id}/context`, { method: 'GET' }) - return res.json() as Promise - } - - const updateContext = async ( - id: string, - messages: Record[], - usage: SubagentUsage, - ): Promise => { - const res = await fetch(`${base}/${id}/context`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ messages, usage }), - }) - return res.json() as Promise - } - - const getOrCreate = async (params: { - name: string - description: string - }): Promise => { - const { items } = await list() - const existing = items.find((item) => item.name === params.name) - if (existing) return existing - return create(params) - } - - const remove = async (id: string): Promise<{ success: boolean }> => { - const res = await fetch(`${base}/${id}`, { method: 'DELETE' }) - return res.status === 204 ? { success: true } : (res.json() as Promise<{ success: boolean }>) - } - - return { list, create, get, getContext, updateContext, getOrCreate, remove } -} -