feat: move all tools from @memoh/agent into built-in mcp

This commit is contained in:
Acbox
2026-03-06 16:48:18 +08:00
parent b8c0bf5d16
commit 4109a141f9
21 changed files with 789 additions and 485 deletions
-3
View File
@@ -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'),
+33 -5
View File
@@ -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
View File
@@ -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)
+7
View File
@@ -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
+57
View File
@@ -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=
-2
View File
@@ -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,
-1
View File
@@ -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"`
}
+3
View File
@@ -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,
}
}
+72
View File
@@ -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
}
+356
View File
@@ -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
}
}
+220
View File
@@ -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
}
+1
View File
@@ -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
View File
@@ -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)
}
-38
View File
@@ -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'
-28
View File
@@ -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,
}
}
-105
View File
@@ -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,
}
}
-139
View File
@@ -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,
}
}
+1 -13
View File
@@ -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 {
+8 -1
View File
@@ -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
View File
@@ -1,4 +1,3 @@
export * from './attachments'
export * from './fs'
export * from './headers'
export * from './subagent'
-131
View File
@@ -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 }
}