Skip to content
Open
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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,8 @@ config/bots/DrElaraVoss.yaml
config/bots/Archimedes.yaml
tools/strangesonnet45/config/bots/strangesonnet45.yaml
config/bots/strangesonnet45.yaml
tools/strangesonnet45/*
tools/strangesonnet45/*
# portal local test instance + scratch
.k27-tmp/
portal-creds.json
verify.mjs
30 changes: 26 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@
"author": "",
"license": "MIT",
"dependencies": {
"@animalabs/membrane": "^0.5.47",
"@animalabs/membrane": "^0.5.67",
"@animalabs/portal-client": "^0.2.1",
"@animalabs/portal-protocol": "^0.2.0",
"@anthropic-ai/sdk": "^0.32.1",
"@aws-sdk/client-bedrock-runtime": "^3.709.0",
"@google/generative-ai": "^0.21.0",
Expand Down
43 changes: 39 additions & 4 deletions src/agent/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
import { EventQueue } from './event-queue.js'
import { DeferredQueue, isTransientError } from './deferred-queue.js'
import { ChannelStateManager } from './state-manager.js'
import { DiscordConnector, type PinnedSteer } from '../discord/connector.js'
import type { IConnector, PinnedSteer } from '../connector/types.js'
import { ConfigSystem } from '../config/system.js'
import { ContextBuilder, BuildContextParams } from '../context/builder.js'
import { ToolSystem } from '../tools/system.js'
Expand Down Expand Up @@ -97,7 +97,7 @@ export class AgentLoop {
constructor(
private botId: string,
private queue: EventQueue,
private connector: DiscordConnector,
private connector: IConnector,
private stateManager: ChannelStateManager,
private configSystem: ConfigSystem,
private contextBuilder: ContextBuilder,
Expand Down Expand Up @@ -793,7 +793,12 @@ export class AgentLoop {

if ((event.data as any)._isMCommand) {
reason = 'm_command'
} else if (message.reference?.messageId && this.botMessageIds.has(message.reference.messageId)) {
} else if (
(message.reference?.messageId && this.botMessageIds.has(message.reference.messageId)) ||
// Portal: the relay's 'reply' address reason is authoritative across restarts
// (botMessageIds only knows this session's sends).
message._address?.reasons?.includes('reply')
) {
reason = 'reply'
} else if (this.botUserId && message.mentions?.has(this.botUserId)) {
reason = 'mention'
Expand Down Expand Up @@ -874,7 +879,10 @@ export class AgentLoop {
userId: triggeringUser.id,
serverId: guildId,
channelId: channelId,
botId: this.botUserId || '',
// Portal personas have no Discord user id; key cost by the EMS bot name
// (this.botId) so admin cost commands (which resolve a portal role to the
// EMS name) target the same key. Account bots stay keyed by Discord user id.
botId: this.connector.isPortal() ? this.botId : (this.botUserId || ''),
messageId: triggeringMessageId || '',
triggerType: triggerReason as SomaTriggerType,
userRoles: triggeringUser.roles || [],
Expand Down Expand Up @@ -1401,6 +1409,33 @@ export class AgentLoop {
// A reply with "ping on reply" enabled includes the bot in message.mentions and is
// already handled by the mention check above. A reply with the ping toggled off must
// not wake the bot — fall through to the remaining triggers (random / name / etc.).
//
// EXCEPTION — portal backend: webhook personas can't carry a native reply/ping, so a
// reply-ping never lands in message.mentions. The relay instead surfaces it as the
// 'reply' address reason (computed per-recipient in portal-relay reasonsFor()). It
// cannot encode the ping toggle for personas, so any reply to one of our messages
// counts — this is the portal equivalent of the account-bot reply-ping above, and
// restores pre-hard-rule behavior for portal only. Account/discord.js events carry
// no _address, so they remain governed by the hard rule.
const portalReply = this.connector.isPortal() && message._address?.reasons?.includes('reply')
if (portalReply) {
const chainDepth = await this.connector.getBotReplyChainDepth(channelId, message)

if (!loadConfig()) return false

if (chainDepth >= config.max_bot_reply_chain_depth) {
logger.info({
messageId: message.id,
chainDepth,
limit: config.max_bot_reply_chain_depth
}, 'Bot reply chain depth limit reached, blocking portal reply activation')
await this.connector.addReaction(channelId, message.id, config.bot_reply_chain_depth_emote)
continue
}

logger.debug({ messageId: message.id, chainDepth }, 'Activated by portal reply')
return true
}

// 4. Random chance activation
if (!loadConfig()) return false
Expand Down
14 changes: 10 additions & 4 deletions src/api/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
*/

import express, { Request, Response, NextFunction } from 'express'
import { DiscordConnector } from '../discord/connector.js'
import type { IConnector } from '../connector/types.js'
import { ConfigSystem } from '../config/system.js'
import { ContextBuilder } from '../context/builder.js'
import { logger } from '../utils/logger.js'
Expand Down Expand Up @@ -68,7 +68,7 @@ export class ApiServer {

constructor(
private config: ApiConfig,
private connector: DiscordConnector,
private connector: IConnector,
private configSystem?: ConfigSystem,
private contextBuilder?: ContextBuilder,
private botName?: string
Expand Down Expand Up @@ -259,6 +259,10 @@ export class ApiServer {
}

const client = (this.connector as any).client
if (!client) {
res.status(501).json({ error: 'Not Implemented', message: 'channel listing requires the discord backend' })
return
}
const guilds = guildIdFilter
? [client.guilds.cache.get(guildIdFilter)].filter(Boolean)
: Array.from(client.guilds.cache.values())
Expand Down Expand Up @@ -578,7 +582,8 @@ export class ApiServer {

private async getUserInfo(userId: string, guildId?: string): Promise<any> {
const client = (this.connector as any).client

if (!client) throw new Error('user lookup requires the discord backend (portal mode)')

// Fetch user from Discord
let user
try {
Expand Down Expand Up @@ -629,7 +634,8 @@ export class ApiServer {

private async getUserAvatar(userId: string, size: number = 128): Promise<string | null> {
const client = (this.connector as any).client

if (!client) throw new Error('avatar lookup requires the discord backend (portal mode)')

try {
const user = await client.users.fetch(userId)
if (!user) {
Expand Down
190 changes: 190 additions & 0 deletions src/connector/portal/adapters.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { describe, it, expect } from 'vitest'
import type { PortalMessage, PortalEvent } from '@animalabs/portal-protocol'
import { inboundFromPortal, queueEventFromPortal, authorOfMessage, containerOf, type AdapterCtx } from './adapters.js'

const BOT = 'persona_bot'

function pm(over: Partial<PortalMessage> = {}): PortalMessage {
return {
id: 'rm_chan_100',
nativeId: '100',
channelId: 'chan',
guildId: 'guild',
author: { kind: 'user', userId: 'u1', username: 'alice', displayName: 'Alice', bot: false },
content: 'hello',
cleanContent: 'hello',
attachments: [],
mentions: { personas: [], roles: [], users: [], everyone: false },
reactions: [],
createdAt: '2026-06-14T00:00:00.000Z',
...over,
}
}

const ctx: AdapterCtx = {
botPersonaId: BOT,
authorOf: (id) => (id === 'rm_chan_100' ? 'u1' : undefined),
guildOf: () => 'guild',
}

describe('authorOfMessage', () => {
it('maps persona/user/system kinds', () => {
expect(authorOfMessage(pm({ author: { kind: 'persona', personaId: 'p9', displayName: 'Mythos', avatarUrl: '' } }))).toEqual({
id: 'p9',
username: 'Mythos',
bot: true,
globalName: 'Mythos',
})
expect(authorOfMessage(pm()).id).toBe('u1')
expect(authorOfMessage(pm()).bot).toBe(false)
expect(authorOfMessage(pm({ author: { kind: 'system' } }))).toEqual({ id: 'system', username: 'system', bot: true })
})
})

describe('containerOf', () => {
it('prefers threadId', () => {
expect(containerOf({ channelId: 'c', threadId: 't' })).toBe('t')
expect(containerOf({ channelId: 'c' })).toBe('c')
})
})

describe('inboundFromPortal', () => {
it('builds a discord.js-ish object', () => {
const m = inboundFromPortal(pm(), BOT)
expect(m.id).toBe('rm_chan_100')
expect(m.channelId).toBe('chan')
expect(m.author.id).toBe('u1')
expect(m.system).toBe(false)
expect(m.reference).toBeUndefined()
})

it('mentions.has() is true for a persona role-mention (→ bot mentioned)', () => {
const m = inboundFromPortal(pm({ mentions: { personas: [BOT], roles: [], users: [], everyone: false } }), BOT)
expect(m.mentions.has(BOT)).toBe(true)
expect(m.mentions.has('someone-else')).toBe(false)
})

it('mentions.has() is true for a direct user mention', () => {
const m = inboundFromPortal(pm({ mentions: { personas: [], roles: [], users: ['u42'], everyone: false } }), BOT)
expect(m.mentions.has('u42')).toBe(true)
})

it('reply maps replyToId → reference.messageId', () => {
const m = inboundFromPortal(pm({ replyToId: 'rm_chan_50' }), BOT)
expect(m.reference?.messageId).toBe('rm_chan_50')
})

it('threaded message uses threadId as channelId', () => {
const m = inboundFromPortal(pm({ threadId: 'thread1' }), BOT)
expect(m.channelId).toBe('thread1')
})

it('reactions.cache.some sees the eye emoji by name', () => {
const m = inboundFromPortal(pm({ reactions: [{ emoji: '👁‍🗨', count: 1, kind: 'native', by: [] }] }), BOT)
const hit = m.reactions!.cache.some((r: any) => r.emoji?.name === '👁‍🗨')
expect(hit).toBe(true)
})

it('system author sets system flag', () => {
const m = inboundFromPortal(pm({ author: { kind: 'system' } }), BOT)
expect(m.system).toBe(true)
})

it('event.data is mutable (loop sets _isMCommand)', () => {
const m = inboundFromPortal(pm(), BOT) as any
m._isMCommand = true
expect(m._isMCommand).toBe(true)
})
})

describe('queueEventFromPortal', () => {
it('message_create → message event', () => {
const e: PortalEvent = { type: 'message_create', message: pm(), addressedToMe: false, reasons: [] }
const ev = queueEventFromPortal(e, ctx)!
expect(ev.type).toBe('message')
expect(ev.channelId).toBe('chan')
expect(ev.guildId).toBe('guild')
expect(ev.data.id).toBe('rm_chan_100')
expect(ev.timestamp instanceof Date).toBe(true)
})

it('message_update → edit event with {old:undefined,new}', () => {
const e: PortalEvent = { type: 'message_update', message: pm({ content: 'edited' }), addressedToMe: false, reasons: [] }
const ev = queueEventFromPortal(e, ctx)!
expect(ev.type).toBe('edit')
expect(ev.data.old).toBeUndefined()
expect(ev.data.new.content).toBe('edited')
})

it('message_delete → delete event, resolves author from ctx', () => {
const e: PortalEvent = { type: 'message_delete', channelId: 'chan', messageId: 'rm_chan_100' }
const ev = queueEventFromPortal(e, ctx)!
expect(ev.type).toBe('delete')
expect(ev.data.id).toBe('rm_chan_100')
expect(ev.data.author.id).toBe('u1')
})

it('message_delete → author "unknown" when unresolved', () => {
const e: PortalEvent = { type: 'message_delete', channelId: 'chan', messageId: 'rm_chan_999' }
const ev = queueEventFromPortal(e, ctx)!
expect(ev.data.author.id).toBe('unknown')
})

it('reaction_add → reaction event with reactor + author', () => {
const e: PortalEvent = {
type: 'reaction_add',
channelId: 'chan',
messageId: 'rm_chan_100',
reaction: { emoji: '✅', count: 1, kind: 'native', by: [{ kind: 'user', id: 'u7', name: 'bob' }] },
}
const ev = queueEventFromPortal(e, ctx)!
expect(ev.type).toBe('reaction')
expect(ev.data.messageId).toBe('rm_chan_100')
expect(ev.data.emoji).toBe('✅')
expect(ev.data.userId).toBe('u7')
expect(ev.data.messageAuthorId).toBe('u1')
})

it('reaction_add with no known reactor → null (unattributable)', () => {
const e: PortalEvent = {
type: 'reaction_add',
channelId: 'chan',
messageId: 'rm_chan_100',
reaction: { emoji: '✅', count: 1, kind: 'native', by: [] },
}
expect(queueEventFromPortal(e, ctx)).toBeNull()
})

it('reaction_remove / typing / structural events → null', () => {
expect(queueEventFromPortal({ type: 'reaction_remove', channelId: 'c', messageId: 'm', emoji: 'x', actor: { kind: 'user', id: 'u', name: 'n' } }, ctx)).toBeNull()
expect(queueEventFromPortal({ type: 'typing', channelId: 'c', author: { kind: 'system' } }, ctx)).toBeNull()
expect(queueEventFromPortal({ type: 'pins_update', channelId: 'c' }, ctx)).toBeNull()
})

it('thread message_create uses threadId as event channelId', () => {
const e: PortalEvent = { type: 'message_create', message: pm({ threadId: 'thr' }), addressedToMe: false, reasons: [] }
const ev = queueEventFromPortal(e, ctx)!
expect(ev.channelId).toBe('thr')
})

it('propagates relay address reasons onto inbound._address (reply-ping signal)', () => {
// A reply-ping to a webhook persona never lands in mentions, so the loop's
// portal reply-activation depends on this 'reply' reason being carried through.
const e: PortalEvent = {
type: 'message_create',
message: pm({ replyToId: 'rm_chan_50' }),
addressedToMe: true,
reasons: ['reply'],
}
const ev = queueEventFromPortal(e, ctx)!
expect(ev.data._address).toEqual({ addressedToMe: true, reasons: ['reply'] })
expect(ev.data._address.reasons.includes('reply')).toBe(true)
})

it('ambient (unaddressed) message carries empty reasons', () => {
const e: PortalEvent = { type: 'message_create', message: pm(), addressedToMe: false, reasons: ['subscription'] }
const ev = queueEventFromPortal(e, ctx)!
expect(ev.data._address.addressedToMe).toBe(false)
expect(ev.data._address.reasons.includes('reply')).toBe(false)
})
})
Loading