diff --git a/client/src/components/Assistant/AssistantChangeCard.tsx b/client/src/components/Assistant/AssistantChangeCard.tsx
index 20382c138..7812c3e09 100644
--- a/client/src/components/Assistant/AssistantChangeCard.tsx
+++ b/client/src/components/Assistant/AssistantChangeCard.tsx
@@ -12,8 +12,10 @@ import {
MapPinPlus,
NotebookPen,
PencilLine,
+ Plus,
StickyNote,
Ticket,
+ Trash2,
TriangleAlert,
Undo2,
Wallet,
@@ -83,6 +85,9 @@ const VERB_LOOK: Record;
return (
- {t(`assistant.change.verb.${preview.verbKey}`)}
+
+ {t(`assistant.change.verb.${preview.verbKey}`)}
+ {preview.domain && (
+ // Where in TREK a change outside the trip plan lands: the vacation planner, the journal.
+
+ {t(`assistant.domain.${preview.domain}`)}
+
+ )}
+
{preview.subject}
diff --git a/client/src/components/Assistant/AssistantChangeFields.tsx b/client/src/components/Assistant/AssistantChangeFields.tsx
index 5d828f494..dd4b829a8 100644
--- a/client/src/components/Assistant/AssistantChangeFields.tsx
+++ b/client/src/components/Assistant/AssistantChangeFields.tsx
@@ -41,6 +41,12 @@ const KEY_ORDER = Object.keys(KEY_FIELDS) as AssistantChangeLabelKey[];
/** Fields that mean nothing written out. */
const HIDDEN: ReadonlySet = new Set(['coordinates', 'position', 'icon', 'color', 'currency']);
+/** What a row is called: its label, or for a tool's own argument the argument's name in words. */
+function labelOf(row: AssistantChangeRow, t: (key: string) => string): string {
+ if (row.labelKey === 'field' && row.field) return row.field.replace(/_/g, ' ').replace(/([a-z])([A-Z])/g, '$1 $2');
+ return t(CHANGE_LABELS[row.labelKey]?.labelKey ?? 'assistant.change.label.field');
+}
+
/** "#3" is how a day comes from the server; it reads as "Day 3". */
function shown(row: AssistantChangeRow, value: string | null | undefined, dayWord: string): string {
if (row.labelKey === 'day' && value && /^#\d+$/.test(value)) return `${dayWord} ${value.slice(1)}`;
@@ -50,7 +56,7 @@ function shown(row: AssistantChangeRow, value: string | null | undefined, dayWor
function Chip({ row }: { row: AssistantChangeRow }) {
const { t } = useTranslation();
const Icon = KEY_FIELDS[row.labelKey] ?? Tag;
- const label = t(CHANGE_LABELS[row.labelKey].labelKey);
+ const label = labelOf(row, t);
const dayWord = t('assistant.week.day');
const changed = row.op !== 'add' && !!row.before;
return (
@@ -76,7 +82,7 @@ function DetailLine({ row }: { row: AssistantChangeRow }) {
const changed = row.op !== 'add' && !!row.before;
return (
-
{t(CHANGE_LABELS[row.labelKey].labelKey)}
+
{labelOf(row, t)}
{changed && (
<>
diff --git a/client/src/components/Assistant/assistantParts.ts b/client/src/components/Assistant/assistantParts.ts
index ad9504526..28533f3db 100644
--- a/client/src/components/Assistant/assistantParts.ts
+++ b/client/src/components/Assistant/assistantParts.ts
@@ -249,6 +249,8 @@ export function undoLeftMs(applied: AssistantApplied | null, now: number): numbe
/** The label of each field a change row can show. A table, so every key is named where it is read. */
export const CHANGE_LABELS: Record = {
+ // A tool's own argument: the row names it in `field`, this is only its fallback.
+ field: { labelKey: 'assistant.change.label.field' },
name: { labelKey: 'assistant.change.label.name' },
description: { labelKey: 'assistant.change.label.description' },
address: { labelKey: 'assistant.change.label.address' },
diff --git a/server/src/nest/assistant/assistant-tool-offer.ts b/server/src/nest/assistant/assistant-tool-offer.ts
new file mode 100644
index 000000000..64d2dd332
--- /dev/null
+++ b/server/src/nest/assistant/assistant-tool-offer.ts
@@ -0,0 +1,205 @@
+import type { McpRegistryListing } from '../../nest-mcp';
+import type { AiToolSpec } from '../ai-connections/ai-chat-client.interface';
+
+/**
+ * Which of TREK's MCP tools the assistant is offered, and how. Every tool the
+ * registry holds is offered, except the few below that no approval makes
+ * safe; the scopes in ASSISTANT_SCOPES hide the rest of what must never run
+ * (deleting a trip, share and invite links, file contents, plugins). A tool
+ * annotated read-only runs at once, every other one waits as a change card.
+ *
+ * Offering all of them in every step would cost a lot of tokens and runs past
+ * what some providers take in one request, so a turn starts with the core of
+ * a trip (days, places, bookings, packing, to-dos, budget, weather, search)
+ * and the model loads an addon's tools when it needs them (USE_TOOLS_TOOL).
+ */
+
+/** Never offered, whatever the user approves, each for the reason beside it. */
+export const ASSISTANT_DENIED_TOOLS: Readonly> = {
+ delete_trip: 'irreversible, and it takes the conversation with it',
+ copy_trip: 'makes a trip outside the conversation',
+ create_trip: 'makes a trip outside the conversation',
+ leave_trip: 'changes who can see the trip',
+ add_trip_member: 'changes who can see the trip',
+ remove_trip_member: 'changes who can see the trip',
+ leave_collection: 'changes who can see the collection',
+ set_collection_member_role: 'changes who can see the collection',
+ remove_collection_member: 'changes who can see the collection',
+ add_journey_contributor: 'changes who can see the journey',
+ update_journey_contributor_role: 'changes who can see the journey',
+ remove_journey_contributor: 'changes who can see the journey',
+ invite_to_collection: 'invites a person; the model could pick whom',
+ send_vacay_invite: 'invites a person; the model could pick whom',
+ share_vacay_calendar: 'shares a calendar with a person',
+ create_share_link: 'mints a public link, whose token would reach the AI provider',
+ get_share_link: 'reads a public link token',
+ delete_share_link: 'belongs with the share links above',
+ get_trip_invite_link: 'reads an invite token',
+ create_trip_invite_link: 'mints an invite token',
+ delete_trip_invite_link: 'belongs with the invite links above',
+ get_journey_share_link: 'reads a public link token',
+ create_journey_share_link: 'mints a public link token',
+ delete_journey_share_link: 'belongs with the share links above',
+ get_trip_calendar_feed: 'reads a calendar feed token',
+ enable_trip_calendar_feed: 'mints a calendar feed token',
+ rotate_trip_calendar_feed: 'mints a calendar feed token',
+ disable_trip_calendar_feed: 'belongs with the feeds above',
+ get_all_trips_calendar_feed: 'reads a calendar feed token',
+ enable_all_trips_calendar_feed: 'mints a calendar feed token',
+ rotate_all_trips_calendar_feed: 'mints a calendar feed token',
+ disable_all_trips_calendar_feed: 'belongs with the feeds above',
+ import_places_from_url: 'fetches an address the model chooses',
+ read_trip_file: 'would send documents such as passports to the AI provider',
+ upload_trip_file: 'writes a file the model made up',
+ create_category: 'changes the categories of every user',
+ update_category: 'changes the categories of every user',
+ delete_category: 'changes the categories of every user',
+ create_manual_school_holiday_country: 'changes the holidays of every user',
+ create_manual_school_holiday_region: 'changes the holidays of every user',
+ update_manual_school_holiday_region: 'changes the holidays of every user',
+ delete_manual_school_holiday_region: 'changes the holidays of every user',
+ delete_manual_school_holiday_country: 'changes the holidays of every user',
+};
+
+/**
+ * Names that read like sharing but only list or take back what the user
+ * shared already. Any other name that shares, invites or publishes a feed
+ * must be denied above; the boot check holds the two apart.
+ */
+const SHARE_LIKE_SAFE: ReadonlySet = new Set([
+ 'set_packing_item_sharing',
+ 'list_vacay_shares',
+ 'get_shareable_vacay_users',
+ 'get_shared_vacay_calendars',
+ 'unshare_vacay_calendar',
+ 'cancel_vacay_invite',
+ 'accept_vacay_invite',
+ 'decline_vacay_invite',
+ 'cancel_collection_invite',
+ 'accept_collection_invite',
+ 'decline_collection_invite',
+]);
+const SHARE_LIKE = /share|invite|feed/;
+
+/** Read-only tools that still wait for the user: they fetch an address that came from the web or a message. */
+export const HELD_READS: ReadonlySet = new Set([
+ 'resolve_maps_url',
+ 'preview_google_maps_route',
+ 'preview_google_maps_walk',
+]);
+
+export function isOfferedTool(name: string): boolean {
+ return !(name in ASSISTANT_DENIED_TOOLS);
+}
+
+/** What is wrong with the denylist against the registry: a sharing tool that slipped through. */
+export function checkDenylist(names: Iterable): string[] {
+ const problems: string[] = [];
+ for (const name of names) {
+ if (SHARE_LIKE.test(name) && isOfferedTool(name) && !SHARE_LIKE_SAFE.has(name)) {
+ problems.push(`"${name}" shares, invites or publishes; deny it or list it as safe`);
+ }
+ }
+ return problems;
+}
+
+/**
+ * The tool groups a turn can load, by the MCP controller that registers the
+ * tools, each with the words the model reads to pick it. A controller not
+ * listed here belongs to the core, which every step offers.
+ */
+export const ASSISTANT_TOOL_GROUPS: Readonly> = {
+ tours: {
+ about:
+ 'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them, ' +
+ 'their packing, the tour library and imports from Google Maps.',
+ controllers: [
+ 'ToursMcp',
+ 'CityWalkMcp',
+ 'TourChangeMcp',
+ 'TourChangeUndoMcp',
+ 'TourLibraryMcp',
+ 'TourPackingMcp',
+ 'TourActivitiesMcp',
+ 'TourActivityStatsMcp',
+ 'TourWalkImportMcp',
+ 'CollectionToursMcp',
+ 'PaddleRoutingMcp',
+ 'SkiRoutingMcp',
+ 'GoogleRouteMcp',
+ ],
+ },
+ roadtrip: {
+ about: 'Road trips and transit: the driving route and its stops, charging, hazards, public transport journeys.',
+ controllers: [
+ 'RoadtripMcp',
+ 'RoadtripPlanningMcp',
+ 'RoadtripPreferencesMcp',
+ 'RoadtripHazardsMcp',
+ 'ChargingMcp',
+ 'TransitMcp',
+ ],
+ },
+ journey: {
+ about: 'Journeys (the travel journal): entries, photos, the journal linked to this trip, location history.',
+ controllers: ['JourneyMcp', 'TripJournalLinkMcp', 'MemoriesMcp', 'DawarichMcp'],
+ },
+ vacay: {
+ about: 'Vacation planner: vacation days per year, public and school holidays, shared calendars.',
+ controllers: ['VacayMcp', 'SchoolHolidaysMcp'],
+ },
+ atlas: { about: 'Atlas: visited countries and regions, the bucket list.', controllers: ['AtlasMcp'] },
+ collections: {
+ about: 'Collections: saved places across trips, their labels and ratings.',
+ controllers: ['CollectionsMcp'],
+ },
+ collab: { about: 'Trip chat: notes, polls and messages to the people on the trip.', controllers: ['CollabMcp'] },
+ files: {
+ about: 'Files and imports: the trip files, document sync, flights from AirTrail.',
+ controllers: ['FilesMcp', 'DocSyncMcp', 'ReservationImportMcp', 'AirtrailMcp'],
+ },
+ account: {
+ about: 'The user: notifications and display settings.',
+ controllers: ['NotificationsMcp', 'SettingsMcp'],
+ },
+ plugins: {
+ about: 'Places and points of interest from installed plugins.',
+ controllers: ['PluginPoisMcp', 'PluginSearchMcp'],
+ },
+};
+
+const GROUP_OF_CONTROLLER = new Map(
+ Object.entries(ASSISTANT_TOOL_GROUPS).flatMap(([group, { controllers }]) =>
+ controllers.map((controller) => [controller, group] as const),
+ ),
+);
+
+/** The group a registered tool loads with; null for the core. */
+export function groupOf(entry: Pick): string | null {
+ return GROUP_OF_CONTROLLER.get(entry.className) ?? null;
+}
+
+/** The server's own step that loads a group's tools for the rest of the turn. */
+export const USE_TOOLS_TOOL = 'use_addon_tools';
+
+export const USE_TOOLS_SPEC: AiToolSpec = {
+ name: USE_TOOLS_TOOL,
+ description:
+ 'Load the tools of more parts of TREK for the rest of this turn. Call it before working with any of: ' +
+ Object.entries(ASSISTANT_TOOL_GROUPS)
+ .map(([group, { about }]) => `"${group}" (${about})`)
+ .join('; ') +
+ '. The trip itself (days, places, bookings, packing, to-dos, budget, weather, search) needs nothing loaded.',
+ parameters: {
+ type: 'object',
+ properties: {
+ groups: {
+ type: 'array',
+ items: { type: 'string', enum: Object.keys(ASSISTANT_TOOL_GROUPS) },
+ minItems: 1,
+ },
+ },
+ required: ['groups'],
+ additionalProperties: false,
+ },
+};
diff --git a/server/src/nest/assistant/assistant-tools.constants.ts b/server/src/nest/assistant/assistant-tools.constants.ts
index 0788836f1..d77692f68 100644
--- a/server/src/nest/assistant/assistant-tools.constants.ts
+++ b/server/src/nest/assistant/assistant-tools.constants.ts
@@ -26,15 +26,21 @@ export const APPROVAL_TTL_MS = 24 * 60 * 60_000;
export const TURNS_PER_MINUTE = 30;
/**
- * What an assistant session may do, as the scopes an MCP client would hold.
- * Day notes sit in the `trips` group and day assignments in `places`; the
- * allowlist below, not these scopes, is what decides which tools exist.
+ * What an assistant session may do, as the scopes an MCP client would hold:
+ * everything a user can reach through MCP but deleting trips, share and
+ * invite links (trips:share, journey:share), file contents and plugin tools,
+ * whose tools the registry then never attaches. assistant-tool-offer.ts
+ * denies the few tools inside these scopes that no approval makes safe.
*/
export const ASSISTANT_SCOPES: readonly string[] = [
'trips:read',
'trips:write',
'places:read',
'places:write',
+ 'collections:read',
+ 'collections:write',
+ 'atlas:read',
+ 'atlas:write',
'packing:read',
'packing:write',
'todos:read',
@@ -43,23 +49,20 @@ export const ASSISTANT_SCOPES: readonly string[] = [
'budget:write',
'reservations:read',
'reservations:write',
+ 'collab:read',
+ 'collab:write',
+ 'notifications:read',
+ 'notifications:write',
+ 'vacay:read',
+ 'vacay:write',
'geo:read',
'weather:read',
-];
-
-/** Tools that only read: they run as soon as the model calls them. */
-export const ASSISTANT_READ_TOOLS: readonly string[] = [
- 'get_trip_summary',
- 'list_places',
- 'search_place',
- 'get_place_details',
- 'search_nearby_places',
- 'search_pois',
- 'list_categories',
- 'get_weather',
- 'list_todos',
- 'get_settlement_summary',
- 'search_help',
+ 'journey:read',
+ 'journey:write',
+ 'files:read',
+ 'files:write',
+ 'settings:read',
+ 'settings:write',
];
/**
@@ -73,7 +76,11 @@ interface AssistantWriteTool {
quiet?: boolean;
}
-/** Tools that change the trip: each call is held as a change card until the user applies it. */
+/**
+ * The writing tools with a change card of their own: its verb and what it
+ * touches once applied. Every other writing tool is held as a card too, one
+ * built from its arguments (tool-call-generic.helpers.ts).
+ */
export const ASSISTANT_WRITE_TOOLS: Readonly> = {
create_place: { verbKey: 'addPlace', entity: 'place' },
create_and_assign_place: { verbKey: 'addPlaceToDay', entity: 'place' },
@@ -120,8 +127,8 @@ const FOUND_PLACE_VIA: ReadonlySet = new Set(['create_and_assign_place',
/**
* Changes the server applies itself: a booking from an attached file, a search
* result the user picked, a note the user wants kept. They are held, decided
- * and recorded like the writes above, and they are not on the allowlist; only
- * the note is offered to the model, by the session itself (REMEMBER_SPEC). A booking is applied by the server
+ * and recorded like the writes above, and no MCP tool has their names; only the
+ * note is offered to the model, by the session itself (REMEMBER_SPEC). A booking is applied by the server
* itself; a found place deliberately goes through the MCP tool named in its
* `via` argument, so it is applied with the user's rights, broadcast and
* undone exactly like the same change proposed by the model.
@@ -162,57 +169,6 @@ export const MAX_BOOKINGS_PER_FILE = MAX_WRITES_PER_STEP;
/** The largest file the assistant reads. */
export const MAX_ATTACHMENT_BYTES = 10 * 1024 * 1024;
-/**
- * The one removal the model may propose: taking a place off a day. The tool
- * is annotated destructive because the assignment row goes, but the place
- * stays in the trip, so it is offered like any other change card. Without it
- * the model "replaced" a day's plan by renaming the place that was on it.
- */
-export const ASSISTANT_REMOVAL_TOOLS: ReadonlySet = new Set(['unassign_place']);
-
-/** Every tool the model is offered. Nothing outside this list is attached to an assistant session. */
-export const ASSISTANT_TOOL_ALLOWLIST: ReadonlySet = new Set([
- ...ASSISTANT_READ_TOOLS,
- ...Object.keys(ASSISTANT_WRITE_TOOLS),
-]);
-
-/**
- * Tools the assistant must never be offered, kept as a list so a test can
- * hold the allowlist apart from it: whatever reaches beyond the trip
- * (members, sharing, invitations, feeds, files, other trips), fetches an
- * address of the model's choosing, or deletes (taking a place off a day is
- * the one removal offered, see ASSISTANT_REMOVAL_TOOLS). `isDeniedTool` adds every name
- * that shares, invites or publishes a feed.
- */
-const ASSISTANT_TOOL_DENYLIST: ReadonlySet = new Set([
- 'import_places_from_url',
- 'resolve_maps_url',
- 'add_trip_member',
- 'remove_trip_member',
- 'leave_trip',
- 'delete_trip',
- 'copy_trip',
- 'read_trip_file',
- 'upload_trip_file',
- 'invite_to_collection',
- 'send_vacay_invite',
- 'share_vacay_calendar',
- 'update_display_settings',
- 'send_collab_message',
- 'list_upcoming_reservations',
- 'delete_place',
- 'delete_day_note',
- 'delete_packing_item',
- 'delete_todo',
- 'delete_budget_item',
-]);
-
-const DENIED_NAME = /share|invite|feed/;
-
-export function isDeniedTool(name: string): boolean {
- return ASSISTANT_TOOL_DENYLIST.has(name) || DENIED_NAME.test(name);
-}
-
/** The argument every trip tool takes; the server sets it to the trip the conversation is about. */
export const PINNED_ARG = 'tripId';
@@ -230,6 +186,8 @@ export const ASSISTANT_ARG_POLICY: Readonly<
create_place: { stripped: ['image_url'] },
create_and_assign_place: { stripped: ['image_url'] },
update_place: { stripped: ['image_url'] },
+ bulk_update_places: { stripped: ['image_url'] },
+ update_trip: { stripped: ['cover_image'] },
};
type ToolArgs = Record;
diff --git a/server/src/nest/assistant/assistant-tools.service.ts b/server/src/nest/assistant/assistant-tools.service.ts
index 28c63938e..1e090830d 100644
--- a/server/src/nest/assistant/assistant-tools.service.ts
+++ b/server/src/nest/assistant/assistant-tools.service.ts
@@ -4,15 +4,21 @@ import { AuditService } from '../audit/audit.service';
import { traceEntry } from '../audit/entry-trace.logger';
import { withRequestContext } from '../database/request-context';
import { REMEMBER_SPEC } from './assistant-memory';
+import {
+ ASSISTANT_TOOL_GROUPS,
+ checkDenylist,
+ groupOf,
+ HELD_READS,
+ isOfferedTool,
+ USE_TOOLS_SPEC,
+ USE_TOOLS_TOOL,
+} from './assistant-tool-offer';
import {
ASSISTANT_ARG_POLICY,
- ASSISTANT_READ_TOOLS,
ASSISTANT_SCOPES,
ASSISTANT_SERVER_TOOLS,
- ASSISTANT_TOOL_ALLOWLIST,
ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
- isDeniedTool,
PINNED_ARG,
REMEMBER_TOOL,
} from './assistant-tools.constants';
@@ -32,46 +38,55 @@ export interface ToolRun {
/** Whether a tool runs at once or waits for the user. */
export type AssistantToolKind = 'read' | 'write';
+/** What a card for a tool without one of its own goes by: what the tool says it does, and where it works. */
+export interface ToolHint {
+ destructive: boolean;
+ creates: boolean;
+ group: string | null;
+}
+
interface ListedTool {
name: string;
description: string;
inputSchema: Record;
kind: AssistantToolKind;
pinned: boolean;
+ hint: ToolHint;
}
+/** A turn's session offers every tool (the core first, the rest by group); an undo's holds its one target. */
+type SessionMode = 'turn' | 'undo';
+
/**
* What is wrong with the tool catalog the assistant relies on, given what the
- * registry holds: an allowlisted tool or an undo target that no longer exists,
- * an allowlisted tool that is also denied, or a tool named like one of the
- * server's own steps. Empty when all is well.
+ * registry holds: a tool with a card of its own or an undo target that no
+ * longer exists, a sharing tool the denylist missed, or a tool named like one
+ * of the server's own steps. Empty when all is well.
*/
export function checkToolCatalog(listing: readonly McpRegistryListing[]): string[] {
const tools = new Set(listing.filter((entry) => entry.kind === 'tool').map((entry) => entry.name));
const problems: string[] = [];
- for (const name of ASSISTANT_TOOL_ALLOWLIST) {
- if (!tools.has(name)) problems.push(`allowlisted tool "${name}" is not registered`);
- if (isDeniedTool(name)) problems.push(`tool "${name}" is both allowlisted and denied`);
+ for (const name of Object.keys(ASSISTANT_WRITE_TOOLS)) {
+ if (!tools.has(name)) problems.push(`tool "${name}" with a card of its own is not registered`);
}
for (const undo of Object.values(ASSISTANT_UNDO_MAP)) {
if (!tools.has(undo.tool)) problems.push(`undo tool "${undo.tool}" is not registered`);
}
// The server's own steps are told apart from tool calls by name alone.
- for (const name of ASSISTANT_SERVER_TOOLS) {
- if (tools.has(name) || ASSISTANT_TOOL_ALLOWLIST.has(name)) problems.push(`"${name}" is the server's own step`);
+ for (const name of [...ASSISTANT_SERVER_TOOLS, USE_TOOLS_TOOL]) {
+ if (tools.has(name)) problems.push(`"${name}" is the server's own step`);
}
- return problems;
+ return [...problems, ...checkDenylist(tools)];
}
/**
- * How a listed tool is treated: a read runs at once, a write waits for the
- * user. A tool counts as a read only when both the list and its own
- * annotation say so, and as a write only when the list names it; anything
- * else is not offered, so a tool whose annotation changed fails closed.
+ * How a listed tool is treated: a read runs at once, everything else waits
+ * for the user. A tool counts as a read only when its own annotation says so
+ * and it fetches nothing a message could have planted, so a tool whose
+ * annotation is missing or wrong costs an extra card, never a silent write.
*/
-function kindOf(name: string, readOnly: boolean): AssistantToolKind | null {
- if (readOnly && ASSISTANT_READ_TOOLS.includes(name)) return 'read';
- return ASSISTANT_WRITE_TOOLS[name] ? 'write' : null;
+function kindOf(name: string, readOnly: boolean): AssistantToolKind {
+ return readOnly && !HELD_READS.has(name) ? 'read' : 'write';
}
function hiddenArgs(name: string): Set {
@@ -127,25 +142,40 @@ export class AssistantToolSession {
private readonly tools: ReadonlyMap,
private readonly tripId: number,
private readonly audit: (toolName: string) => void,
- /** Whether the model may keep trip notes here: a turn's session, never an undo's. */
- private readonly offersNotes: boolean,
+ private readonly mode: SessionMode,
) {}
- /** The tools as the model is offered them, with the trip notes the server keeps itself. */
+ /** The groups this turn loaded; the core is always there. */
+ private readonly loaded = new Set();
+
+ /**
+ * The tools as the model is offered them: the core and the groups it
+ * loaded, the trip notes, and the step that loads the other groups.
+ */
specs(): AiToolSpec[] {
- const listed = [...this.tools.values()].map((tool) => ({
- name: tool.name,
- description: tool.description,
- parameters: modelSchema(tool.name, tool.inputSchema),
- }));
- return this.offersNotes ? [...listed, REMEMBER_SPEC] : listed;
+ const listed = [...this.tools.values()]
+ .filter((tool) => this.mode === 'undo' || tool.hint.group === null || this.loaded.has(tool.hint.group))
+ .map((tool) => ({
+ name: tool.name,
+ description: tool.description,
+ parameters: modelSchema(tool.name, tool.inputSchema),
+ }));
+ if (this.mode === 'undo') return listed;
+ const more = Object.keys(ASSISTANT_TOOL_GROUPS).some((group) => !this.loaded.has(group));
+ return [...listed, REMEMBER_SPEC, ...(more ? [USE_TOOLS_SPEC] : [])];
}
kindOf(name: string): AssistantToolKind | null {
- if (name === REMEMBER_TOOL) return this.offersNotes ? 'write' : null;
+ if (name === REMEMBER_TOOL) return this.mode === 'turn' ? 'write' : null;
+ if (name === USE_TOOLS_TOOL) return this.mode === 'turn' ? 'read' : null;
return this.tools.get(name)?.kind ?? null;
}
+ /** What a card for this tool goes by; null for a tool this session does not hold. */
+ hintOf(name: string): ToolHint | null {
+ return this.tools.get(name)?.hint ?? null;
+ }
+
/**
* The model's arguments as they are shown and stored: the trip, the forced
* and the stripped arguments taken out. A trip id other than this
@@ -162,6 +192,7 @@ export class AssistantToolSession {
/** Runs a tool on the user's behalf (a read the model asked for, an applied change, an undo), audited. */
async call(name: string, args: ToolArgs): Promise {
+ if (name === USE_TOOLS_TOOL) return this.load(args);
const run = await this.invoke(name, args);
this.audit(name);
return run;
@@ -172,6 +203,17 @@ export class AssistantToolSession {
return this.invoke(name, args);
}
+ /** Loads tool groups for the rest of the turn, and says which tools came with them. */
+ private load(args: ToolArgs): ToolRun {
+ const asked = Array.isArray(args.groups) ? args.groups.filter((g): g is string => typeof g === 'string') : [];
+ const groups = asked.filter((group) => group in ASSISTANT_TOOL_GROUPS);
+ if (groups.length === 0)
+ return { ok: false, value: `Name one of: ${Object.keys(ASSISTANT_TOOL_GROUPS).join(', ')}` };
+ for (const group of groups) this.loaded.add(group);
+ const tools = [...this.tools.values()].filter((tool) => tool.hint.group && groups.includes(tool.hint.group));
+ return { ok: true, value: { loaded: groups, tools: tools.map((tool) => tool.name) } };
+ }
+
async close(): Promise {
await this.client.close().catch(() => undefined);
await this.server.close().catch(() => undefined);
@@ -208,11 +250,13 @@ export class AssistantToolsService implements OnApplicationBootstrap {
}
/**
- * A session for one user and trip holding the named tools (the allowlist by
- * default, a single undo target for an undo). No plugin tools are ever
+ * A session for one user and trip: for a turn every tool the assistant is
+ * offered, for an undo only the named targets. No plugin tools are ever
* attached: the assistant passes no dynamic tool source.
*/
- async open(userId: number, tripId: number, include: ReadonlySet = ASSISTANT_TOOL_ALLOWLIST) {
+ async open(userId: number, tripId: number, undoTargets?: ReadonlySet) {
+ const include = (name: string) => (undoTargets ? undoTargets.has(name) : isOfferedTool(name));
+ const classOf = new Map(this.registry.list().map((entry) => [entry.name, entry] as const));
const server = createLocalMcpServer('TREK assistant');
const around = (info: { kind: string; name: string }, call: () => unknown): unknown =>
traceEntry('mcp', `${info.kind} ${info.name} user=${userId} client=assistant`, () =>
@@ -222,7 +266,7 @@ export class AssistantToolsService implements OnApplicationBootstrap {
this.registry.attach(
server,
{ userId, scopes: [...ASSISTANT_SCOPES], isStaticToken: false },
- { include: (kind, name) => kind === 'tool' && include.has(name), around },
+ { include: (kind, name) => kind === 'tool' && include(name), around },
),
);
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@@ -232,21 +276,26 @@ export class AssistantToolsService implements OnApplicationBootstrap {
const tools = new Map();
for (const tool of (await client.listTools()).tools) {
- const isUndoTarget = !ASSISTANT_TOOL_ALLOWLIST.has(tool.name);
- const kind = isUndoTarget ? 'write' : kindOf(tool.name, tool.annotations?.readOnlyHint === true);
- if (!kind) continue;
+ const annotations = tool.annotations ?? {};
+ const kind = undoTargets ? 'write' : kindOf(tool.name, annotations.readOnlyHint === true);
const inputSchema = tool.inputSchema as Record;
const properties = (inputSchema.properties as Record | undefined) ?? {};
+ const entry = classOf.get(tool.name);
tools.set(tool.name, {
name: tool.name,
description: tool.description ?? '',
inputSchema,
kind,
pinned: PINNED_ARG in properties,
+ hint: {
+ destructive: annotations.destructiveHint === true,
+ creates: annotations.idempotentHint === false,
+ group: entry ? groupOf(entry) : null,
+ },
});
}
- const offersNotes = include === ASSISTANT_TOOL_ALLOWLIST;
- return new AssistantToolSession(client, server, tools, tripId, (name) => this.audit(userId, name), offersNotes);
+ const mode: SessionMode = undoTargets ? 'undo' : 'turn';
+ return new AssistantToolSession(client, server, tools, tripId, (name) => this.audit(userId, name), mode);
}
/** The audit row an MCP client's call writes, marked as the assistant's. A failed write never fails the call. */
diff --git a/server/src/nest/assistant/assistant.prompt.ts b/server/src/nest/assistant/assistant.prompt.ts
index 6e08440cc..224b797b8 100644
--- a/server/src/nest/assistant/assistant.prompt.ts
+++ b/server/src/nest/assistant/assistant.prompt.ts
@@ -25,6 +25,9 @@ How you work:
- The trip card may start with what the user is asking about. A question about "this place", "it" or "here" is about that; never ask which place they mean then.
- The user can name days, places and bookings with "@" before the name. The trip card then lists what they named, with the ids; use those.
- The app shows the places your searches find as cards with a picture, and the weather you look up as a strip. Do not list them again in your text; say in a sentence or two which ones you suggest and why.
+- Besides the open trip you can work in the rest of TREK: tours (hikes, bike tours, city walks), the road trip, the travel journal, the vacation planner, the atlas, collections, the trip chat, files and the user's settings. Their tools are not loaded at first: call use_addon_tools with the groups you need, then use them in the same reply. Tools without a trip work on the user's own data across trips.
+- Propose a deletion only when the user asks for it; it cannot be undone, and the card says so.
+- You cannot share, invite people, publish links or calendar feeds, change who can see a trip, leave or delete the trip. If asked, say so in one sentence and where in TREK the user can do it.
- When the user asks you to remember something for this trip (who travels, what they like or avoid), keep it with remember_for_trip; it comes back as in every later conversation. Never say you cannot remember things.
- Existing expenses can be changed with update_budget_item (their ids are in get_trip_summary); never add a second entry to correct one.
- If the request is unclear, ask one short question instead of guessing.`;
diff --git a/server/src/nest/assistant/assistant.service.ts b/server/src/nest/assistant/assistant.service.ts
index e0ad1c816..fa0161bc6 100644
--- a/server/src/nest/assistant/assistant.service.ts
+++ b/server/src/nest/assistant/assistant.service.ts
@@ -840,7 +840,8 @@ export class AssistantService {
store: this.stepStore(spec),
describe: async (toolName, toolCallId, args) => {
categories ??= await this.categoriesOf(session);
- return describeToolCall(toolName, toolCallId, args, { card, categories, locale: spec.locale });
+ const hintOf = (name: string) => session.hintOf(name);
+ return describeToolCall(toolName, toolCallId, args, { card, categories, locale: spec.locale, hintOf });
},
newId: randomUUID,
quota: spec.quota,
diff --git a/server/src/nest/assistant/tool-call-generic.helpers.ts b/server/src/nest/assistant/tool-call-generic.helpers.ts
new file mode 100644
index 000000000..dc708d649
--- /dev/null
+++ b/server/src/nest/assistant/tool-call-generic.helpers.ts
@@ -0,0 +1,93 @@
+import type { TripCard } from './assistant-context';
+import type { ToolHint } from './assistant-tools.service';
+import type { AssistantChangePreview, AssistantChangeRow, AssistantChangeVerbKey } from '@trek/shared';
+
+/**
+ * The change card of a writing tool without a card of its own: a verb read
+ * from what the tool says it does (removes, creates, changes), a subject from
+ * the name or title it is given, and one row per argument, with the trip's
+ * days, places and people named where an argument holds their id. It is never
+ * undoable: only the tools with a matching removal are.
+ */
+
+type Args = Record;
+
+const MAX_ROWS = 12;
+const MAX_VALUE_CHARS = 160;
+const SUBJECT_KEYS = ['name', 'title', 'text', 'label', 'content', 'region', 'country_code', 'date'] as const;
+
+function clip(value: string): string {
+ const line = value.replace(/\s+/g, ' ').trim();
+ return line.length > MAX_VALUE_CHARS ? `${line.slice(0, MAX_VALUE_CHARS - 1)}…` : line;
+}
+
+/** "update_vacay_plan" reads as "update vacay plan". */
+export function humanToolName(name: string): string {
+ return name.replace(/_/g, ' ');
+}
+
+function verbOf(hint: ToolHint | null, name: string): AssistantChangeVerbKey {
+ if (hint?.destructive || /^(delete|remove|unassign|unmark|clear|dissolve|unlink|unshare)_/.test(name)) {
+ return 'genericRemove';
+ }
+ if (hint?.creates || /^(create|add|import|save|mark|copy|take_over|link)_/.test(name)) return 'genericCreate';
+ return 'genericUpdate';
+}
+
+/** An argument holding the id of a day, place or member, named as the trip names it. */
+function named(card: TripCard, key: string, value: unknown): string | null {
+ if (typeof value !== 'number') return null;
+ if (/^day_?id$/i.test(key)) {
+ const day = card.days.find((d) => d.id === value);
+ return day ? (day.title ?? day.date ?? `#${day.number}`) : null;
+ }
+ if (/^place_?id$/i.test(key)) return card.places.get(value)?.name ?? null;
+ if (/^(user_?id|member_?id|assigned_user_id)$/i.test(key)) return card.members.get(value) ?? null;
+ return null;
+}
+
+function valueText(card: TripCard, key: string, value: unknown): string {
+ const name = named(card, key, value);
+ if (name) return clip(name);
+ if (typeof value === 'boolean') return value ? '✓' : '✗';
+ if (Array.isArray(value)) {
+ const items = value.map((item) => (typeof item === 'object' ? JSON.stringify(item) : String(item)));
+ return clip(items.join(', '));
+ }
+ if (value && typeof value === 'object') return clip(JSON.stringify(value));
+ return clip(String(value));
+}
+
+export function describeGeneric(
+ toolName: string,
+ toolCallId: string,
+ args: Args,
+ card: TripCard,
+ hint: ToolHint | null,
+): AssistantChangePreview {
+ const subjectKey = SUBJECT_KEYS.find((key) => typeof args[key] === 'string' && (args[key] as string).trim());
+ const rows: AssistantChangeRow[] = Object.entries(args)
+ .filter(([key, value]) => key !== subjectKey && value !== undefined && value !== null && value !== '')
+ .slice(0, MAX_ROWS)
+ .map(([key, value]) => ({
+ op: 'add',
+ labelKey: 'field',
+ field: key.slice(0, 60),
+ before: null,
+ after: valueText(card, key, value),
+ }));
+ const dayId = typeof args.dayId === 'number' ? args.dayId : typeof args.day_id === 'number' ? args.day_id : null;
+ const placeId = typeof args.placeId === 'number' ? args.placeId : null;
+ return {
+ toolCallId,
+ verbKey: verbOf(hint, toolName),
+ subject: clip(subjectKey ? String(args[subjectKey]) : humanToolName(toolName)),
+ rows,
+ dayId: dayId && card.days.some((d) => d.id === dayId) ? dayId : null,
+ placeIds: placeId && card.places.has(placeId) ? [placeId] : [],
+ undoable: false,
+ effect: null,
+ tool: toolName,
+ ...(hint?.group ? { domain: hint.group } : {}),
+ };
+}
diff --git a/server/src/nest/assistant/tool-call-preview.helpers.ts b/server/src/nest/assistant/tool-call-preview.helpers.ts
index 740cad306..80810d114 100644
--- a/server/src/nest/assistant/tool-call-preview.helpers.ts
+++ b/server/src/nest/assistant/tool-call-preview.helpers.ts
@@ -8,6 +8,8 @@ import {
REMEMBER_TOOL,
writeToolOf,
} from './assistant-tools.constants';
+import type { ToolHint } from './assistant-tools.service';
+import { describeGeneric } from './tool-call-generic.helpers';
import type {
AssistantApplied,
AssistantChangeEffect,
@@ -29,6 +31,8 @@ export interface PreviewContext {
card: TripCard;
categories: ReadonlyMap;
locale: string;
+ /** What a tool without a card of its own says about itself, for the card built from its arguments. */
+ hintOf?: (toolName: string) => ToolHint | null;
}
/** Longest value a row shows; URLs keep their start, so the host stays visible. */
@@ -494,7 +498,7 @@ export function describeToolCall(
const runs = writeToolOf(toolName, args);
const tool = writeTool(runs);
const describe = DESCRIBERS[runs];
- if (!tool || !describe) throw new Error(`No change card for tool "${toolName}"`);
+ if (!tool || !describe) return describeGeneric(runs, toolCallId, args, ctx.card, ctx.hintOf?.(runs) ?? null);
const described = describe(ctx, args);
return {
toolCallId,
@@ -542,8 +546,8 @@ export function appliedChange(
dayId: number | null,
undoUntil: string | null,
): AssistantApplied | null {
- const tool = writeTool(writeToolOf(toolName, args));
- if (!tool) return null;
+ // A tool without a card of its own touched something the client has no view for.
+ const tool = writeTool(writeToolOf(toolName, args)) ?? { entity: 'other' as const };
const touched = createdId(result) ?? ARG_KEYS.map((key) => id(args, key)).find((value) => value !== null) ?? null;
return {
toolCallId,
diff --git a/server/tests/e2e/assistant.e2e.test.ts b/server/tests/e2e/assistant.e2e.test.ts
index d4f54f0ad..244b4fd68 100644
--- a/server/tests/e2e/assistant.e2e.test.ts
+++ b/server/tests/e2e/assistant.e2e.test.ts
@@ -374,7 +374,7 @@ describe('Trip assistant e2e', () => {
expect(shown.body.messages[1].parts).toEqual([{ type: 'text', text: 'Kyoto in June is lovely.' }]);
});
- it('ASTE2E-004: a read runs at once on the trip, and the model never sees the trip id or the denied tools', async () => {
+ it('ASTE2E-004: a read runs at once on the trip, and the model never sees the trip id or a denied tool', async () => {
const t = await travelerWithTrip();
provider.script = [callTools({ id: 'call_r', name: 'list_places', args: {} }), say('You have Kinkaku-ji.')];
const turn = await post(`${t.base}/chat`, t.userId, { ...CTX, threadId: null, text: 'What is planned?' });
@@ -383,7 +383,7 @@ describe('Trip assistant e2e', () => {
const tools = provider.requests[0].tools ?? [];
const names = tools.map((tool) => tool.function.name);
expect(names).toContain('list_places');
- for (const denied of ['delete_place', 'delete_trip', 'import_places_from_url', 'create_share_link']) {
+ for (const denied of ['delete_trip', 'import_places_from_url', 'create_share_link']) {
expect(names).not.toContain(denied);
}
for (const tool of tools) expect(Object.keys(tool.function.parameters.properties ?? {})).not.toContain('tripId');
diff --git a/server/tests/unit/nest/assistant/assistant-tools.service.test.ts b/server/tests/unit/nest/assistant/assistant-tools.service.test.ts
index 763bfc909..81f4775e4 100644
--- a/server/tests/unit/nest/assistant/assistant-tools.service.test.ts
+++ b/server/tests/unit/nest/assistant/assistant-tools.service.test.ts
@@ -1,23 +1,25 @@
/**
- * The assistant's tool sessions over the container's real MCP registry: the
- * allowlist exists, stays apart from what is denied and matches the tools'
- * own annotations; a session offers nothing else (no plugin tools), hides
- * the trip and the arguments the server sets; and its calls run on the
- * conversation's trip with the user's rights, broadcasting and auditing as
- * an MCP client's would.
+ * The assistant's tool sessions over the container's real MCP registry: every
+ * registered tool is offered but the denied ones, a sharing tool cannot slip
+ * past the denylist, what runs at once is what the tools call read-only, a
+ * turn starts with the core and loads an addon's tools on request; a session
+ * hides the trip and the arguments the server sets, and its calls run on the
+ * conversation's trip with the user's rights, broadcasting and auditing as an
+ * MCP client's would.
*/
import { AuditLog } from '../../../../src/db/entities/AuditLog.entity';
import { McpRegistryService } from '../../../../src/nest-mcp';
+import {
+ ASSISTANT_DENIED_TOOLS,
+ ASSISTANT_TOOL_GROUPS,
+ checkDenylist,
+ isOfferedTool,
+} from '../../../../src/nest/assistant/assistant-tool-offer';
import {
ASSISTANT_ARG_POLICY,
- ASSISTANT_READ_TOOLS,
- ASSISTANT_REMOVAL_TOOLS,
ASSISTANT_SCOPES,
ASSISTANT_SERVER_TOOLS,
- ASSISTANT_TOOL_ALLOWLIST,
- ASSISTANT_UNDO_MAP,
ASSISTANT_WRITE_TOOLS,
- isDeniedTool,
} from '../../../../src/nest/assistant/assistant-tools.constants';
import { AssistantToolsService, checkToolCatalog } from '../../../../src/nest/assistant/assistant-tools.service';
import { withRequestContext } from '../../../../src/nest/database/request-context';
@@ -47,9 +49,9 @@ interface ListedTool {
}
let listed: Map;
-/** Every allowlisted tool and undo target as the registry attaches them, with full scopes and every addon on. */
+/** Every registered tool as the registry attaches it, with full scopes and every addon on. */
async function listRaw(userId: number): Promise