Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions joke-bot/agent.ts
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;
Comment on lines +132 to +134

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Unwrap Slack event payloads before reading fields

In deployments where Slack message.created arrives wrapped as resource.payload or resource.record (the shape linear-slack handles explicitly), data.channel and data.ts are undefined here, so every @mention exits through joke-bot.slack-no-target and the Slack chat path never replies. Unwrap the Slack record before reading channel, ts, text, and bot/subtype fields.

Useful? React with 👍 / 👎.

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 });
}
11 changes: 11 additions & 0 deletions joke-bot/fixtures/inbox-joke.json
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"
}
}
88 changes: 88 additions & 0 deletions joke-bot/persona.ts
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'
});
6 changes: 6 additions & 0 deletions linear-slack/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,12 @@ export default defineAgent({
{
on: 'message.created',
paths: ['/slack/channels/${SLACK_CHANNEL}/**'],
// Gate the WAKE on an actual mention so the cloud only provisions a
// Daytona box when the agent is addressed — without this, every message
// in the board channel provisions a box + runs the harness just to be
// self-filtered (sandbox-per-message waste). The handler still strips
// the mention and reads the rest.
match: '@mention',
},
],
},
Expand Down