diff --git a/docs/discord.mdx b/docs/discord.mdx index c4bdf2e..988b944 100644 --- a/docs/discord.mdx +++ b/docs/discord.mdx @@ -19,6 +19,7 @@ triggers: # from_users: ["amin", "ratul"] # only handle these authors (user ids or usernames) # reply_channel: "1522..." # post replies here instead of the source channel # allow_silence: true # a reply of exactly __NO_REPLY__ posts nothing + # show_typing: true # show "typing…" in the channel while the agent works ``` @@ -69,7 +70,11 @@ Setting up a Discord bot takes about 15 minutes: 4. Run `af discord-invite agent.yaml` to print a ready-made invite URL with the correct scopes and permissions, then open it and invite the bot to your server. 5. Run the agent: `af run agent.yaml` -The agent replies in-channel to the triggering message; conversations are keyed per channel or thread, and `memory.scope: thread` continues them ([Memory](agent-file.md#memory)). It ignores bots, itself, and empty messages; long replies split at Discord's 2000-char limit. +The agent replies in-channel to the triggering message; conversations are keyed per channel or thread, and `memory.scope: thread` continues them ([Memory](agent-file.md#memory)). It ignores bots, itself, and empty messages; long replies split at Discord's 2000-char limit. While connected the bot shows as online, and with `show_typing: true` it also shows the "typing…" indicator in the source channel for as long as a run is in flight. A run can take a while on a slow model, and the indicator is what tells the person who asked that something is happening. + +## Direct messages + +The bot answers DMs too. A DM addresses the bot by definition, so the `channels` filter and `require_mention` don't apply there. `from_users` still does, and it's the knob to reach for if you don't want the bot talking to everyone who can see it in a server. Each DM is its own conversation, with history kept separate from every channel the bot watches. ## Observer agents @@ -79,4 +84,6 @@ Three optional keys together turn the trigger from a chatbot into an observer - `reply_channel` — deliver replies to a dedicated channel instead of the source. Out-of-channel replies quote the triggering message and link back to it. - `allow_silence` — let the agent say nothing. Instruct it in `purpose` to answer with exactly `__NO_REPLY__` when it has no feedback; the trigger then posts nothing instead of a "looks fine" reply on every message. +Leave `show_typing` off for an observer. A typing indicator that ends in no message reads as the bot changing its mind, and an observer stays silent most of the time. + Every run — replied or silent — lands in the agent's [run history](docker-run.md#persistence-the-data-volume) with its status, steps and tokens. diff --git a/packages/core/config/schema.ts b/packages/core/config/schema.ts index c5d22b5..9c7146f 100644 --- a/packages/core/config/schema.ts +++ b/packages/core/config/schema.ts @@ -30,9 +30,11 @@ const ModelConfigSchema = z.strictObject({ const DiscordTriggerSchema = z.strictObject({ type: z.literal("discord"), channels: z.array(z.string().min(1)).optional().describe( - "Channel names or ids to listen in; omit for all channels the bot can see.", + "Channel names or ids to listen in; omit for all channels the bot can see. DMs always pass.", + ), + require_mention: z.boolean().optional().describe( + "Only respond when the bot is @-mentioned. DMs always address the bot.", ), - require_mention: z.boolean().optional().describe("Only respond when the bot is @-mentioned."), token_env: z.string().min(1).default("DISCORD_BOT_TOKEN").describe( "Env var holding the Discord bot token.", ), @@ -45,6 +47,9 @@ const DiscordTriggerSchema = z.strictObject({ allow_silence: z.boolean().default(false).describe( "Post nothing when the agent replies with exactly __NO_REPLY__ (or nothing). Instruct the sentinel in purpose.", ), + show_typing: z.boolean().default(false).describe( + "Show the typing indicator in the source channel while the agent works. Looks odd with allow_silence: typing may end in no message.", + ), }).describe("Listen to Discord messages via the gateway; replies go in-channel by default."); const SlackTriggerSchema = z.strictObject({ @@ -247,9 +252,9 @@ export interface ModelConfig { export interface DiscordTriggerConfig { /** Discriminant for TriggerConfig. */ type: "discord"; - /** Channel names or ids to listen in; omit for all channels the bot can see. */ + /** Channel names or ids to listen in; omit for all channels the bot can see. DMs always pass. */ channels?: string[]; - /** Only respond when the bot is @-mentioned. */ + /** Only respond when the bot is @-mentioned. DMs always address the bot. */ require_mention?: boolean; /** Env var holding the Discord bot token. */ token_env: string; @@ -259,6 +264,8 @@ export interface DiscordTriggerConfig { reply_channel?: string; /** Post nothing when the agent replies with exactly __NO_REPLY__ (or nothing). */ allow_silence: boolean; + /** Show the typing indicator in the source channel while the agent works. */ + show_typing: boolean; } /** A Slack trigger: listen to messages via Socket Mode. */ diff --git a/packages/core/providers/openai.ts b/packages/core/providers/openai.ts index da12562..0474451 100644 --- a/packages/core/providers/openai.ts +++ b/packages/core/providers/openai.ts @@ -42,7 +42,9 @@ function toWireMessages(system: string | undefined, messages: Message[]): WireMe case "assistant": wire.push({ role: "assistant", - content: m.content || null, + // "" rather than null: the OpenAI API accepts either on tool-call + // turns, but stricter compatible providers reject null. + content: m.content || "", tool_calls: m.toolCalls?.map((tc) => ({ id: tc.id, type: "function", diff --git a/packages/core/providers/providers_test.ts b/packages/core/providers/providers_test.ts index 441f3d8..d23245b 100644 --- a/packages/core/providers/providers_test.ts +++ b/packages/core/providers/providers_test.ts @@ -47,6 +47,31 @@ Deno.test("openai adapter: maps request and parses tool calls", async () => { assertEquals(sent.tools[0].function.name, "echo"); }); +Deno.test("openai adapter: tool-call turns are replayed with string content, never null", async () => { + const f = fakeFetch(200, { + choices: [{ finish_reason: "stop", message: { content: "done" } }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + const provider = new OpenAICompatibleProvider({ apiKey: "k", fetch: f }); + await provider.complete({ + model: "gpt-5.4-mini", + messages: [ + { role: "user", content: "hello" }, + { + role: "assistant", + content: "", + toolCalls: [{ id: "call_1", name: "echo", arguments: '{"message":"hi"}' }], + }, + { role: "tool", content: "hi", toolCallId: "call_1" }, + ], + }); + + const sent = await f.calls[0].json(); + const assistant = sent.messages[1]; + assertEquals(assistant.content, ""); + assertEquals(assistant.tool_calls[0].id, "call_1"); +}); + Deno.test("openai adapter: 401 maps to a non-retryable auth error", async () => { const provider = new OpenAICompatibleProvider({ apiKey: "bad", diff --git a/packages/triggers/discord.ts b/packages/triggers/discord.ts index aa0c0ab..9bec9c9 100644 --- a/packages/triggers/discord.ts +++ b/packages/triggers/discord.ts @@ -7,8 +7,8 @@ import { NO_REPLY, splitMessage } from "./text.ts"; // ~200 lines keeps the container minimal (Plan 0, principle 8). const API = "https://discord.com/api/v10"; -// GUILDS | GUILD_MESSAGES | MESSAGE_CONTENT -const INTENTS = (1 << 0) | (1 << 9) | (1 << 15); +// GUILDS | GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT +const INTENTS = (1 << 0) | (1 << 9) | (1 << 12) | (1 << 15); export { NO_REPLY, splitMessage }; @@ -51,9 +51,9 @@ export interface DiscordMessage { /** Message filters shared by {@linkcode shouldHandle} and {@linkcode DiscordTriggerOptions}. */ export interface DiscordFilterOptions { - /** Channel names or ids to listen in; empty/undefined = all channels. */ + /** Channel names or ids to listen in; empty/undefined = all channels. DMs always pass. */ channels?: string[]; - /** Only handle messages that @-mention the bot. */ + /** Only handle messages that @-mention the bot (DMs always pass). */ requireMention?: boolean; /** Only handle messages from these authors (user ids or usernames); empty/undefined = anyone. */ fromUsers?: string[]; @@ -74,12 +74,15 @@ export function shouldHandle( !opts.fromUsers.some((u) => u === msg.author.id || u === msg.author.username) ) return false; if (!msg.content.trim()) return false; - if (opts.channels?.length) { + // A DM addresses the bot by definition: the channel filter and the mention + // gate only apply to guild messages (DMs carry no guild_id). + const isDM = !msg.guild_id; + if (!isDM && opts.channels?.length) { const match = opts.channels.includes(msg.channel_id) || (channelName !== undefined && opts.channels.includes(channelName)); if (!match) return false; } - if (opts.requireMention && !msg.mentions?.some((m) => m.id === botUserId)) return false; + if (!isDM && opts.requireMention && !msg.mentions?.some((m) => m.id === botUserId)) return false; return true; } @@ -91,6 +94,19 @@ export interface DiscordTriggerOptions extends DiscordFilterOptions { replyChannel?: string; /** Suppress the reply when the agent answers with the NO_REPLY sentinel (or nothing). */ allowSilence?: boolean; + /** Show the typing indicator in the source channel while the agent works. */ + showTyping?: boolean; +} + +/** + * Fire `send` now and again every `intervalMs` until the returned stop + * function is called. Discord's typing indicator expires after ~10 s, so the + * default interval keeps it alive for the whole run. + */ +export function typingLoop(send: () => void, intervalMs = 8_000): () => void { + send(); + const timer = setInterval(send, intervalMs); + return () => clearInterval(timer); } /** @@ -205,6 +221,9 @@ export class DiscordTrigger implements Trigger { token: this.#opts.token, intents: INTENTS, properties: { os: "linux", browser: "looped-af", device: "looped-af" }, + // Explicit presence so the bot reads as online for as long as + // the gateway connection is up. + presence: { since: null, activities: [], status: "online", afk: false }, }, })); break; @@ -221,13 +240,24 @@ export class DiscordTrigger implements Trigger { ? await this.#channelName(msg.channel_id) : undefined; if (!shouldHandle(msg, this.#botUserId, channelName, this.#opts)) break; - const result = await emit({ - id: msg.id, - trigger: this.name, - input: msg.content, - conversationKey: `discord:${msg.channel_id}`, - }); - await this.#reply(msg, result); + const stopTyping = this.#opts.showTyping + ? typingLoop(() => { + this.#api(`/channels/${msg.channel_id}/typing`, { method: "POST" }) + .then((res) => res.body?.cancel()) + .catch(() => {}); // typing is cosmetic — never let it break the run + }) + : undefined; + try { + const result = await emit({ + id: msg.id, + trigger: this.name, + input: msg.content, + conversationKey: `discord:${msg.channel_id}`, + }); + await this.#reply(msg, result); + } finally { + stopTyping?.(); + } } break; } diff --git a/packages/triggers/discord_test.ts b/packages/triggers/discord_test.ts index f9fab28..812fe35 100644 --- a/packages/triggers/discord_test.ts +++ b/packages/triggers/discord_test.ts @@ -6,6 +6,7 @@ import { inviteUrl, shouldHandle, splitMessage, + typingLoop, } from "./discord.ts"; const BOT_ID = "bot-1"; @@ -14,6 +15,7 @@ function msg(overrides: Partial): DiscordMessage { return { id: "m1", channel_id: "c-issues", + guild_id: "g1", content: "the export breaks on big files", author: { id: "user-1" }, ...overrides, @@ -42,6 +44,17 @@ Deno.test("shouldHandle: require_mention", () => { assert(shouldHandle(msg({ mentions: [{ id: BOT_ID }] }), BOT_ID, "issues", opts)); }); +Deno.test("shouldHandle: DMs skip the channel filter and the mention gate", () => { + const dm = msg({ guild_id: undefined, channel_id: "dm-1" }); + assert(shouldHandle(dm, BOT_ID, undefined, { channels: ["issues"], requireMention: true })); + // from_users still applies in DMs + assert(!shouldHandle(dm, BOT_ID, undefined, { fromUsers: ["someone-else"] })); + // and the bot's own DMs are still ignored + assert( + !shouldHandle(msg({ guild_id: undefined, author: { id: BOT_ID } }), BOT_ID, undefined, {}), + ); +}); + Deno.test("shouldHandle: from_users matches by id or username, drops everyone else", () => { const opts = { fromUsers: ["user-1", "amin"] }; assert(shouldHandle(msg({}), BOT_ID, "issues", opts)); // by id @@ -70,6 +83,18 @@ Deno.test("inviteUrl: correct client_id, scope, and permissions integer", () => ); }); +Deno.test("typingLoop: fires immediately, keeps firing, and stops cleanly", async () => { + let fired = 0; + const stop = typingLoop(() => fired++, 5); + assertEquals(fired, 1); // immediate first fire + await new Promise((r) => setTimeout(r, 20)); + assert(fired > 1); // kept alive on the interval + stop(); + const atStop = fired; + await new Promise((r) => setTimeout(r, 20)); + assertEquals(fired, atStop); // nothing after stop +}); + Deno.test("fetchApplicationId: returns id, throws readable error on bad token", async () => { const ok = ((_url: RequestInfo | URL, _init?: RequestInit) => diff --git a/packages/triggers/mod.ts b/packages/triggers/mod.ts index 89f28a3..42fa341 100644 --- a/packages/triggers/mod.ts +++ b/packages/triggers/mod.ts @@ -91,6 +91,7 @@ export function triggersFromConfig( fromUsers: t.from_users, replyChannel: t.reply_channel, allowSilence: t.allow_silence, + showTyping: t.show_typing, }), ); break; diff --git a/schema/agent.json b/schema/agent.json index 85dcd16..406b1d3 100644 --- a/schema/agent.json +++ b/schema/agent.json @@ -77,7 +77,7 @@ "const": "discord" }, "channels": { - "description": "Channel names or ids to listen in; omit for all channels the bot can see.", + "description": "Channel names or ids to listen in; omit for all channels the bot can see. DMs always pass.", "type": "array", "items": { "type": "string", @@ -85,7 +85,7 @@ } }, "require_mention": { - "description": "Only respond when the bot is @-mentioned.", + "description": "Only respond when the bot is @-mentioned. DMs always address the bot.", "type": "boolean" }, "token_env": { @@ -111,6 +111,11 @@ "default": false, "description": "Post nothing when the agent replies with exactly __NO_REPLY__ (or nothing). Instruct the sentinel in purpose.", "type": "boolean" + }, + "show_typing": { + "default": false, + "description": "Show the typing indicator in the source channel while the agent works. Looks odd with allow_silence: typing may end in no message.", + "type": "boolean" } }, "required": [