fix: web, skills tools

This commit is contained in:
Acbox
2026-02-12 23:32:23 +08:00
parent 287b024887
commit b4797f8c52
6 changed files with 154 additions and 437 deletions
+154 -98
View File
@@ -1,5 +1,20 @@
import { generateText, ImagePart, LanguageModelUsage, ModelMessage, stepCountIs, streamText, UserModelMessage } from 'ai'
import { AgentInput, AgentParams, AgentSkill, allActions, Schedule } from './types'
import {
generateText,
ImagePart,
LanguageModelUsage,
ModelMessage,
stepCountIs,
streamText,
ToolSet,
UserModelMessage,
} from 'ai'
import {
AgentInput,
AgentParams,
AgentSkill,
allActions,
Schedule,
} from './types'
import { system, schedule, user, subagentSystem } from './prompts'
import { AuthFetcher } from './index'
import { createModel } from './model'
@@ -12,36 +27,40 @@ import {
} from './utils/attachments'
import type { ContainerFileAttachment } from './types/attachment'
import { getMCPTools } from './tools/mcp'
import { getTools } from './tools'
export const createAgent = ({
model: modelConfig,
activeContextTime = 24 * 60,
brave,
language = 'Same as the user input',
allowedActions = allActions,
channels = [],
skills = [],
currentChannel = 'Unknown Channel',
identity = {
botId: '',
containerId: '',
channelIdentityId: '',
displayName: '',
},
auth,
}: AgentParams, fetch: AuthFetcher) => {
export const createAgent = (
{
model: modelConfig,
activeContextTime = 24 * 60,
brave,
language = 'Same as the user input',
allowedActions = allActions,
channels = [],
skills = [],
currentChannel = 'Unknown Channel',
identity = {
botId: '',
containerId: '',
channelIdentityId: '',
displayName: '',
},
auth,
}: AgentParams,
fetch: AuthFetcher,
) => {
const model = createModel(modelConfig)
const enabledSkills: AgentSkill[] = []
const enableSkill = (skill: string) => {
const agentSkill = skills.find(s => s.name === skill)
const agentSkill = skills.find((s) => s.name === skill)
if (agentSkill) {
enabledSkills.push(agentSkill)
}
}
const getEnabledSkills = () => {
return enabledSkills.map(skill => skill.name)
return enabledSkills.map((skill) => skill.name)
}
const loadSystemFiles = async () => {
@@ -56,8 +75,8 @@ export const createAgent = ({
const url = `${auth.baseUrl.replace(/\/$/, '')}/bots/${identity.botId}/tools`
const headers: Record<string, string> = {
'Content-Type': 'application/json',
'Accept': 'application/json, text/event-stream',
'Authorization': `Bearer ${auth.bearer}`,
Accept: 'application/json, text/event-stream',
Authorization: `Bearer ${auth.bearer}`,
}
if (identity.channelIdentityId) {
headers['X-Memoh-Channel-Identity-Id'] = identity.channelIdentityId
@@ -70,8 +89,9 @@ export const createAgent = ({
})
const response = await fetch(url, { method: 'POST', headers, body })
if (!response.ok) return ''
const data = await response.json().catch(() => ({} as any))
const structured = data?.result?.structuredContent ?? data?.result?.content?.[0]?.text
const data = await response.json().catch(() => ({}))
const structured =
data?.result?.structuredContent ?? data?.result?.content?.[0]?.text
if (typeof structured === 'string') {
try {
const parsed = JSON.parse(structured)
@@ -98,7 +118,8 @@ export const createAgent = ({
}
const generateSystemPrompt = async () => {
const { identityContent, soulContent, toolsContent } = await loadSystemFiles()
const { identityContent, soulContent, toolsContent } =
await loadSystemFiles()
return system({
date: new Date(),
language,
@@ -123,7 +144,7 @@ export const createAgent = ({
}
}
const headers: Record<string, string> = {
'Authorization': `Bearer ${auth.bearer}`,
Authorization: `Bearer ${auth.bearer}`,
}
if (identity.channelIdentityId) {
headers['X-Memoh-Channel-Identity-Id'] = identity.channelIdentityId
@@ -137,16 +158,24 @@ export const createAgent = ({
if (identity.replyTarget) {
headers['X-Memoh-Reply-Target'] = identity.replyTarget
}
const { tools: mcpTools, close: closeMCP } = await getMCPTools(`${baseUrl}/bots/${botId}/tools`, headers)
const { tools: mcpTools, close: closeMCP } = await getMCPTools(
`${baseUrl}/bots/${botId}/tools`,
headers,
)
const tools = getTools(allowedActions, { fetch, model: modelConfig, brave, identity, auth, enableSkill })
return {
tools: mcpTools,
tools: { ...mcpTools, ...tools } as ToolSet,
close: closeMCP,
}
}
const generateUserPrompt = (input: AgentInput) => {
const images = input.attachments.filter(attachment => attachment.type === 'image')
const files = input.attachments.filter((a): a is ContainerFileAttachment => a.type === 'file')
const images = input.attachments.filter(
(attachment) => attachment.type === 'image',
)
const files = input.attachments.filter(
(a): a is ContainerFileAttachment => a.type === 'file',
)
const text = user(input.query, {
channelIdentityId: identity.channelIdentityId || identity.contactId || '',
displayName: identity.displayName || identity.contactName || 'User',
@@ -158,8 +187,10 @@ export const createAgent = ({
role: 'user',
content: [
{ type: 'text', text },
...images.map(image => ({ type: 'image', image: image.base64 }) as ImagePart),
]
...images.map(
(image) => ({ type: 'image', image: image.base64 }) as ImagePart,
),
],
}
return userMessage
}
@@ -167,7 +198,7 @@ export const createAgent = ({
const ask = async (input: AgentInput) => {
const userPrompt = generateUserPrompt(input)
const messages = [...input.messages, userPrompt]
input.skills.forEach(skill => enableSkill(skill))
input.skills.forEach((skill) => enableSkill(skill))
const systemPrompt = await generateSystemPrompt()
const { tools, close } = await getAgentTools()
const { response, reasoning, text, usage } = await generateText({
@@ -185,12 +216,17 @@ export const createAgent = ({
},
tools,
})
const { cleanedText, attachments: textAttachments } = extractAttachmentsFromText(text)
const { messages: strippedMessages, attachments: messageAttachments } = stripAttachmentsFromMessages(response.messages)
const allAttachments = dedupeAttachments([...textAttachments, ...messageAttachments])
const { cleanedText, attachments: textAttachments } =
extractAttachmentsFromText(text)
const { messages: strippedMessages, attachments: messageAttachments } =
stripAttachmentsFromMessages(response.messages)
const allAttachments = dedupeAttachments([
...textAttachments,
...messageAttachments,
])
return {
messages: strippedMessages,
reasoning: reasoning.map(part => part.text),
reasoning: reasoning.map((part) => part.text),
usage,
text: cleanedText,
attachments: allAttachments,
@@ -199,16 +235,14 @@ export const createAgent = ({
}
const askAsSubagent = async (params: {
input: string
name: string
description: string
messages: ModelMessage[]
input: string;
name: string;
description: string;
messages: ModelMessage[];
}) => {
const userPrompt: UserModelMessage = {
role: 'user',
content: [
{ type: 'text', text: params.input },
]
content: [{ type: 'text', text: params.input }],
}
const generateSubagentSystemPrompt = () => {
return subagentSystem({
@@ -236,7 +270,7 @@ export const createAgent = ({
})
return {
messages: [userPrompt, ...response.messages],
reasoning: reasoning.map(part => part.text),
reasoning: reasoning.map((part) => part.text),
usage,
text,
skills: getEnabledSkills(),
@@ -244,18 +278,21 @@ export const createAgent = ({
}
const triggerSchedule = async (params: {
schedule: Schedule
messages: ModelMessage[]
skills: string[]
schedule: Schedule;
messages: ModelMessage[];
skills: string[];
}) => {
const scheduleMessage: UserModelMessage = {
role: 'user',
content: [
{ type: 'text', text: schedule({ schedule: params.schedule, date: new Date() }) },
]
{
type: 'text',
text: schedule({ schedule: params.schedule, date: new Date() }),
},
],
}
const messages = [...params.messages, scheduleMessage]
params.skills.forEach(skill => enableSkill(skill))
params.skills.forEach((skill) => enableSkill(skill))
const { tools, close } = await getAgentTools()
const { response, reasoning, text, usage } = await generateText({
model,
@@ -269,7 +306,7 @@ export const createAgent = ({
})
return {
messages: [scheduleMessage, ...response.messages],
reasoning: reasoning.map(part => part.text),
reasoning: reasoning.map((part) => part.text),
usage,
text,
skills: getEnabledSkills(),
@@ -301,17 +338,17 @@ export const createAgent = ({
async function* stream(input: AgentInput): AsyncGenerator<AgentAction> {
const userPrompt = generateUserPrompt(input)
const messages = [...input.messages, userPrompt]
input.skills.forEach(skill => enableSkill(skill))
input.skills.forEach((skill) => enableSkill(skill))
const systemPrompt = await generateSystemPrompt()
const attachmentsExtractor = new AttachmentsStreamExtractor()
const result: {
messages: ModelMessage[]
reasoning: string[]
usage: LanguageModelUsage | null
messages: ModelMessage[];
reasoning: string[];
usage: LanguageModelUsage | null;
} = {
messages: [],
reasoning: [],
usage: null
usage: null,
}
const { tools, close } = await getAgentTools()
const { fullStream } = streamText({
@@ -328,9 +365,9 @@ export const createAgent = ({
onFinish: async ({ usage, reasoning, response }) => {
await close()
result.usage = usage as never
result.reasoning = reasoning.map(part => part.text)
result.reasoning = reasoning.map((part) => part.text)
result.messages = response.messages
}
},
})
yield {
type: 'agent_start',
@@ -338,26 +375,38 @@ export const createAgent = ({
}
for await (const chunk of fullStream) {
if (chunk.type === 'error') {
throw new Error(resolveStreamErrorMessage((chunk as { error?: unknown }).error))
throw new Error(
resolveStreamErrorMessage((chunk as { error?: unknown }).error),
)
}
switch (chunk.type) {
case 'reasoning-start': yield {
type: 'reasoning_start',
metadata: chunk
}; break
case 'reasoning-delta': yield {
type: 'reasoning_delta',
delta: chunk.text
}; break
case 'reasoning-end': yield {
type: 'reasoning_end',
metadata: chunk
}; break
case 'text-start': yield {
type: 'text_start',
}; break
case 'reasoning-start':
yield {
type: 'reasoning_start',
metadata: chunk,
}
break
case 'reasoning-delta':
yield {
type: 'reasoning_delta',
delta: chunk.text,
}
break
case 'reasoning-end':
yield {
type: 'reasoning_end',
metadata: chunk,
}
break
case 'text-start':
yield {
type: 'text_start',
}
break
case 'text-delta': {
const { visibleText, attachments } = attachmentsExtractor.push(chunk.text)
const { visibleText, attachments } = attachmentsExtractor.push(
chunk.text,
)
if (visibleText) {
yield {
type: 'text_delta',
@@ -393,30 +442,37 @@ export const createAgent = ({
}
break
}
case 'tool-call': yield {
type: 'tool_call_start',
toolName: chunk.toolName,
toolCallId: chunk.toolCallId,
input: chunk.input,
metadata: chunk
}; break
case 'tool-result': yield {
type: 'tool_call_end',
toolName: chunk.toolName,
toolCallId: chunk.toolCallId,
input: chunk.input,
result: chunk.output,
metadata: chunk
}; break
case 'file': yield {
type: 'image_delta',
image: chunk.file.base64,
metadata: chunk
}
case 'tool-call':
yield {
type: 'tool_call_start',
toolName: chunk.toolName,
toolCallId: chunk.toolCallId,
input: chunk.input,
metadata: chunk,
}
break
case 'tool-result':
yield {
type: 'tool_call_end',
toolName: chunk.toolName,
toolCallId: chunk.toolCallId,
input: chunk.input,
result: chunk.output,
metadata: chunk,
}
break
case 'file':
yield {
type: 'image_delta',
image: chunk.file.base64,
metadata: chunk,
}
}
}
const { messages: strippedMessages } = stripAttachmentsFromMessages(result.messages)
const { messages: strippedMessages } = stripAttachmentsFromMessages(
result.messages,
)
yield {
type: 'agent_end',
messages: strippedMessages,
-75
View File
@@ -1,75 +0,0 @@
import { tool } from 'ai'
import { z } from 'zod'
import { AuthFetcher } from '..'
import type { IdentityContext } from '../types'
export type ContactToolParams = {
fetch: AuthFetcher
identity: IdentityContext
}
export const getContactTools = ({ fetch, identity }: ContactToolParams) => {
const botId = identity.botId.trim()
const listMyIdentities = async () => {
const response = await fetch('/users/me/identities')
return response.json()
}
const contactSearch = tool({
description: 'Search identity cards by platform, external id, or display name',
inputSchema: z.object({
query: z.string().describe('The query to search identities').optional().default(''),
}),
execute: async ({ query }) => {
const payload = await listMyIdentities()
const keyword = query.trim().toLowerCase()
const items = Array.isArray(payload?.items) ? payload.items : []
const filtered = keyword
? items.filter((item: { platform?: string; external_id?: string; display_name?: string }) => {
const platform = String(item?.platform ?? '').toLowerCase()
const externalID = String(item?.external_id ?? '').toLowerCase()
const displayName = String(item?.display_name ?? '').toLowerCase()
return platform.includes(keyword) || externalID.includes(keyword) || displayName.includes(keyword)
})
: items
return {
canonical_channel_identity_id: payload?.canonical_channel_identity_id ?? '',
total: filtered.length,
items: filtered,
}
},
})
const contactCardMe = tool({
description: 'Get my canonical identity card and all linked channel identities',
inputSchema: z.object({}),
execute: async () => {
return listMyIdentities()
},
})
const contactIssueBindCode = tool({
description: 'Issue a bind code for linking current channel identity to this account',
inputSchema: z.object({
ttl_seconds: z.number().int().positive().optional().describe('Bind code ttl in seconds'),
}),
execute: async ({ ttl_seconds }) => {
if (!botId) {
throw new Error('bot_id is required')
}
const response = await fetch(`/bots/${botId}/bind_codes`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ttl_seconds }),
})
return response.json()
},
})
return {
'contact_search': contactSearch,
'contact_card_me': contactCardMe,
'contact_issue_bind_code': contactIssueBindCode,
}
}
-20
View File
@@ -2,11 +2,7 @@ import { AuthFetcher } from '..'
import { AgentAction, AgentAuthContext, BraveConfig, IdentityContext, ModelConfig } from '../types'
import { ToolSet } from 'ai'
import { getWebTools } from './web'
import { getScheduleTools } from './schedule'
import { getMemoryTools } from './memory'
import { getSubagentTools } from './subagent'
import { getContactTools } from './contact'
import { getMessageTools } from './message'
import { getSkillTools } from './skill'
export interface ToolsParams {
@@ -27,26 +23,10 @@ export const getTools = (
const webTools = getWebTools({ brave })
Object.assign(tools, webTools)
}
if (actions.includes(AgentAction.Schedule)) {
const scheduleTools = getScheduleTools({ fetch, identity })
Object.assign(tools, scheduleTools)
}
if (actions.includes(AgentAction.Memory)) {
const memoryTools = getMemoryTools({ fetch, identity })
Object.assign(tools, memoryTools)
}
if (actions.includes(AgentAction.Subagent)) {
const subagentTools = getSubagentTools({ fetch, model, brave, identity, auth })
Object.assign(tools, subagentTools)
}
if (actions.includes(AgentAction.Contact)) {
const contactTools = getContactTools({ fetch, identity })
Object.assign(tools, contactTools)
}
if (actions.includes(AgentAction.Message)) {
const messageTools = getMessageTools({ fetch, identity })
Object.assign(tools, messageTools)
}
if (actions.includes(AgentAction.Skill)) {
const skillTools = getSkillTools({ useSkill: enableSkill })
Object.assign(tools, skillTools)
-63
View File
@@ -1,63 +0,0 @@
import { tool } from 'ai'
import { AuthFetcher } from '..'
import type { IdentityContext } from '../types'
import { z } from 'zod'
export type MemoryToolParams = {
fetch: AuthFetcher
identity: IdentityContext
}
type MemorySearchItem = {
id?: string
memory?: string
score?: number
createdAt?: string
metadata?: {
source?: string
}
}
export const getMemoryTools = ({ fetch, identity }: MemoryToolParams) => {
const searchMemory = tool({
description: 'Search for memories',
inputSchema: z.object({
query: z.string().describe('The query to search for memories'),
limit: z.number().int().positive().max(50).optional(),
}),
execute: async ({ query, limit }) => {
const botId = identity.botId.trim()
if (!botId) {
throw new Error('botId is required to search memory')
}
const response = await fetch(`/bots/${botId}/memory/search`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
query,
limit,
}),
})
const data = await response.json()
const results = Array.isArray(data?.results)
? (data.results as MemorySearchItem[])
: []
const simplified = results.map((item) => ({
id: item?.id,
memory: item?.memory,
score: item?.score,
}))
return {
query,
total: simplified.length,
results: simplified,
}
},
})
return {
'search_memory': searchMemory,
}
}
-83
View File
@@ -1,83 +0,0 @@
import { tool } from 'ai'
import { z } from 'zod'
import { AuthFetcher } from '..'
import type { IdentityContext } from '../types'
export type MessageToolParams = {
fetch: AuthFetcher
identity: IdentityContext
}
const SendMessageSchema = z.object({
bot_id: z.string().optional(),
platform: z.string().optional(),
target: z.string().optional(),
channel_identity_id: z.string().optional(),
to_user_id: z.string().optional(),
message: z.string(),
})
export const getMessageTools = ({ fetch, identity }: MessageToolParams) => {
const sendMessage = tool({
description: 'Send a message to a channel or session',
inputSchema: SendMessageSchema,
execute: async (payload) => {
const botId = (payload.bot_id ?? identity.botId ?? '').trim()
const platform = (payload.platform ?? identity.currentPlatform ?? '').trim()
const replyTarget = (identity.replyTarget ?? '').trim()
const target = (payload.target ?? replyTarget).trim()
const channelIdentityID = (payload.channel_identity_id ?? payload.to_user_id ?? '').trim()
if (!botId) {
throw new Error('bot_id is required')
}
if (!platform) {
throw new Error('platform is required')
}
// Prefer chat token when there is no explicit target identity.
const useSessionToken = !!identity.sessionToken && !channelIdentityID
if (!target && !channelIdentityID && !useSessionToken) {
throw new Error('target or channel_identity_id is required')
}
console.log('[Tool] send_message', {
botId,
platform,
target: target || undefined,
channelIdentityID: channelIdentityID || undefined,
replyTarget,
useSessionToken,
})
const body: Record<string, unknown> = {
message: {
text: payload.message,
},
}
if (target) {
body.target = target
}
if (channelIdentityID) {
body.channel_identity_id = channelIdentityID
}
const url = useSessionToken
? `/bots/${botId}/channel/${platform}/send_chat`
: `/bots/${botId}/channel/${platform}/send`
const headers: Record<string, string> = { 'Content-Type': 'application/json' }
if (useSessionToken && identity.sessionToken) {
headers.Authorization = `Bearer ${identity.sessionToken}`
}
const response = await fetch(url, {
method: 'POST',
headers,
body: JSON.stringify(body),
})
const result = await response.json()
return {
...result,
instruction: 'Message delivered successfully. You have completed your response. Please STOP now and do not call any more tools.',
}
},
})
return {
'send_message': sendMessage,
}
}
-98
View File
@@ -1,98 +0,0 @@
import { tool } from 'ai'
import { z } from 'zod'
import { AuthFetcher } from '..'
import type { IdentityContext } from '../types'
export type ScheduleToolParams = {
fetch: AuthFetcher
identity: IdentityContext
}
const ScheduleSchema = z.object({
name: z.string(),
description: z.string(),
pattern: z.string(),
max_calls: z.number().nullable().optional(),
enabled: z.boolean(),
command: z.string(),
})
export const getScheduleTools = ({ fetch, identity }: ScheduleToolParams) => {
const botId = identity.botId.trim()
const base = `/bots/${botId}/schedule`
const listSchedules = tool({
description: 'List schedules for current user',
inputSchema: z.object({}),
execute: async () => {
const response = await fetch(base, { method: 'GET' })
return response.json()
},
})
const getSchedule = tool({
description: 'Get a schedule by id',
inputSchema: z.object({
id: z.string().describe('Schedule ID'),
}),
execute: async ({ id }) => {
const response = await fetch(`${base}/${id}`, { method: 'GET' })
return response.json()
},
})
const createSchedule = tool({
description: 'Create a new schedule',
inputSchema: z.object({
name: z.string(),
description: z.string(),
pattern: z.string(),
max_calls: z.number().nullable().optional().default(null).describe('Max calls (optional, empty for unlimited)'),
enabled: z.boolean().optional(),
command: z.string(),
}),
execute: async (payload) => {
const response = await fetch(base, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
return response.json()
},
})
const updateSchedule = tool({
description: 'Update an existing schedule',
inputSchema: ScheduleSchema.partial().extend({
id: z.string(),
}),
execute: async (payload) => {
const { id, ...body } = payload
const response = await fetch(`${base}/${id}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
return response.json()
},
})
const deleteSchedule = tool({
description: 'Delete a schedule',
inputSchema: z.object({
id: z.string(),
}),
execute: async ({ id }) => {
const response = await fetch(`${base}/${id}`, { method: 'DELETE' })
return response.status === 204 ? { success: true } : response.json()
},
})
return {
'schedule_list': listSchedules,
'schedule_get': getSchedule,
'schedule_create': createSchedule,
'schedule_update': updateSchedule,
'schedule_delete': deleteSchedule,
}
}