mirror of
https://github.com/memohai/Memoh.git
synced 2026-04-25 07:00:48 +09:00
feat: move all tools from @memoh/agent into built-in mcp
This commit is contained in:
@@ -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'),
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
+8
-2
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
+22
-15
@@ -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<string, unknown> | 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<string, unknown> | undefined
|
||||
const sn = typeof res?.skillName === 'string' ? res.skillName : ''
|
||||
if (sn) enableSkill(sn)
|
||||
}
|
||||
if (shouldAbortForToolLoop) {
|
||||
throw new Error(TOOL_LOOP_DETECTED_ABORT_MESSAGE)
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>[],
|
||||
newUsage,
|
||||
)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
result: result.messages[result.messages.length - 1].content,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return {
|
||||
'list_subagents': listSubagents,
|
||||
'delete_subagent': deleteSubagent,
|
||||
'query_subagent': querySubagent,
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<string, string> = {
|
||||
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
|
||||
}
|
||||
@@ -1,4 +1,3 @@
|
||||
export * from './attachments'
|
||||
export * from './fs'
|
||||
export * from './headers'
|
||||
export * from './subagent'
|
||||
|
||||
@@ -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<string, unknown>[]
|
||||
metadata: Record<string, unknown>
|
||||
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<string, unknown>[]
|
||||
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<string, unknown>
|
||||
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<SubagentListResponse> => {
|
||||
const res = await fetch(base, { method: 'GET' })
|
||||
return res.json() as Promise<SubagentListResponse>
|
||||
}
|
||||
|
||||
const create = async (params: {
|
||||
name: string
|
||||
description: string
|
||||
}): Promise<SubagentItem> => {
|
||||
const res = await fetch(base, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(params),
|
||||
})
|
||||
return res.json() as Promise<SubagentItem>
|
||||
}
|
||||
|
||||
const get = async (id: string): Promise<SubagentItem> => {
|
||||
const res = await fetch(`${base}/${id}`, { method: 'GET' })
|
||||
return res.json() as Promise<SubagentItem>
|
||||
}
|
||||
|
||||
const getContext = async (id: string): Promise<SubagentContextResponse> => {
|
||||
const res = await fetch(`${base}/${id}/context`, { method: 'GET' })
|
||||
return res.json() as Promise<SubagentContextResponse>
|
||||
}
|
||||
|
||||
const updateContext = async (
|
||||
id: string,
|
||||
messages: Record<string, unknown>[],
|
||||
usage: SubagentUsage,
|
||||
): Promise<SubagentContextResponse> => {
|
||||
const res = await fetch(`${base}/${id}/context`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages, usage }),
|
||||
})
|
||||
return res.json() as Promise<SubagentContextResponse>
|
||||
}
|
||||
|
||||
const getOrCreate = async (params: {
|
||||
name: string
|
||||
description: string
|
||||
}): Promise<SubagentItem> => {
|
||||
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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user