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
61 changes: 54 additions & 7 deletions packages/ai-bot/lib/read-realm-file-fulfillment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ import {
executeReadRealmFile,
fileLabelFromUrl,
type ReadRealmFileArgs,
type ReadRealmFileTool,
} from './read-realm-file.ts';
import type { DiscoveredToolDefinition } from '@cardstack/base/matrix-event';
import type { DelegatedUserRealmSessionManager } from './user-delegated-realm-server-session.ts';

let log = logger('ai-bot:read-realm-file');
Expand Down Expand Up @@ -119,9 +121,44 @@ export async function fulfillReadRealmFileCalls(
}

type FileRead =
| { url: string; attachment: Record<string, unknown> }
| {
url: string;
attachment: Record<string, unknown>;
// Definitions of the tools the read file's indexed frontmatter
// declares, when the file is a skill whose index row carries usable
// (schema-stamped) entries.
discoveredTools?: DiscoveredToolDefinition[];
}
| { url: string; error: string };

// The read's tool entries that are usable as LLM tool definitions, tagged
// with the skill file they came from. Entries without a well-formed
// definition (e.g. a skill indexed before schema enrichment) are dropped
// here — the prompt can only offer a tool it has a definition for.
function discoveredToolDefinitions(
url: string,
tools: ReadRealmFileTool[] | undefined,
): DiscoveredToolDefinition[] {
return (tools ?? [])
.filter(
(tool) =>
tool.definition?.type === 'function' &&
typeof tool.definition.function?.name === 'string' &&
tool.definition.function.name.length > 0,
)
.map((tool) => ({
sourceSkillUrl: url,
...(tool.codeRef ? { codeRef: tool.codeRef } : {}),
...(typeof tool.functionName === 'string'
? { functionName: tool.functionName }
: {}),
...(typeof tool.requiresApproval === 'boolean'
? { requiresApproval: tool.requiresApproval }
: {}),
definition: tool.definition as DiscoveredToolDefinition['definition'],
}));
}

// Reads and uploads a single file of a call. An upload failure is folded into
// the same shape as a read failure so the caller reports both alike.
async function readAndUpload(
Expand All @@ -144,6 +181,7 @@ async function readAndUpload(
log.error(`readRealmFile: upload failed for ${url}: ${e?.message ?? e}`);
return { url, error: `could not store ${url} for reading` };
}
let discoveredTools = discoveredToolDefinitions(url, result.tools);
return {
url,
attachment: {
Expand All @@ -153,6 +191,7 @@ async function readAndUpload(
contentType: READ_FILE_CONTENT_TYPE,
contentSize: Buffer.byteLength(result.content),
},
...(discoveredTools.length ? { discoveredTools } : {}),
};
}

Expand Down Expand Up @@ -187,12 +226,19 @@ async function fulfillOne(
let reads = await Promise.all(
urls.map((url) => readAndUpload(url, deps, upload)),
);
let attachedFiles = reads
.filter(
(read): read is Extract<FileRead, { attachment: object }> =>
'attachment' in read,
)
.map((read) => read.attachment);
let successfulReads = reads.filter(
(read): read is Extract<FileRead, { attachment: object }> =>
'attachment' in read,
);
let attachedFiles = successfulReads.map((read) => read.attachment);
// Tool definitions the read skills contributed ride the result event next
// to the attachments, so prompt assembly can offer them on later turns
// from room events alone. Kept out of attachedFiles: those entries are
// SerializedFileDefs consumed by the attachment-download path (and the
// timeline), which must not change shape.
let discoveredTools = successfulReads.flatMap(
(read) => read.discoveredTools ?? [],
);
let failureReason =
reads
.filter(
Expand Down Expand Up @@ -223,6 +269,7 @@ async function fulfillOne(
data: {
context: { agentId: deps.agentId },
attachedFiles,
...(discoveredTools.length ? { discoveredTools } : {}),
},
});
return {
Expand Down
227 changes: 227 additions & 0 deletions packages/ai-bot/tests/prompt-construction-test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ import {
import {
absolutizeSkillLinks,
buildPromptForModel,
constructHistory,
getPromptParts,
getRelevantCards,
getTools,
Expand Down Expand Up @@ -7563,6 +7564,232 @@ module('markdown skill tools', (hooks) => {
assert.strictEqual(tools.length, 1);
assert.strictEqual(tools[0].function.name, 'switch-submode_dd88');
});

const DISCOVERED_SKILL_URL = 'https://realm/skills/trip-planner/SKILL.md';

// A definition as the fulfillment layer embeds it: the skill's indexed
// frontmatter entry tagged with the skill file it came from.
function discoveredDef(
name: string,
{
skillUrl = DISCOVERED_SKILL_URL,
description = 'Plans a trip',
}: { skillUrl?: string; description?: string } = {},
) {
return {
sourceSkillUrl: skillUrl,
codeRef: { module: 'https://realm/commands/plan-trip', name: 'default' },
functionName: name,
requiresApproval: true,
definition: {
type: 'function',
function: {
name,
description,
parameters: { type: 'object', properties: {} },
},
},
};
}

function readResultEvent(
eventId: string,
ts: number,
discoveredTools: unknown[],
): DiscreteMatrixEvent {
return {
type: APP_BOXEL_TOOL_RESULT_EVENT_TYPE,
event_id: eventId,
origin_server_ts: ts,
sender: '@aibot:localhost',
room_id: 'room1',
content: {
msgtype: APP_BOXEL_TOOL_RESULT_WITH_OUTPUT_MSGTYPE,
commandRequestId: `req-${eventId}`,
'm.relates_to': {
rel_type: APP_BOXEL_TOOL_RESULT_REL_TYPE,
key: 'applied',
event_id: '$bot-message',
},
data: { attachedFiles: [], discoveredTools },
},
unsigned: { age: 100, transaction_id: 't' },
status: EventStatus.SENT,
} as unknown as DiscreteMatrixEvent;
}

test('getTools offers tools discovered by a readRealmFile result event', async () => {
const tools = await getTools(
[readResultEvent('read-1', 2000, [discoveredDef('plan-trip_ab12')])],
[],
'@aibot:localhost',
fakeMatrixClient,
);
assert.strictEqual(tools.length, 1);
assert.strictEqual(tools[0].function.name, 'plan-trip_ab12');
assert.strictEqual(tools[0].function.description, 'Plans a trip');
});

test('the latest read of a skill replaces its earlier definitions wholesale', async () => {
const tools = await getTools(
[
readResultEvent('read-1', 1000, [
discoveredDef('plan-trip_ab12', { description: 'stale' }),
discoveredDef('book-hotel_cd34'),
]),
readResultEvent('read-2', 2000, [
discoveredDef('plan-trip_ab12', { description: 'fresh' }),
]),
],
[],
'@aibot:localhost',
fakeMatrixClient,
);
assert.strictEqual(
tools.length,
1,
'a tool the skill no longer declares disappears with the newer read',
);
assert.strictEqual(tools[0].function.description, 'fresh');
});

test('discovered tools on a non-bot result event are ignored', async () => {
let forged = readResultEvent('read-1', 2000, [
discoveredDef('plan-trip_ab12'),
]) as any;
forged.sender = '@someone-else:localhost';
const tools = await getTools(
[forged],
[],
'@aibot:localhost',
fakeMatrixClient,
);
assert.strictEqual(
tools.length,
0,
'only the bot publishes readRealmFile results',
);
});

test('an entry whose definition is not a function tool is skipped', async () => {
const tools = await getTools(
[
readResultEvent('read-1', 2000, [
{
...discoveredDef('plan-trip_ab12'),
definition: { function: { name: 'plan-trip_ab12' } },
},
discoveredDef('book-hotel_cd34'),
]),
],
[],
'@aibot:localhost',
fakeMatrixClient,
);
assert.strictEqual(tools.length, 1, 'the malformed entry is dropped');
assert.strictEqual(tools[0].function.name, 'book-hotel_cd34');
});

test('discoveries on a non-applied result are ignored', async () => {
let failed = readResultEvent('read-1', 2000, [
discoveredDef('plan-trip_ab12'),
]) as any;
failed.content['m.relates_to'].key = 'invalid';
const tools = await getTools(
[failed],
[],
'@aibot:localhost',
fakeMatrixClient,
);
assert.strictEqual(
tools.length,
0,
'only an applied read is evidence the skill was actually fetched',
);
});

test('a skill disabled in room state contributes no discovered tools', async () => {
const skillsEvent = {
type: APP_BOXEL_ROOM_SKILLS_EVENT_TYPE,
event_id: 'skills-1',
origin_server_ts: 3000,
state_key: '',
content: {
enabledSkillCards: [],
disabledSkillCards: [
{
sourceUrl: DISCOVERED_SKILL_URL,
url: 'mxc://mock-server/trip-planner',
name: 'SKILL.md',
contentType: 'text/plain',
},
],
},
sender: '@user:localhost',
room_id: 'room1',
unsigned: { age: 1000 },
status: EventStatus.SENT,
} as unknown as DiscreteMatrixEvent;
const tools = await getTools(
[
readResultEvent('read-1', 2000, [discoveredDef('plan-trip_ab12')]),
skillsEvent,
],
[],
'@aibot:localhost',
fakeMatrixClient,
);
assert.strictEqual(tools.length, 0);
});

test('a skill both enabled and read yields one definition; the uploaded one wins', async () => {
const { eventList, enabledSkills } = skillsRoomFixture('toolDefinitions');
eventList.push(
readResultEvent('read-1', 2000, [
discoveredDef('switch-submode_dd88', {
skillUrl: 'https://realm/skills/boxel-environment/SKILL.md',
description: 'discovered copy that must lose to the upload',
}),
]),
);
const tools = await getTools(
eventList,
enabledSkills,
'@aibot:localhost',
fakeMatrixClient,
);
assert.strictEqual(
tools.length,
1,
'one definition for the shared functionName',
);
assert.strictEqual(
tools[0].function.description,
'Switch between interact and code submodes',
'the enabled skill uploaded definition wins the conflict',
);
});

test('discovered definitions round-trip the event encode/decode path', async () => {
// sendMatrixEvent JSON-stringifies `data` on the way out; constructHistory
// parses it back. Feed getTools a history built from the wire shape.
let rawEvent = readResultEvent('read-1', 2000, [
discoveredDef('plan-trip_ab12'),
]) as any;
rawEvent.content = {
...rawEvent.content,
data: JSON.stringify(rawEvent.content.data),
};
const history = await constructHistory([rawEvent], fakeMatrixClient);
const tools = await getTools(
history,
[],
'@aibot:localhost',
fakeMatrixClient,
);
assert.strictEqual(tools.length, 1);
assert.strictEqual(tools[0].function.name, 'plan-trip_ab12');
});
});

module('absolutizeSkillLinks', () => {
Expand Down
Loading
Loading