diff --git a/agent/src/agent.ts b/agent/src/agent.ts index 3866575d..2fee0426 100644 --- a/agent/src/agent.ts +++ b/agent/src/agent.ts @@ -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 = { '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 = { - '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 { 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, diff --git a/agent/src/tools/contact.ts b/agent/src/tools/contact.ts deleted file mode 100644 index de5e1c6a..00000000 --- a/agent/src/tools/contact.ts +++ /dev/null @@ -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, - } -} diff --git a/agent/src/tools/index.ts b/agent/src/tools/index.ts index 80456ac3..ff9a443e 100644 --- a/agent/src/tools/index.ts +++ b/agent/src/tools/index.ts @@ -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) diff --git a/agent/src/tools/memory.ts b/agent/src/tools/memory.ts deleted file mode 100644 index e2ed7b78..00000000 --- a/agent/src/tools/memory.ts +++ /dev/null @@ -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, - } -} \ No newline at end of file diff --git a/agent/src/tools/message.ts b/agent/src/tools/message.ts deleted file mode 100644 index c633c6a7..00000000 --- a/agent/src/tools/message.ts +++ /dev/null @@ -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 = { - 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 = { '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, - } -} diff --git a/agent/src/tools/schedule.ts b/agent/src/tools/schedule.ts deleted file mode 100644 index d44f4027..00000000 --- a/agent/src/tools/schedule.ts +++ /dev/null @@ -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, - } -} \ No newline at end of file