@owlmeans/bot-agent 0.0.0-stage → 0.1.18-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +63 -2
- package/agent-meta/manifest.json +16 -0
- package/agent-meta/skills/bot-agent/SKILL.md +184 -0
- package/build/consts.d.ts +27 -0
- package/build/consts.d.ts.map +1 -0
- package/build/consts.js +37 -0
- package/build/consts.js.map +1 -0
- package/build/consts.local.d.ts +6 -0
- package/build/consts.local.d.ts.map +1 -0
- package/build/consts.local.js +8 -0
- package/build/consts.local.js.map +1 -0
- package/build/helpers/agent/types.d.ts +19 -0
- package/build/helpers/agent/types.d.ts.map +1 -0
- package/build/helpers/agent/types.js +2 -0
- package/build/helpers/agent/types.js.map +1 -0
- package/build/helpers/agent.d.ts +4 -0
- package/build/helpers/agent.d.ts.map +1 -0
- package/build/helpers/agent.js +101 -0
- package/build/helpers/agent.js.map +1 -0
- package/build/helpers/index.d.ts +3 -0
- package/build/helpers/index.d.ts.map +1 -0
- package/build/helpers/index.js +2 -0
- package/build/helpers/index.js.map +1 -0
- package/build/index.d.ts +6 -0
- package/build/index.d.ts.map +1 -0
- package/build/index.js +5 -0
- package/build/index.js.map +1 -0
- package/build/plugin.d.ts +16 -0
- package/build/plugin.d.ts.map +1 -0
- package/build/plugin.js +22 -0
- package/build/plugin.js.map +1 -0
- package/build/tools/consts.local.d.ts +6 -0
- package/build/tools/consts.local.d.ts.map +1 -0
- package/build/tools/consts.local.js +15 -0
- package/build/tools/consts.local.js.map +1 -0
- package/build/tools/schemas.d.ts +12 -0
- package/build/tools/schemas.d.ts.map +1 -0
- package/build/tools/schemas.js +43 -0
- package/build/tools/schemas.js.map +1 -0
- package/build/tools/types.local.d.ts +7 -0
- package/build/tools/types.local.d.ts.map +1 -0
- package/build/tools/types.local.js +2 -0
- package/build/tools/types.local.js.map +1 -0
- package/build/tools.d.ts +14 -0
- package/build/tools.d.ts.map +1 -0
- package/build/tools.js +70 -0
- package/build/tools.js.map +1 -0
- package/build/types.d.ts +21 -0
- package/build/types.d.ts.map +1 -0
- package/build/types.js +2 -0
- package/build/types.js.map +1 -0
- package/package.json +51 -4
- package/src/consts.local.ts +9 -0
- package/src/consts.ts +41 -0
- package/src/helpers/agent/types.ts +19 -0
- package/src/helpers/agent.ts +130 -0
- package/src/helpers/index.ts +2 -0
- package/src/index.ts +5 -0
- package/src/plugin.ts +24 -0
- package/src/tools/consts.local.ts +16 -0
- package/src/tools/schemas.ts +48 -0
- package/src/tools/types.local.ts +6 -0
- package/src/tools.ts +94 -0
- package/src/types.ts +22 -0
- package/tests/_tools/model.ts +49 -0
- package/tests/context.ts +85 -0
- package/tests/helper.spec.ts +38 -0
- package/tests/loop.spec.ts +103 -0
- package/tests/plugin.spec.ts +91 -0
- package/tests/tools.spec.ts +75 -0
- package/tests/tsconfig.json +12 -0
- package/tsconfig.json +12 -0
package/src/tools.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { tool, type StructuredToolInterface } from '@langchain/core/tools'
|
|
2
|
+
import type { JSONSchema } from '@langchain/core/utils/json_schema'
|
|
3
|
+
import { toolHelper, type AgentToolSet, type ToolErrorResponse } from '@owlmeans/agent'
|
|
4
|
+
import type { BotActionKind } from '@owlmeans/bot'
|
|
5
|
+
import type { BotActionOptions, BotActionResult } from '@owlmeans/server-bot'
|
|
6
|
+
import { BOT_AGENT_ACTIONS, BOT_AGENT_DONE, BOT_AGENT_TOOL_ACTIONS, BotAgentTool } from './consts.js'
|
|
7
|
+
import { BOT_AGENT_MEMBER_TOOLS, BOT_AGENT_MESSAGE_TOOLS } from './consts.local.js'
|
|
8
|
+
import type { BotAgentToolsOptions } from './types.js'
|
|
9
|
+
import { BOT_AGENT_NO_REASON, BOT_AGENT_TOOL_DESCRIPTIONS } from './tools/consts.local.js'
|
|
10
|
+
import {
|
|
11
|
+
BotAgentMuteSchema, BotAgentNoArgsSchema, BotAgentReactSchema, BotAgentTextSchema, BotAgentWarnSchema,
|
|
12
|
+
} from './tools/schemas.js'
|
|
13
|
+
import type { BotAgentToolArgs } from './tools/types.local.js'
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The bot's actions as agent tools, bound to ONE update — without the plugin, for a caller that
|
|
17
|
+
* assembles an agent's tools itself.
|
|
18
|
+
*
|
|
19
|
+
* A tool is offered only when its action is in `allow` (default: all), `bot.supports` it, and the
|
|
20
|
+
* update has what it acts on (a message for `reply` / `warn` / `delete` / `react`, a sender for
|
|
21
|
+
* `mute` / `ban`). Every tool NEVER throws: it answers `done`, or `{ error }` with the action's
|
|
22
|
+
* one-sentence `error` — the `toolHelper` contract, so a refused action is something the model reads
|
|
23
|
+
* and adapts to. The activity each action records names the first matched group as its rule.
|
|
24
|
+
*/
|
|
25
|
+
export const botAgentTools = (options: BotAgentToolsOptions): AgentToolSet => {
|
|
26
|
+
const { update, bot } = options
|
|
27
|
+
const allowed = new Set<BotActionKind>(options.allow ?? BOT_AGENT_ACTIONS)
|
|
28
|
+
const rule = update.matches?.[0]?.rule.id
|
|
29
|
+
const opts: BotActionOptions = rule != null ? { rule } : {}
|
|
30
|
+
|
|
31
|
+
const supported = (kind: BotActionKind): boolean => {
|
|
32
|
+
try {
|
|
33
|
+
return bot.supports(kind)
|
|
34
|
+
} catch {
|
|
35
|
+
return false
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const offered = (name: BotAgentTool): boolean => {
|
|
40
|
+
const kind = BOT_AGENT_TOOL_ACTIONS[name]
|
|
41
|
+
if (!allowed.has(kind) || !supported(kind)) {
|
|
42
|
+
return false
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return BOT_AGENT_MESSAGE_TOOLS.has(name) ? update.message != null
|
|
46
|
+
: BOT_AGENT_MEMBER_TOOLS.has(name) ? update.sender != null
|
|
47
|
+
: true
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const textOf = (value: unknown): string => typeof value === 'string' ? value : ''
|
|
51
|
+
|
|
52
|
+
const define = (
|
|
53
|
+
name: BotAgentTool, schema: JSONSchema, perform: (args: BotAgentToolArgs) => Promise<BotActionResult>,
|
|
54
|
+
): StructuredToolInterface => tool(
|
|
55
|
+
async (input: unknown): Promise<string | ToolErrorResponse> => {
|
|
56
|
+
try {
|
|
57
|
+
const args: BotAgentToolArgs = input != null && typeof input === 'object' ? input : {}
|
|
58
|
+
const result = await perform(args)
|
|
59
|
+
// The action's own sentence ("WhatsApp cannot delete messages."), never its machine `code`.
|
|
60
|
+
return result?.ok === true
|
|
61
|
+
? BOT_AGENT_DONE
|
|
62
|
+
: { error: result?.error ?? BOT_AGENT_NO_REASON }
|
|
63
|
+
} catch (e) {
|
|
64
|
+
// BotActions never throw by contract; a broken one still must not take the run with it.
|
|
65
|
+
return toolHelper.toErrorResponse(e)
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{ name, description: BOT_AGENT_TOOL_DESCRIPTIONS[name], schema },
|
|
69
|
+
) as unknown as StructuredToolInterface
|
|
70
|
+
|
|
71
|
+
const definitions: Record<BotAgentTool, () => StructuredToolInterface> = {
|
|
72
|
+
[BotAgentTool.Reply]: () => define(BotAgentTool.Reply, BotAgentTextSchema,
|
|
73
|
+
async ({ text }) => await bot.reply(update, textOf(text), opts)),
|
|
74
|
+
[BotAgentTool.Delete]: () => define(BotAgentTool.Delete, BotAgentNoArgsSchema,
|
|
75
|
+
async () => await bot.delete(update, opts)),
|
|
76
|
+
[BotAgentTool.Warn]: () => define(BotAgentTool.Warn, BotAgentWarnSchema,
|
|
77
|
+
async ({ text }) => await bot.reply(update, textOf(text), opts)),
|
|
78
|
+
[BotAgentTool.Mute]: () => define(BotAgentTool.Mute, BotAgentMuteSchema,
|
|
79
|
+
async ({ minutes }) => await bot.mute(update, { ...opts, minutes: Math.round(Number(minutes)) })),
|
|
80
|
+
[BotAgentTool.Ban]: () => define(BotAgentTool.Ban, BotAgentNoArgsSchema,
|
|
81
|
+
async () => await bot.ban(update, opts)),
|
|
82
|
+
[BotAgentTool.React]: () => define(BotAgentTool.React, BotAgentReactSchema,
|
|
83
|
+
async ({ emoji }) => await bot.react(update, textOf(emoji), opts)),
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const tools: AgentToolSet = {}
|
|
87
|
+
for (const name of Object.values(BotAgentTool)) {
|
|
88
|
+
if (offered(name)) {
|
|
89
|
+
tools[name] = definitions[name]()
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
return tools
|
|
94
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { BotActionKind, BotUpdate } from '@owlmeans/bot'
|
|
2
|
+
import type { BotActions } from '@owlmeans/server-bot'
|
|
3
|
+
|
|
4
|
+
export interface BotAgentToolsOptions {
|
|
5
|
+
/** The update the tools act on: its message, its sender, its chat. */
|
|
6
|
+
update: BotUpdate
|
|
7
|
+
/**
|
|
8
|
+
* The connection's actions — the `bot` a handler receives, or `BotService.actions(update)`. They
|
|
9
|
+
* never throw: every call answers `{ ok, error? }` and records one activity.
|
|
10
|
+
*/
|
|
11
|
+
bot: BotActions
|
|
12
|
+
/**
|
|
13
|
+
* What the model may do (`warn` is `reply`); default every action a tool performs. Intersected
|
|
14
|
+
* with `bot.supports(kind)`: a tool the platform cannot perform is never offered.
|
|
15
|
+
*/
|
|
16
|
+
allow?: readonly BotActionKind[]
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface BotAgentOptions extends BotAgentToolsOptions {
|
|
20
|
+
/** One more instruction, contributed after the matched groups' — the product's own rule for this agent. */
|
|
21
|
+
instruction?: string
|
|
22
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { AIMessageChunk } from '@langchain/core/messages'
|
|
2
|
+
import type { BaseChatModel } from '@langchain/core/language_models/chat_models'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A model whose answers are decided in advance — the same double `@owlmeans/agent`'s own specs use
|
|
6
|
+
* (`FakeStreamingChatModel` always replays its first response and cannot drive a tool loop). It
|
|
7
|
+
* stands in for the MODEL, an external boundary, never for an `@owlmeans/*` package; the agent only
|
|
8
|
+
* asks a model for `bindTools` and `stream`, so that is all it implements.
|
|
9
|
+
*/
|
|
10
|
+
export interface ScriptedTurn {
|
|
11
|
+
content?: string
|
|
12
|
+
toolCalls?: Array<{ name: string, args: Record<string, unknown> }>
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ScriptedModel {
|
|
16
|
+
model: BaseChatModel
|
|
17
|
+
/** The message lists it was asked with, in order. */
|
|
18
|
+
asked: () => unknown[][]
|
|
19
|
+
/** The tool names the asking view was bound with, per ask — `null` for the unbound model. */
|
|
20
|
+
bound: () => Array<string[] | null>
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const scriptedModel = (script: ScriptedTurn[]): ScriptedModel => {
|
|
24
|
+
let at = 0
|
|
25
|
+
const asked: unknown[][] = []
|
|
26
|
+
const bound: Array<string[] | null> = []
|
|
27
|
+
|
|
28
|
+
const answer = (tools: string[] | null) => async (messages: unknown[]) => {
|
|
29
|
+
asked.push(messages)
|
|
30
|
+
bound.push(tools)
|
|
31
|
+
const turn = script[Math.min(at, script.length - 1)]
|
|
32
|
+
at += 1
|
|
33
|
+
const chunk = new AIMessageChunk({
|
|
34
|
+
content: turn.content ?? '',
|
|
35
|
+
tool_calls: (turn.toolCalls ?? []).map((call, index) => ({
|
|
36
|
+
name: call.name, args: call.args, id: `call_${at}_${index}`, type: 'tool_call' as const,
|
|
37
|
+
})),
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
return (async function* () { yield chunk })()
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const model = {
|
|
44
|
+
bindTools: (tools: Array<{ name: string }>) => ({ ...model, stream: answer(tools.map(item => item.name)) }),
|
|
45
|
+
stream: answer(null),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
return { model: model as unknown as BaseChatModel, asked: () => asked, bound: () => bound }
|
|
49
|
+
}
|
package/tests/context.ts
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto'
|
|
2
|
+
import { AppType, makeBasicContext, type BasicConfig, type BasicContext } from '@owlmeans/context'
|
|
3
|
+
import { BotPlatform, BotTransport, type BotRuleInput, type BotSimulateInput, type BotUpdate } from '@owlmeans/bot'
|
|
4
|
+
import { configureLog } from '@owlmeans/log'
|
|
5
|
+
import {
|
|
6
|
+
appendBot, botHandler, RES_BOT_ACTIVITY, RES_BOT_CONNECTION, RES_BOT_RULE, RES_BOT_UPDATE, type BotActions,
|
|
7
|
+
type BotActivityRecord, type BotConnectionRecord, type BotHandle, type BotRuleRecord, type BotService,
|
|
8
|
+
type BotUpdateRecord,
|
|
9
|
+
} from '@owlmeans/server-bot'
|
|
10
|
+
import { createStaticResource } from '@owlmeans/static-resource'
|
|
11
|
+
|
|
12
|
+
// Refused actions are expected here; `LOG_LEVEL=debug bun test` shows every line.
|
|
13
|
+
configureLog({ level: process.env.LOG_LEVEL ?? 'error' })
|
|
14
|
+
|
|
15
|
+
export interface TestBot {
|
|
16
|
+
context: BasicContext<BasicConfig>
|
|
17
|
+
service: BotService
|
|
18
|
+
scope: string
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
let stores = 0
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* One real context per test: the four bot resources as `@owlmeans/static-resource` stores (a fresh
|
|
25
|
+
* key each time), then `appendBot` — what a target's backend does with its Postgres resources.
|
|
26
|
+
*/
|
|
27
|
+
export const makeTestBot = (): TestBot => {
|
|
28
|
+
const key = `bot-agent-tests-${++stores}`
|
|
29
|
+
const context = makeBasicContext<BasicConfig>({ ready: false, service: 'bot-agent-tests', type: AppType.Backend, services: {} })
|
|
30
|
+
context.registerResource(createStaticResource<BotConnectionRecord>(RES_BOT_CONNECTION, `${key}-connection`))
|
|
31
|
+
context.registerResource(createStaticResource<BotRuleRecord>(RES_BOT_RULE, `${key}-rule`))
|
|
32
|
+
context.registerResource(createStaticResource<BotUpdateRecord>(RES_BOT_UPDATE, `${key}-update`))
|
|
33
|
+
context.registerResource(createStaticResource<BotActivityRecord>(RES_BOT_ACTIVITY, `${key}-activity`))
|
|
34
|
+
const service = appendBot(context, { secret: () => 'bot-agent-tests-secret' })
|
|
35
|
+
|
|
36
|
+
return { context, service, scope: `scope-${randomUUID()}` }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A verified MOCK connection — no network, every platform call recorded. */
|
|
40
|
+
export const connect = async (bot: TestBot, platform: BotPlatform = BotPlatform.Telegram): Promise<string> => {
|
|
41
|
+
const saved = await bot.service.connections.save(bot.scope, { platform, label: 'Guard', transport: BotTransport.Mock })
|
|
42
|
+
return (await bot.service.connections.verify(bot.scope, saved.id)).id
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface Received {
|
|
46
|
+
connection: string
|
|
47
|
+
update: BotUpdate
|
|
48
|
+
actions: BotActions
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* A message through the platform's own path (Telegram unless `platform` says otherwise): the rules
|
|
53
|
+
* saved, the message simulated on a mock connection and dispatched by the worker's runtime to
|
|
54
|
+
* `handle` — the update and the actions a handler receives. `handle` runs inside the handler, as a
|
|
55
|
+
* target's would.
|
|
56
|
+
*/
|
|
57
|
+
export const receive = async (
|
|
58
|
+
bot: TestBot, input: BotSimulateInput, rules: BotRuleInput[] = [], handle?: BotHandle,
|
|
59
|
+
platform: BotPlatform = BotPlatform.Telegram,
|
|
60
|
+
): Promise<Received> => {
|
|
61
|
+
const connection = await connect(bot, platform)
|
|
62
|
+
for (const rule of rules) {
|
|
63
|
+
await bot.service.rules.save(bot.scope, rule)
|
|
64
|
+
}
|
|
65
|
+
let received: Received | null = null
|
|
66
|
+
const runtime = bot.service.runtime([botHandler('agent', async (update, actions, context) => {
|
|
67
|
+
received = { connection, update, actions }
|
|
68
|
+
await handle?.(update, actions, context)
|
|
69
|
+
})])
|
|
70
|
+
await bot.service.simulate(bot.scope, connection, input)
|
|
71
|
+
await runtime.dispatch()
|
|
72
|
+
await runtime.stop()
|
|
73
|
+
if (received == null) {
|
|
74
|
+
throw new Error('the simulated message was not dispatched')
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return received
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* A WhatsApp customer's message on a verified mock connection, through server-bot's WhatsApp plugin —
|
|
82
|
+
* a platform that can reply, send and react, never delete, mute or ban.
|
|
83
|
+
*/
|
|
84
|
+
export const whatsAppUpdate = async (bot: TestBot): Promise<Received> =>
|
|
85
|
+
await receive(bot, { text: 'When do you open?', sender: { id: '4915550001', name: 'Ann' } }, [], undefined, BotPlatform.WhatsApp)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { BotChatKind, BotRuleAction } from '@owlmeans/bot'
|
|
3
|
+
import { botAgentHelper } from '../src/index.js'
|
|
4
|
+
import { makeTestBot, receive } from './context.js'
|
|
5
|
+
|
|
6
|
+
describe('@owlmeans/bot-agent — botAgentHelper', () => {
|
|
7
|
+
test('instructionOf joins the matched groups\' instructions, then the extra one', async () => {
|
|
8
|
+
const bot = makeTestBot()
|
|
9
|
+
const { update } = await receive(bot, { text: 'casino idiot rain' }, [
|
|
10
|
+
{ name: 'Spam', keywords: ['casino'], action: BotRuleAction.Agent, instruction: ' Delete advertising. ', order: 1 },
|
|
11
|
+
{ name: 'Rude', keywords: ['idiot'], action: BotRuleAction.Agent, instruction: 'Delete advertising.', order: 2 },
|
|
12
|
+
{ name: 'Weather', keywords: ['rain'], action: BotRuleAction.Reply, reply: 'Umbrella.', order: 3 },
|
|
13
|
+
])
|
|
14
|
+
|
|
15
|
+
expect(botAgentHelper.instructionOf(update, 'Answer in English.')).toBe('Delete advertising.\n\nAnswer in English.')
|
|
16
|
+
expect(botAgentHelper.instructionOf({ ...update, matches: [] })).toBe('')
|
|
17
|
+
})
|
|
18
|
+
|
|
19
|
+
test('contextOf says what is absent rather than inventing it', async () => {
|
|
20
|
+
const bot = makeTestBot()
|
|
21
|
+
const { update } = await receive(bot, { text: 'hi there', chat: { kind: BotChatKind.Private } })
|
|
22
|
+
|
|
23
|
+
const blocks = botAgentHelper.contextOf({ ...update, sender: undefined, message: undefined }, ' ')
|
|
24
|
+
expect(blocks).toHaveLength(2)
|
|
25
|
+
expect(blocks[0]).toContain('Chat: private')
|
|
26
|
+
expect(blocks[0]).not.toContain('Sender:')
|
|
27
|
+
expect(blocks[0]).toContain('The update carries no message.')
|
|
28
|
+
expect(blocks[1]).toContain('None — the message matched no keyword group')
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
test('promptOf names the platform and the chat, and the tools\' answers', async () => {
|
|
32
|
+
const bot = makeTestBot()
|
|
33
|
+
const { update } = await receive(bot, { text: 'hello', chat: { title: 'Gamers' } })
|
|
34
|
+
|
|
35
|
+
expect(botAgentHelper.promptOf(update)).toStartWith('A message arrived in the Telegram chat "Gamers". ')
|
|
36
|
+
expect(botAgentHelper.promptOf(update)).toContain('"done"')
|
|
37
|
+
})
|
|
38
|
+
})
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import type { ToolMessage } from '@langchain/core/messages'
|
|
3
|
+
import { makeAgentModel, type AgentModel, type AgentResult } from '@owlmeans/agent'
|
|
4
|
+
import { BotActionKind, BotRuleAction } from '@owlmeans/bot'
|
|
5
|
+
import { DEFAULT_EFFORT, makeExecutionService, makePromptService } from '@owlmeans/llm'
|
|
6
|
+
import { BOT_AGENT_DONE, BotAgentTool, botAgentHelper, botAgentPlugin } from '../src/index.js'
|
|
7
|
+
import { makeTestBot, receive } from './context.js'
|
|
8
|
+
import { scriptedModel, type ScriptedModel, type ScriptedTurn } from './_tools/model.js'
|
|
9
|
+
|
|
10
|
+
const ROLE = 'You moderate a community chat for its owner. Judge each message against the matched '
|
|
11
|
+
+ 'keyword groups\' instructions and act only through your tools.'
|
|
12
|
+
|
|
13
|
+
/** The shape a target's agent module has: one execution, one prompt service, the model handed in. */
|
|
14
|
+
const executions = makeExecutionService('bot-agent-loop-exec')
|
|
15
|
+
const prompts = makePromptService({}, 'bot-agent-loop-prompts')
|
|
16
|
+
const exec = executions.root({
|
|
17
|
+
models: () => { throw new Error('the agent asks agentModel only') },
|
|
18
|
+
prompts: () => prompts,
|
|
19
|
+
policy: { effort: DEFAULT_EFFORT },
|
|
20
|
+
purpose: { type: 'moderator' },
|
|
21
|
+
prompt: { role: ROLE },
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
const makeModeratorAgent = (scripted: ScriptedModel): AgentModel =>
|
|
25
|
+
makeAgentModel({ exec, agentModel: scripted.model, tools: {}, maxTurns: 6 })
|
|
26
|
+
|
|
27
|
+
const RULES = [
|
|
28
|
+
{ name: 'Spam', keywords: ['casino'], action: BotRuleAction.Delete, instruction: 'Delete advertising and warn the sender.', order: 1 },
|
|
29
|
+
]
|
|
30
|
+
|
|
31
|
+
/** A message simulated on a mock Telegram connection; the handler runs the agent over it. */
|
|
32
|
+
const moderate = async (text: string, script: ScriptedTurn[]) => {
|
|
33
|
+
const bot = makeTestBot()
|
|
34
|
+
const scripted = scriptedModel(script)
|
|
35
|
+
let result: AgentResult | null = null
|
|
36
|
+
const received = await receive(bot, { text, sender: { id: '777', name: 'Spammer' } }, RULES, async (update, actions) => {
|
|
37
|
+
const agent = makeModeratorAgent(scripted)
|
|
38
|
+
agent.use(botAgentPlugin({
|
|
39
|
+
update, bot: actions, allow: [BotActionKind.Delete, BotActionKind.Reply, BotActionKind.Mute],
|
|
40
|
+
}))
|
|
41
|
+
result = await agent.invoke(botAgentHelper.promptOf(update), { action: 'moderate' })
|
|
42
|
+
})
|
|
43
|
+
if (result == null) {
|
|
44
|
+
throw new Error('the agent did not run')
|
|
45
|
+
}
|
|
46
|
+
const tools = (result as AgentResult).messages.filter(message => message.getType() === 'tool') as ToolMessage[]
|
|
47
|
+
|
|
48
|
+
return { bot, scripted, received, result: result as AgentResult, tools }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('@owlmeans/bot-agent — an agent loop over a bot update', () => {
|
|
52
|
+
test('the agent reads the message from its context and acts through the tools', async () => {
|
|
53
|
+
const { bot, scripted, received, result, tools } = await moderate('Best casino in town', [
|
|
54
|
+
{
|
|
55
|
+
toolCalls: [
|
|
56
|
+
{ name: BotAgentTool.Delete, args: {} },
|
|
57
|
+
{ name: BotAgentTool.Warn, args: { text: 'Advertising is not allowed here.' } },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
{ content: 'Deleted the casino ad and warned the sender.' },
|
|
61
|
+
])
|
|
62
|
+
|
|
63
|
+
// Bound with exactly the allowed tools the platform supports, every round.
|
|
64
|
+
expect(scripted.bound()).toEqual([
|
|
65
|
+
[BotAgentTool.Reply, BotAgentTool.Delete, BotAgentTool.Warn, BotAgentTool.Mute],
|
|
66
|
+
[BotAgentTool.Reply, BotAgentTool.Delete, BotAgentTool.Warn, BotAgentTool.Mute],
|
|
67
|
+
])
|
|
68
|
+
// The persona in the system message; the message and its group in the project message after it.
|
|
69
|
+
const [system, project] = scripted.asked()[0] as Array<{ getType: () => string, content: unknown }>
|
|
70
|
+
expect(system.getType()).toBe('system')
|
|
71
|
+
expect(JSON.stringify(system.content)).toContain('You moderate a community chat')
|
|
72
|
+
expect(JSON.stringify(system.content)).not.toContain('Best casino in town')
|
|
73
|
+
expect(project.getType()).toBe('human')
|
|
74
|
+
expect(JSON.stringify(project.content)).toContain('> Best casino in town')
|
|
75
|
+
expect(JSON.stringify(project.content)).toContain('Instruction: Delete advertising and warn the sender.')
|
|
76
|
+
|
|
77
|
+
expect(tools.map(message => [message.name, message.content, message.status]))
|
|
78
|
+
.toEqual([[BotAgentTool.Delete, BOT_AGENT_DONE, 'success'], [BotAgentTool.Warn, BOT_AGENT_DONE, 'success']])
|
|
79
|
+
expect(result.message.content).toBe('Deleted the casino ad and warned the sender.')
|
|
80
|
+
const rule = received.update.matches[0].rule.id
|
|
81
|
+
const activity = (await bot.service.activity.list(bot.scope)).reverse()
|
|
82
|
+
expect(activity.map(entry => [entry.action, entry.ok, entry.update, entry.rule, entry.text ?? null])).toEqual([
|
|
83
|
+
[BotActionKind.Delete, true, received.update.id, rule, null],
|
|
84
|
+
[BotActionKind.Reply, true, received.update.id, rule, 'Advertising is not allowed here.'],
|
|
85
|
+
])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
test('a refused or unoffered tool comes back to the model as an error and the run goes on', async () => {
|
|
89
|
+
const { bot, tools, result } = await moderate('casino again', [
|
|
90
|
+
{ toolCalls: [{ name: BotAgentTool.Mute, args: { minutes: 0 } }, { name: BotAgentTool.Ban, args: {} }] },
|
|
91
|
+
{ content: 'Could not mute; banning is not mine to do.' },
|
|
92
|
+
])
|
|
93
|
+
|
|
94
|
+
expect(tools.map(message => [message.name, message.status])).toEqual([
|
|
95
|
+
[BotAgentTool.Mute, 'error'], [BotAgentTool.Ban, 'error'],
|
|
96
|
+
])
|
|
97
|
+
expect(tools[0].content).toBe('A mute lasts at least one minute.')
|
|
98
|
+
expect(tools[1].content).toContain('Tool ban not found')
|
|
99
|
+
expect(result.message.content).toBe('Could not mute; banning is not mine to do.')
|
|
100
|
+
expect((await bot.service.activity.list(bot.scope)).map(entry => [entry.action, entry.ok]))
|
|
101
|
+
.toEqual([[BotActionKind.Mute, false]])
|
|
102
|
+
})
|
|
103
|
+
})
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { makeAgentModel, type AgentRun } from '@owlmeans/agent'
|
|
3
|
+
import { BotActionKind, BotRuleAction } from '@owlmeans/bot'
|
|
4
|
+
import { DEFAULT_EFFORT, makeExecutionService, makePromptService } from '@owlmeans/llm'
|
|
5
|
+
import { BOT_AGENT_PLUGIN, BotAgentTool, botAgentPlugin } from '../src/index.js'
|
|
6
|
+
import { makeTestBot, receive, whatsAppUpdate } from './context.js'
|
|
7
|
+
import { scriptedModel } from './_tools/model.js'
|
|
8
|
+
|
|
9
|
+
const RULES = [
|
|
10
|
+
{ name: 'Spam', keywords: ['casino', 'free money'], action: BotRuleAction.Delete, instruction: 'Delete advertising.', order: 1 },
|
|
11
|
+
{ name: 'Rudeness', keywords: ['idiot'], action: BotRuleAction.Agent, instruction: 'Warn politely.', reply: 'Be kind.', order: 2 },
|
|
12
|
+
{ name: 'Weather', keywords: ['rain'], action: BotRuleAction.Reply, reply: 'Take an umbrella.', order: 3 },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
/** The plugin ignores the run it is handed; a spec has none outside a loop. */
|
|
16
|
+
const NO_RUN = {} as AgentRun
|
|
17
|
+
|
|
18
|
+
describe('@owlmeans/bot-agent — botAgentPlugin', () => {
|
|
19
|
+
test('contributes the message, chat, sender, replied-to text, every matched group and the instruction', async () => {
|
|
20
|
+
const bot = makeTestBot()
|
|
21
|
+
const { update, actions } = await receive(bot, {
|
|
22
|
+
text: 'Free money at the CASINO, idiot!\nJoin now',
|
|
23
|
+
chat: { title: 'Gamers' },
|
|
24
|
+
sender: { id: '777', name: 'Spammer', username: 'spammer' },
|
|
25
|
+
replyTo: { id: '5', text: 'Welcome, everyone' },
|
|
26
|
+
}, RULES)
|
|
27
|
+
|
|
28
|
+
const plugin = botAgentPlugin({ update, bot: actions, instruction: 'Never ban anybody.' })
|
|
29
|
+
const [message, groups, instruction, ...rest] = await plugin.context!(NO_RUN)
|
|
30
|
+
|
|
31
|
+
expect(rest).toEqual([])
|
|
32
|
+
expect(message).toContain('Platform: Telegram')
|
|
33
|
+
expect(message).toContain('Chat: group "Gamers"')
|
|
34
|
+
expect(message).toContain('Sender: Spammer (@spammer)')
|
|
35
|
+
expect(message).toContain('> Free money at the CASINO, idiot!\n> Join now')
|
|
36
|
+
expect(message).toContain('> Welcome, everyone')
|
|
37
|
+
expect(groups).toContain('## 1. Spam\nKeywords matched: casino, free money\nAction: delete\nInstruction: Delete advertising.')
|
|
38
|
+
// `agent` is the hand-over itself, not an action to tell the model about.
|
|
39
|
+
expect(groups).toContain('## 2. Rudeness\nKeywords matched: idiot\nReply text: Be kind.\nInstruction: Warn politely.')
|
|
40
|
+
expect(groups).not.toContain('Weather')
|
|
41
|
+
expect(instruction).toBe('# Instruction\n\nNever ban anybody.')
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
test('offers only the tools the platform supports and allow names', async () => {
|
|
45
|
+
const bot = makeTestBot()
|
|
46
|
+
const telegram = await receive(bot, { text: 'casino' }, RULES)
|
|
47
|
+
const whatsApp = await whatsAppUpdate(bot)
|
|
48
|
+
const names = (options: Parameters<typeof botAgentPlugin>[0]): string[] =>
|
|
49
|
+
Object.values(botAgentPlugin(options).tools!(NO_RUN)).map(item => item.name)
|
|
50
|
+
|
|
51
|
+
expect(names({ update: telegram.update, bot: telegram.actions })).toEqual([
|
|
52
|
+
BotAgentTool.Reply, BotAgentTool.Delete, BotAgentTool.Warn, BotAgentTool.Mute, BotAgentTool.Ban, BotAgentTool.React,
|
|
53
|
+
])
|
|
54
|
+
expect(names({ update: telegram.update, bot: telegram.actions, allow: [BotActionKind.Delete, BotActionKind.Mute] }))
|
|
55
|
+
.toEqual([BotAgentTool.Delete, BotAgentTool.Mute])
|
|
56
|
+
// WhatsApp can reply and react — never delete, mute or ban — and `warn` is a reply.
|
|
57
|
+
expect(names({ update: whatsApp.update, bot: whatsApp.actions })).toEqual([
|
|
58
|
+
BotAgentTool.Reply, BotAgentTool.Warn, BotAgentTool.React,
|
|
59
|
+
])
|
|
60
|
+
expect(names({ update: whatsApp.update, bot: whatsApp.actions, allow: [BotActionKind.Delete, BotActionKind.Ban] }))
|
|
61
|
+
.toEqual([])
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
test('keeps one alias: registering it for the next update replaces the previous one', async () => {
|
|
65
|
+
const bot = makeTestBot()
|
|
66
|
+
const first = await receive(bot, { text: 'the first message' })
|
|
67
|
+
const second = await receive(bot, { text: 'the second message' })
|
|
68
|
+
const scripted = scriptedModel([{ content: 'nothing to do' }])
|
|
69
|
+
const agent = makeAgentModel({
|
|
70
|
+
exec: makeExecutionService('bot-agent-alias-exec').root({
|
|
71
|
+
models: () => { throw new Error('the agent asks agentModel only') },
|
|
72
|
+
prompts: () => makePromptService({}, 'bot-agent-alias-prompts'),
|
|
73
|
+
policy: { effort: DEFAULT_EFFORT },
|
|
74
|
+
purpose: { type: 'bot-agent-spec' },
|
|
75
|
+
prompt: { role: 'You moderate a chat.' },
|
|
76
|
+
}),
|
|
77
|
+
agentModel: scripted.model,
|
|
78
|
+
tools: {},
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
agent.use(botAgentPlugin({ update: first.update, bot: first.actions }))
|
|
82
|
+
agent.use(botAgentPlugin({ update: second.update, bot: second.actions }))
|
|
83
|
+
await agent.invoke('act')
|
|
84
|
+
|
|
85
|
+
expect(botAgentPlugin({ update: first.update, bot: first.actions }).alias).toBe(BOT_AGENT_PLUGIN)
|
|
86
|
+
const asked = JSON.stringify(scripted.asked()[0])
|
|
87
|
+
expect(asked).toContain('the second message')
|
|
88
|
+
expect(asked).not.toContain('the first message')
|
|
89
|
+
expect(asked.split('# The chat message').length - 1).toBe(1)
|
|
90
|
+
})
|
|
91
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from 'bun:test'
|
|
2
|
+
import { toolHelper } from '@owlmeans/agent'
|
|
3
|
+
import { BotActionKind, BotRuleAction, BotTransport } from '@owlmeans/bot'
|
|
4
|
+
import { telegramBotPlatform } from '@owlmeans/server-bot'
|
|
5
|
+
import { BOT_AGENT_DONE, BotAgentTool, botAgentTools } from '../src/index.js'
|
|
6
|
+
import { makeTestBot, receive } from './context.js'
|
|
7
|
+
|
|
8
|
+
const callOf = (name: string, args: Record<string, unknown> = {}) =>
|
|
9
|
+
({ name, args, id: `call_${name}`, type: 'tool_call' as const })
|
|
10
|
+
|
|
11
|
+
describe('@owlmeans/bot-agent — botAgentTools', () => {
|
|
12
|
+
test('each tool performs its action on the update and answers done', async () => {
|
|
13
|
+
const bot = makeTestBot()
|
|
14
|
+
const { connection, update, actions } = await receive(bot, { text: 'casino tonight', sender: { id: '777' } }, [
|
|
15
|
+
{ name: 'Spam', keywords: ['casino'], action: BotRuleAction.Delete, order: 1 },
|
|
16
|
+
])
|
|
17
|
+
const tools = botAgentTools({ update, bot: actions })
|
|
18
|
+
|
|
19
|
+
for (const [name, args] of [
|
|
20
|
+
[BotAgentTool.Reply, { text: 'Hello' }], [BotAgentTool.Delete, {}], [BotAgentTool.Warn, { text: 'No ads, please.' }],
|
|
21
|
+
[BotAgentTool.Mute, { minutes: 60 }], [BotAgentTool.Ban, {}], [BotAgentTool.React, { emoji: '👎' }],
|
|
22
|
+
] as const) {
|
|
23
|
+
expect(await toolHelper.safeInvokeTool(tools, callOf(name, args))).toBe(BOT_AGENT_DONE)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const message = { chat: update.chat.id, message: update.message!.ref.id }
|
|
27
|
+
const member = { chat: update.chat.id, user: '777' }
|
|
28
|
+
const rule = update.matches[0].rule.id
|
|
29
|
+
const activity = (await bot.service.activity.list(bot.scope)).reverse()
|
|
30
|
+
expect(activity.map(entry => [entry.action, entry.ok, entry.update, entry.rule, entry.target, entry.text ?? null]))
|
|
31
|
+
.toEqual([
|
|
32
|
+
[BotActionKind.Reply, true, update.id, rule, message, 'Hello'],
|
|
33
|
+
[BotActionKind.Delete, true, update.id, rule, message, null],
|
|
34
|
+
[BotActionKind.Reply, true, update.id, rule, message, 'No ads, please.'],
|
|
35
|
+
[BotActionKind.Mute, true, update.id, rule, member, null],
|
|
36
|
+
[BotActionKind.Ban, true, update.id, rule, member, null],
|
|
37
|
+
[BotActionKind.React, true, update.id, rule, message, '👎'],
|
|
38
|
+
])
|
|
39
|
+
expect(activity.every(entry => entry.transport === BotTransport.Mock)).toBe(true)
|
|
40
|
+
expect(telegramBotPlatform.mock.calls(connection).map(call => call.method)).toEqual(expect.arrayContaining([
|
|
41
|
+
'sendMessage', 'deleteMessage', 'restrictChatMember', 'banChatMember', 'setMessageReaction',
|
|
42
|
+
]))
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
test('a refused action answers what went wrong instead of throwing', async () => {
|
|
46
|
+
const bot = makeTestBot()
|
|
47
|
+
const { connection, update, actions } = await receive(bot, { text: 'hello' })
|
|
48
|
+
const tools = botAgentTools({ update, bot: actions })
|
|
49
|
+
|
|
50
|
+
expect(await toolHelper.safeInvokeTool(tools, callOf(BotAgentTool.Reply, { text: ' ' })))
|
|
51
|
+
.toEqual({ error: 'The text is empty.' })
|
|
52
|
+
expect(await toolHelper.safeInvokeTool(tools, callOf(BotAgentTool.Mute, { minutes: 0 })))
|
|
53
|
+
.toEqual({ error: 'A mute lasts at least one minute.' })
|
|
54
|
+
// A wrong argument type never reaches the action: the schema refuses it, readable.
|
|
55
|
+
expect(toolHelper.isToolError(await toolHelper.safeInvokeTool(tools, callOf(BotAgentTool.Mute, { minutes: 'soon' }))))
|
|
56
|
+
.toBe(true)
|
|
57
|
+
|
|
58
|
+
// The connection switched off between the update and the agent's decision.
|
|
59
|
+
await bot.service.connections.save(bot.scope, { id: connection, platform: update.platform, label: 'Guard', enabled: false })
|
|
60
|
+
const disabled = botAgentTools({ update, bot: await bot.service.actions(update) })
|
|
61
|
+
const answer = await disabled[BotAgentTool.Delete].invoke({})
|
|
62
|
+
expect(answer).toEqual({ error: 'The bot connection is switched off.' })
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
test('offers no message tool without a message and no member tool without a sender', async () => {
|
|
66
|
+
const bot = makeTestBot()
|
|
67
|
+
const { update, actions } = await receive(bot, { text: 'hello' })
|
|
68
|
+
const names = (patch: object): string[] =>
|
|
69
|
+
Object.values(botAgentTools({ update: { ...update, ...patch }, bot: actions })).map(item => item.name)
|
|
70
|
+
|
|
71
|
+
expect(names({ message: undefined })).toEqual([BotAgentTool.Mute, BotAgentTool.Ban])
|
|
72
|
+
expect(names({ sender: undefined }))
|
|
73
|
+
.toEqual([BotAgentTool.Reply, BotAgentTool.Delete, BotAgentTool.Warn, BotAgentTool.React])
|
|
74
|
+
})
|
|
75
|
+
})
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": [
|
|
3
|
+
"@owlmeans/dep-config/tsconfig.base.json",
|
|
4
|
+
"@owlmeans/dep-config/tsconfig.server.json"
|
|
5
|
+
],
|
|
6
|
+
"compilerOptions": {
|
|
7
|
+
"rootDir": "./src/",
|
|
8
|
+
"outDir": "./build/"
|
|
9
|
+
},
|
|
10
|
+
"include": ["src/**/*"],
|
|
11
|
+
"exclude": ["./dist/**/*", "./build/**/*", "./tests/**/*", "./*.ts"]
|
|
12
|
+
}
|