-
Notifications
You must be signed in to change notification settings - Fork 0
feat(joke-bot): conversational test bot + gate Slack wakes on @mention #83
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
+279
−0
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,174 @@ | ||
| /** | ||
| * joke-bot handler. | ||
| * | ||
| * relay inbox DM OR Slack @mention arrives | ||
| * → pull the recent conversation from memory (multi-turn threading) | ||
| * → ask ctx.llm.complete (subscription-backed, direct inference) for a joke | ||
| * → post it to Slack (writeback) — the chat reply surface | ||
| * → save the turn back to memory so the next message can do callbacks | ||
| * | ||
| * Reply generation uses ctx.llm.complete (NOT ctx.harness.run): a direct LLM | ||
| * call (sub-second) instead of booting a full harness CLI session (minutes). | ||
| * Combined with `sandbox: false` (persona), the handler runs in the persona | ||
| * runner with no Daytona box — the right shape for a lightweight reply bot. | ||
| * (Testing the claude/codex/opencode harness CLIs is a separate exercise that | ||
| * needs ctx.harness.run + sandbox:true.) | ||
| */ | ||
| import { | ||
| defineAgent, | ||
| isCronTickEvent, | ||
| isRelaycastMessageEvent, | ||
| type AgentEvent, | ||
| type WorkforceCtx | ||
| } from '@agentworkforce/runtime'; | ||
| import { slackClient } from '@relayfile/relay-helpers'; | ||
|
|
||
| const CONVO_TURNS = 6; // how much recent back-and-forth to feed the model | ||
|
|
||
| // ctx.llm.complete() takes only { maxTokens } — no system option — so the | ||
| // persona's voice rides as a preamble on the prompt. | ||
| const COMEDIAN_PREAMBLE = | ||
| 'You are a sharp, fast stand-up comedian who riffs on current events, tech, and pop culture. ' + | ||
| 'Keep replies short (1-3 lines), punchy, and genuinely funny — a clever observation or tight ' + | ||
| 'setup→punchline over puns. Good-natured: no slurs, no punching down, nothing mean about the ' + | ||
| 'person you are talking to. If the user is continuing an earlier bit, build on it (callback humor). ' + | ||
| 'Output ONLY the reply text — no preamble, no quotes, no stage directions.'; | ||
|
|
||
| function input(ctx: WorkforceCtx, name: string): string | undefined { | ||
| const spec = ctx.persona?.inputSpecs?.[name]; | ||
| const raw = process.env[spec?.env ?? name] ?? ctx.persona?.inputs?.[name] ?? spec?.default; | ||
| const v = raw != null ? String(raw).trim() : ''; | ||
| return v || undefined; | ||
| } | ||
|
|
||
| /** Generate a joke via direct LLM inference (subscription-backed). */ | ||
| async function joke(ctx: WorkforceCtx, context: string): Promise<string> { | ||
| const reply = (await ctx.llm.complete(`${COMEDIAN_PREAMBLE}\n\n${context}`, { maxTokens: 300 })).trim(); | ||
| if (!reply) throw new Error('ctx.llm.complete returned an empty reply'); | ||
| return reply; | ||
| } | ||
|
|
||
| async function readQuestion(event: AgentEvent): Promise<string> { | ||
| const payload = await event.expand('full').catch(() => undefined); | ||
| const data = (payload as { data?: Record<string, unknown> } | undefined)?.data; | ||
| const nested = (data?.message && typeof data.message === 'object' ? data.message : {}) as Record<string, unknown>; | ||
| const text = typeof data?.text === 'string' ? data.text | ||
| : typeof nested.text === 'string' ? nested.text : ''; | ||
| return text.trim(); | ||
| } | ||
|
|
||
| function toLines(records: unknown): string[] { | ||
| if (!Array.isArray(records)) return []; | ||
| return records | ||
| .map((r) => (typeof r === 'string' ? r : (r as { content?: unknown })?.content)) | ||
| .filter((c): c is string => typeof c === 'string' && c.trim().length > 0); | ||
| } | ||
|
|
||
| async function recall(ctx: WorkforceCtx, tag: string): Promise<string[]> { | ||
| return toLines( | ||
| await ctx.memory.recall('recent joke-bot conversation', { tags: [tag], limit: CONVO_TURNS, scope: 'workspace' }).catch(() => []) | ||
| ); | ||
| } | ||
|
|
||
| async function remember(ctx: WorkforceCtx, tag: string, user: string, reply: string): Promise<void> { | ||
| await ctx.memory | ||
| .save(`User: ${user}\njoke-bot: ${reply}`, { tags: [tag], scope: 'workspace', ttlSeconds: 30 * 24 * 60 * 60 }) | ||
| .catch((e) => ctx.log?.('warn', 'joke-bot.memory-save-failed', { error: String(e) })); | ||
| } | ||
|
|
||
| function buildPrompt(history: string[], question: string): string { | ||
| return [ | ||
| history.length > 0 ? `Conversation so far (oldest first):\n${history.join('\n')}\n` : '', | ||
| `The user just said: ${question}`, | ||
| '', | ||
| 'Reply with a single short, funny joke or comeback.' | ||
| ].filter(Boolean).join('\n'); | ||
| } | ||
|
|
||
| export default defineAgent({ | ||
| schedules: [{ name: 'joke-of-the-day', cron: '0 16 * * *', tz: 'UTC' }], | ||
| triggers: { | ||
| // NOTE: trigger `match` is currently NOT enforced by the cloud dispatch | ||
| // (parse-validated then dropped) — kept here for when cloud enforcement lands | ||
| // (AgentWorkforce/cloud). With `sandbox: false` there is no box to waste | ||
| // anyway, so the per-message wake is cheap (handler returns in ms). | ||
| slack: [ | ||
| { on: 'message.created', paths: ['/slack/channels/${SLACK_CHANNEL}/**'], match: '@mention' } | ||
| ] | ||
| }, | ||
|
|
||
| handler: async (ctx: WorkforceCtx, event: AgentEvent) => { | ||
| if (isCronTickEvent(event)) { | ||
| await handleJokeOfTheDay(ctx); | ||
| return; | ||
| } | ||
| if (typeof event.type === 'string' && event.type.startsWith('slack.')) { | ||
| await handleSlackMention(ctx, event); | ||
| return; | ||
| } | ||
| if (!isRelaycastMessageEvent(event)) return; | ||
|
|
||
| const channel = input(ctx, 'SLACK_CHANNEL'); | ||
| if (!channel) { | ||
| ctx.log?.('warn', 'joke-bot.no-channel', { reason: 'SLACK_CHANNEL not set; cannot reply' }); | ||
| return; | ||
| } | ||
| const question = await readQuestion(event); | ||
| if (!question) { | ||
| ctx.log?.('info', 'joke-bot.empty', { reason: 'no text in message; skipping' }); | ||
| return; | ||
| } | ||
| const tag = `joke-convo:${channel}`; | ||
| const reply = await joke(ctx, buildPrompt(await recall(ctx, tag), question)); | ||
| const result = await slackClient({ writebackTimeoutMs: 15_000 }).post(channel, reply); | ||
| if (!result?.ts) throw new Error('Slack post returned no receipt ts'); | ||
| await remember(ctx, tag, question, reply); | ||
| ctx.log?.('info', 'joke-bot.replied', { channel, surface: 'relay', chars: reply.length }); | ||
| } | ||
| }); | ||
|
|
||
| /** Slack @mention path: reply in-thread, with per-thread memory. */ | ||
| async function handleSlackMention(ctx: WorkforceCtx, event: AgentEvent): Promise<void> { | ||
| const data = ((await event.expand('full').catch(() => undefined)) as { data?: Record<string, unknown> } | undefined)?.data ?? {}; | ||
| const channel = typeof data.channel === 'string' ? data.channel : undefined; | ||
| const ts = typeof data.ts === 'string' ? data.ts : undefined; | ||
| if (!channel || !ts) { | ||
| ctx.log?.('info', 'joke-bot.slack-no-target', { reason: 'missing channel/ts' }); | ||
| return; | ||
| } | ||
| if (data.is_bot === true || data.bot_id || (typeof data.subtype === 'string' && data.subtype)) { | ||
| ctx.log?.('info', 'joke-bot.slack-skip', { reason: 'bot or non-plain message' }); | ||
| return; | ||
| } | ||
| const rawText = typeof data.text === 'string' ? data.text : ''; | ||
| if (!/<@[^>]+>/.test(rawText)) { | ||
| ctx.log?.('info', 'joke-bot.slack-no-mention', { reason: 'message did not mention the bot; skipping' }); | ||
| return; | ||
| } | ||
| const threadTs = typeof data.thread_ts === 'string' && data.thread_ts ? data.thread_ts : ts; | ||
| const question = rawText.replace(/<@[^>]+>/g, '').trim(); | ||
| if (!question) { | ||
| ctx.log?.('info', 'joke-bot.slack-empty', { reason: 'no text after stripping mention' }); | ||
| return; | ||
| } | ||
|
|
||
| const tag = `joke-convo:slack:${channel}:${threadTs}`; | ||
| const reply = await joke(ctx, buildPrompt(await recall(ctx, tag), question)); | ||
| const result = await slackClient({ writebackTimeoutMs: 15_000 }).reply(channel, threadTs, reply); | ||
| if (!result?.ts) throw new Error('Slack reply returned no receipt ts'); | ||
| await remember(ctx, tag, question, reply); | ||
| ctx.log?.('info', 'joke-bot.slack-replied', { channel, threadTs, chars: reply.length }); | ||
| } | ||
|
|
||
| /** Scheduled path: post one topical joke of the day. */ | ||
| async function handleJokeOfTheDay(ctx: WorkforceCtx): Promise<void> { | ||
| const channel = input(ctx, 'SLACK_CHANNEL'); | ||
| if (!channel) { | ||
| ctx.log?.('warn', 'joke-bot.no-channel', { reason: 'SLACK_CHANNEL not set; skipping joke of the day' }); | ||
| return; | ||
| } | ||
| const reply = await joke(ctx, 'Give me one short, original "joke of the day" about recent tech / pop-culture / current events.'); | ||
| const result = await slackClient({ writebackTimeoutMs: 15_000 }).post(channel, `🃏 Joke of the day:\n${reply}`); | ||
| if (!result?.ts) throw new Error('Slack post returned no receipt ts for joke of the day'); | ||
| ctx.log?.('info', 'joke-bot.jotd-posted', { channel }); | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| { | ||
| "id": "evt-jokebot-inbox-1", | ||
| "workspace": "ws-test", | ||
| "type": "relaycast.message", | ||
| "occurredAt": "2026-06-21T20:00:00Z", | ||
| "resource": { | ||
| "channel": "dm_inbox_test", | ||
| "messageId": "m-jokebot-1", | ||
| "text": "tell me a joke about AI hype" | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,88 @@ | ||
| import { definePersona } from '@agentworkforce/persona-kit'; | ||
|
|
||
| /** | ||
| * joke-bot — the simplest possible conversational agent: you DM it via the relay | ||
| * inbox, it replies in Slack with a joke. No provider data, no VFS reads — its | ||
| * only job is to PROVE the conversational path works end to end (relay inbox → | ||
| * harness → Slack writeback) and to exercise multi-turn threading. | ||
| * | ||
| * Why it exists: we keep getting conversational agents + threading wrong. A bot | ||
| * with zero data dependencies isolates the chat path from all the | ||
| * sync/materialization machinery — if joke-bot can hold a multi-turn | ||
| * conversation, "conversational agents work" is confirmed independent of the VFS. | ||
| * | ||
| * It is also the harness test vehicle: the joke is generated via ctx.harness.run | ||
| * (NOT ctx.llm.complete), so flipping `harness` below to claude / codex / opencode | ||
| * tests each provider's conversational + session-resume behavior in turn. | ||
| * | ||
| * Reply surface is Slack writeback (slackClient().post) — the proven chat-reply | ||
| * path for relay-inbox agents in this repo. | ||
| */ | ||
| export default definePersona({ | ||
| id: 'joke-bot', | ||
| intent: 'relay-orchestrator', | ||
| tags: ['discovery'], | ||
| description: | ||
| 'A conversational joke bot: DM it via the relay inbox and it replies in Slack with a pop-culture / current-events joke. Exists to confirm conversational agents + multi-turn threading work, and to test the claude/codex/opencode harnesses.', | ||
| cloud: true, | ||
|
|
||
| // sandbox:true (default) is required: the reply is a Slack WRITEBACK | ||
| // (slackClient().post → relayfile mount), and sandbox:false bypasses the | ||
| // relayfile mount, so the reply can't be written. The run is still fast — the | ||
| // handler answers via ctx.llm.complete (one LLM call), NOT ctx.harness.run | ||
| // (which boots a full CLI session and took minutes). Cost is just the box | ||
| // cold-start; trigger `match` (once cloud enforces it) limits when it provisions. | ||
| sandbox: true, | ||
|
|
||
| // ctx.llm.complete() resolves against the deployer's connected subscription | ||
| // credential (rides in providerEnv; the deploy log shows it selected for ctx.llm). | ||
| useSubscription: true, | ||
|
|
||
| // Swap this between 'claude' | 'codex' | 'opencode' to test each provider. | ||
| // The joke is produced by ctx.harness.run, so the harness here is what runs. | ||
| harness: 'claude', | ||
| model: 'claude-haiku-4-5-20251001', | ||
| systemPrompt: | ||
| "You are a sharp, fast stand-up comedian. You riff on current events, tech, and pop culture. " + | ||
| 'Keep replies short (1-3 lines), punchy, and genuinely funny — favor a clever observation or a tight setup→punchline over puns. ' + | ||
| 'Stay good-natured: no slurs, no punching down, nothing mean about the person you are talking to. ' + | ||
| 'If the user is clearly continuing an earlier bit, build on it (callback humor) using the conversation so far.', | ||
|
|
||
| harnessSettings: { reasoning: 'low', timeoutSeconds: 300 }, | ||
|
|
||
| // Makes joke-bot a candidate for Slack @AgentRelay conversational routing. | ||
| // The cloud dispatcher only routes app_mentions to personas with this | ||
| // capability; `channels` scopes it to proj-cloud and `defaultResponder` lets | ||
| // it answer there without having to name it (`@AgentRelay joke-bot ...` still | ||
| // works and is needed if other conversational agents share the channel). | ||
| capabilities: { | ||
| conversational: { | ||
| enabled: true, | ||
| defaultResponder: true, | ||
| channels: ['C0AD7UU0J1G'], | ||
| identity: { username: 'joke-bot' } | ||
| } | ||
| }, | ||
|
|
||
| // Slack is the reply surface (writeback to /slack/channels/{id}/messages), so | ||
| // scope channels. An unscoped slack mount would make post() a silent no-op. | ||
| integrations: { | ||
| slack: { scope: { channels: '/slack/channels/**' } } | ||
| }, | ||
|
|
||
| inputs: { | ||
| SLACK_CHANNEL: { | ||
| description: 'Slack channel id to reply in.', | ||
| env: 'SLACK_CHANNEL', | ||
| picker: { provider: 'slack', resource: 'channels' } | ||
| } | ||
| }, | ||
|
|
||
| // Conversation memory drives the multi-turn threading test: each turn is saved | ||
| // and recalled so the bot can do callbacks across messages. | ||
| memory: { enabled: true, scopes: ['workspace'], ttlDays: 30 }, | ||
|
|
||
| relay: { inbox: ['@self'] }, | ||
|
|
||
| onEvent: './agent.ts' | ||
| }); |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In deployments where Slack
message.createdarrives wrapped asresource.payloadorresource.record(the shapelinear-slackhandles explicitly),data.channelanddata.tsare undefined here, so every @mention exits throughjoke-bot.slack-no-targetand the Slack chat path never replies. Unwrap the Slack record before readingchannel,ts,text, and bot/subtype fields.Useful? React with 👍 / 👎.