Skip to content

Commit 8bc9242

Browse files
author
Shaw
committed
message ops
1 parent fb34e96 commit 8bc9242

4 files changed

Lines changed: 139 additions & 35 deletions

File tree

cloud/packages/tests/unit/eliza-agent-provision-route.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,7 @@ function installMocks(state: MockState): void {
148148
}
149149
return state.enqueueResult;
150150
},
151+
triggerImmediate: async () => undefined,
151152
},
152153
}));
153154

packages/core/src/services/message.ts

Lines changed: 79 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1113,11 +1113,79 @@ function createV5MessageContextObject(args: {
11131113
});
11141114
}
11151115

1116-
function renderV5MessageHandlerPrompt(context: ContextObject): string {
1117-
return v5MessageHandlerTemplate.replace(
1118-
"{{contextObject}}",
1119-
JSON.stringify(context, null, 2),
1120-
);
1116+
/**
1117+
* Format the role-filtered context catalog as a compact bullet list for the
1118+
* Stage 1 prompt. Each line is `- <id>: <description>` (or just `- <id>` when
1119+
* no description is available). Order is preserved from the registry, which
1120+
* keeps the rendered prefix byte-stable across iterations.
1121+
*/
1122+
export function formatAvailableContextsForPrompt(
1123+
contexts: readonly ContextDefinition[],
1124+
): string {
1125+
if (contexts.length === 0) {
1126+
return "(no contexts registered)";
1127+
}
1128+
return contexts
1129+
.map((definition) => {
1130+
const description = definition.description?.trim();
1131+
return description
1132+
? `- ${definition.id}: ${description}`
1133+
: `- ${definition.id}`;
1134+
})
1135+
.join("\n");
1136+
}
1137+
1138+
function renderV5MessageHandlerPrompt(
1139+
context: ContextObject,
1140+
availableContexts: readonly ContextDefinition[] = [],
1141+
): string {
1142+
return v5MessageHandlerTemplate
1143+
.replace("{{contextObject}}", JSON.stringify(context, null, 2))
1144+
.replace(
1145+
"{{availableContexts}}",
1146+
formatAvailableContextsForPrompt(availableContexts),
1147+
);
1148+
}
1149+
1150+
/**
1151+
* Resolve the calling sender's role for context-catalog filtering.
1152+
*
1153+
* This is best-effort: when there is no world context (DM-only sessions,
1154+
* benchmarks, tests), `checkSenderRole` returns null and we fall through to a
1155+
* conservative default. Owner-only messages always pass the agent's own
1156+
* messages without a world lookup.
1157+
*/
1158+
async function resolveStage1SenderRole(
1159+
runtime: IAgentRuntime,
1160+
message: Memory,
1161+
): Promise<RoleGateRole> {
1162+
if (typeof message.entityId === "string" && message.entityId === runtime.agentId) {
1163+
return "OWNER";
1164+
}
1165+
try {
1166+
const result = await checkSenderRole(runtime, message);
1167+
if (result?.role) {
1168+
return result.role as RoleGateRole;
1169+
}
1170+
} catch (error) {
1171+
runtime.logger.debug(
1172+
{ src: "service:message", error },
1173+
"Stage 1 sender role lookup failed; defaulting to USER",
1174+
);
1175+
}
1176+
// No world metadata — fall back to USER. This matches the lenient default
1177+
// in plugin-role-gating so local-only usage isn't blocked.
1178+
return "USER";
1179+
}
1180+
1181+
function listAvailableContextsForRole(
1182+
registry: ContextRegistry | undefined,
1183+
role: RoleGateRole,
1184+
): ContextDefinition[] {
1185+
if (!registry) {
1186+
return [];
1187+
}
1188+
return registry.listAvailable(role);
11211189
}
11221190

11231191
interface ExecuteV5PlannedToolCallParams {
@@ -1240,10 +1308,15 @@ export async function runV5MessageRuntimeStage1(args: {
12401308
responseId: UUID;
12411309
}): Promise<V5MessageRuntimeStage1Result> {
12421310
const context = createV5MessageContextObject(args);
1311+
const senderRole = await resolveStage1SenderRole(args.runtime, args.message);
1312+
const availableContexts = listAvailableContextsForRole(
1313+
args.runtime.contexts,
1314+
senderRole,
1315+
);
12431316
const rawMessageHandler = (await args.runtime.useModel(
12441317
ModelType.RESPONSE_HANDLER,
12451318
{
1246-
prompt: renderV5MessageHandlerPrompt(context),
1319+
prompt: renderV5MessageHandlerPrompt(context, availableContexts),
12471320
responseFormat: { type: "json_object" },
12481321
responseSchema: v5MessageHandlerSchema,
12491322
},

plugins/plugin-bluebubbles/src/actions/messageOp.ts

Lines changed: 45 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,12 @@ import type {
99
ActionExample,
1010
ActionResult,
1111
HandlerCallback,
12+
HandlerOptions,
1213
IAgentRuntime,
1314
Memory,
1415
State,
1516
} from "@elizaos/core";
16-
import {
17-
composePromptFromState,
18-
logger,
19-
ModelType,
20-
parseToonKeyValue,
21-
} from "@elizaos/core";
17+
import { composePromptFromState, logger, ModelType } from "@elizaos/core";
2218
import { BLUEBUBBLES_SERVICE_NAME } from "../constants.js";
2319
import type { BlueBubblesService } from "../service.js";
2420

@@ -45,12 +41,8 @@ Operations:
4541
- send: send an iMessage reply. Provide \`text\` with the reply.
4642
- react: add or remove a reaction. Provide \`emoji\`, \`messageId\` ("last" for the most recent message), and \`remove\` (true to remove).
4743
48-
Respond with TOON only:
49-
op: send
50-
text:
51-
emoji:
52-
messageId: last
53-
remove: false
44+
Respond with JSON only. Return exactly one JSON object with this shape:
45+
{"op":"send","text":"","emoji":null,"messageId":"last","remove":false}
5446
`;
5547

5648
const sendMessageTemplate = `# Task: Generate a response to send via iMessage (BlueBubbles)
@@ -89,11 +81,38 @@ const examples: ActionExample[][] = [
8981
],
9082
];
9183

92-
function parseInfo(raw: string): MessageOpInfo | null {
93-
const parsed = parseToonKeyValue<Record<string, unknown>>(raw);
94-
if (!parsed) {
84+
function parseJsonObject(value: unknown): Record<string, unknown> | null {
85+
if (value && typeof value === "object" && !Array.isArray(value)) {
86+
return value as Record<string, unknown>;
87+
}
88+
if (typeof value !== "string") {
89+
return null;
90+
}
91+
try {
92+
const parsed = JSON.parse(value.trim()) as unknown;
93+
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
94+
? (parsed as Record<string, unknown>)
95+
: null;
96+
} catch {
9597
return null;
9698
}
99+
}
100+
101+
function readParams(
102+
options?: HandlerOptions | Record<string, unknown> | unknown,
103+
): Record<string, unknown> {
104+
const direct =
105+
options && typeof options === "object"
106+
? (options as Record<string, unknown>)
107+
: {};
108+
const parameters =
109+
direct.parameters && typeof direct.parameters === "object"
110+
? (direct.parameters as Record<string, unknown>)
111+
: {};
112+
return { ...direct, ...parameters };
113+
}
114+
115+
function normalizeInfo(parsed: Record<string, unknown>): MessageOpInfo | null {
97116
const opRaw =
98117
typeof parsed.op === "string" ? parsed.op.toLowerCase().trim() : "";
99118
if (!VALID_OPS.has(opRaw as MessageOp)) {
@@ -119,6 +138,11 @@ function parseInfo(raw: string): MessageOpInfo | null {
119138
};
120139
}
121140

141+
function parseInfo(raw: unknown): MessageOpInfo | null {
142+
const parsed = parseJsonObject(raw);
143+
return parsed ? normalizeInfo(parsed) : null;
144+
}
145+
122146
async function handleSend(
123147
runtime: IAgentRuntime,
124148
service: BlueBubblesService,
@@ -272,7 +296,7 @@ export const bluebubblesMessageOp: Action = {
272296
runtime: IAgentRuntime,
273297
message: Memory,
274298
state: State | undefined,
275-
_options: Record<string, unknown> | undefined,
299+
_options: HandlerOptions | Record<string, unknown> | undefined,
276300
callback?: HandlerCallback,
277301
): Promise<ActionResult> => {
278302
const service = runtime.getService<BlueBubblesService>(
@@ -315,12 +339,13 @@ export const bluebubblesMessageOp: Action = {
315339
template: messageOpTemplate,
316340
});
317341

318-
let info: MessageOpInfo | null = null;
342+
let info: MessageOpInfo | null = normalizeInfo(readParams(_options));
319343
for (let attempt = 0; attempt < 3; attempt++) {
344+
if (info) {
345+
break;
346+
}
320347
const response = await runtime.useModel(ModelType.TEXT_SMALL, { prompt });
321-
info = parseInfo(
322-
typeof response === "string" ? response : String(response),
323-
);
348+
info = parseInfo(response);
324349
if (info) {
325350
break;
326351
}

plugins/plugin-discord/actions/mediaOp.ts

Lines changed: 14 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,13 @@ import {
1212
type Memory,
1313
MemoryType,
1414
ModelType,
15-
parseToonKeyValue,
1615
type Service,
1716
ServiceType,
1817
type State,
1918
} from "@elizaos/core";
2019
import { mediaUrlTemplate } from "../generated/prompts/typescript/prompts.js";
2120
import { requireActionSpec } from "../generated/specs/spec-helpers";
21+
import { getActionParameters, parseJsonObjectFromText } from "../utils";
2222

2323
type DiscordMediaOp = "download" | "transcribe";
2424

@@ -33,8 +33,8 @@ Allowed values for "op":
3333
- download: download a media attachment or URL from Discord
3434
- transcribe: transcribe an audio or video attachment from Discord
3535
36-
Respond with TOON only:
37-
op: <one of: download|transcribe>`;
36+
Respond with JSON only, no markdown:
37+
{"op":"download"}`;
3838

3939
const TRANSCRIBE_REQUEST_PATTERN =
4040
/\b(?:transcribe|transcript|audio|video|media|youtube|meeting|recording|podcast|call|conference|interview|speech|lecture|presentation|voice|song)\b/i;
@@ -201,9 +201,10 @@ function quickResolveOp(
201201
options: HandlerOptions | undefined,
202202
message: Memory,
203203
): DiscordMediaOp | null {
204+
const parameters = getActionParameters(options);
204205
const optsOp =
205-
typeof (options as Record<string, unknown> | undefined)?.op === "string"
206-
? ((options as Record<string, unknown>).op as string).toLowerCase()
206+
typeof parameters.op === "string"
207+
? parameters.op.toLowerCase()
207208
: undefined;
208209
if (optsOp && (VALID_OPS as readonly string[]).includes(optsOp)) {
209210
return optsOp as DiscordMediaOp;
@@ -227,7 +228,7 @@ async function modelResolveOp(
227228
const prompt = composePromptFromState({ state, template: opRouterTemplate });
228229
for (let i = 0; i < 3; i++) {
229230
const response = await runtime.useModel(ModelType.TEXT_SMALL, { prompt });
230-
const parsed = parseToonKeyValue<Record<string, unknown>>(response);
231+
const parsed = parseJsonObjectFromText(response);
231232
const op =
232233
typeof parsed?.op === "string" ? parsed.op.toLowerCase() : undefined;
233234
if (op && (VALID_OPS as readonly string[]).includes(op)) {
@@ -241,6 +242,7 @@ async function handleDownload(
241242
runtime: IAgentRuntime,
242243
message: Memory,
243244
state: State,
245+
options: HandlerOptions | undefined,
244246
callback: HandlerCallback | undefined,
245247
): Promise<ActionResult | undefined> {
246248
const videoService = runtime.getService<VideoServiceInterface>(
@@ -257,11 +259,14 @@ async function handleDownload(
257259
return { success: false, error: "Video service not available" };
258260
}
259261

262+
const parameters = getActionParameters(options);
263+
let mediaUrl =
264+
typeof parameters.mediaUrl === "string" ? parameters.mediaUrl : null;
260265
const prompt = composePromptFromState({ state, template: mediaUrlTemplate });
261-
let mediaUrl: string | null = null;
262266
for (let i = 0; i < 5; i++) {
267+
if (mediaUrl) break;
263268
const response = await runtime.useModel(ModelType.TEXT_SMALL, { prompt });
264-
const parsed = parseToonKeyValue<Record<string, unknown>>(response);
269+
const parsed = parseJsonObjectFromText(response);
265270
if (parsed?.mediaUrl) {
266271
mediaUrl = String(parsed.mediaUrl);
267272
break;
@@ -446,7 +451,7 @@ export const mediaOp: Action = {
446451
case "download": {
447452
const downloadState =
448453
currentState ?? (await runtime.composeState(message));
449-
return handleDownload(runtime, message, downloadState, callback);
454+
return handleDownload(runtime, message, downloadState, options, callback);
450455
}
451456
case "transcribe":
452457
return handleTranscribe(runtime, message, callback);

0 commit comments

Comments
 (0)