mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(assistant): a card framework every addon package plugs into
This commit is contained in:
@@ -3,7 +3,9 @@ import {
|
||||
FERRATAS_RESULT_CAP,
|
||||
dawarichTrackSegmentSchema,
|
||||
assistantChangePreviewSchema,
|
||||
assistantMessagePartSchema,
|
||||
assistantChangePreviewWriteSchema,
|
||||
assistantMessagePartWriteSchema,
|
||||
assistantStoredPartsSchema,
|
||||
ferrataSchema,
|
||||
hikingPreferencesSchema,
|
||||
obstacleSnapshotSchema,
|
||||
@@ -335,12 +337,24 @@ export const JOURNEY_RECORDED_DAY_SEGMENTS: JsonColumn<DawarichTrackSegment[]> =
|
||||
|
||||
/**
|
||||
* The parts of an assistant message as the client renders them: text, tool
|
||||
* calls with their results and approvals, change previews and receipts. A
|
||||
* broken row reads as an empty message and is logged.
|
||||
* calls with their results and approvals, change previews, receipts and
|
||||
* cards. A data part this build does not know (one a later build wrote) is
|
||||
* dropped on read, not the message, and a later verb, entity or card kind
|
||||
* reads as its generic stand-in; any other broken row reads as an empty
|
||||
* message and is logged. A row this build rewrites (a decision, a cancel)
|
||||
* is written back as it was read, so what only a later build knew is gone
|
||||
* from it for good: a rollback costs those cards, never the conversation.
|
||||
*/
|
||||
export const ASSISTANT_MESSAGE_PARTS: JsonColumn<AssistantMessagePart[]> = {
|
||||
column: 'assistant_messages.parts',
|
||||
schema: z.array(assistantMessagePartSchema),
|
||||
schema: assistantStoredPartsSchema,
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
/** The same column as it is written: strictly, so a part this build cannot describe is a bug in the writer. */
|
||||
export const ASSISTANT_MESSAGE_PARTS_WRITE: JsonColumn<AssistantMessagePart[]> = {
|
||||
column: ASSISTANT_MESSAGE_PARTS.column,
|
||||
schema: z.array(assistantMessagePartWriteSchema),
|
||||
fallback: () => [],
|
||||
};
|
||||
|
||||
@@ -370,13 +384,20 @@ export const ASSISTANT_TOOL_ARGS: JsonColumn<Record<string, unknown>> = {
|
||||
fallback: () => ({}),
|
||||
};
|
||||
|
||||
/** The change card of a proposed change. A broken row reads as none. */
|
||||
/** The change card of a proposed change. A broken row reads as none, a later verb or card kind as its stand-in. */
|
||||
export const ASSISTANT_TOOL_PREVIEW: JsonColumn<AssistantChangePreview | null> = {
|
||||
column: 'assistant_tool_calls.preview',
|
||||
schema: assistantChangePreviewSchema.nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
/** The same column as it is written: strictly, like the message parts. */
|
||||
export const ASSISTANT_TOOL_PREVIEW_WRITE: JsonColumn<AssistantChangePreview | null> = {
|
||||
column: ASSISTANT_TOOL_PREVIEW.column,
|
||||
schema: assistantChangePreviewWriteSchema.nullable(),
|
||||
fallback: () => null,
|
||||
};
|
||||
|
||||
/** What an executed change answered, the tool's own result. A broken row reads as none, which offers no undo. */
|
||||
export const ASSISTANT_TOOL_RESULT: JsonColumn<unknown> = {
|
||||
column: 'assistant_tool_calls.result',
|
||||
|
||||
@@ -19,5 +19,7 @@ import { Module } from '@nestjs/common';
|
||||
imports: [MikroOrmModule.forFeature([Reservations, ReservationEndpoints]), SchedulingModule],
|
||||
controllers: [AirportsController],
|
||||
providers: [AirportsService, AirportsMcp],
|
||||
// The assistant's cards name an airport by its code (AssistantModule).
|
||||
exports: [AirportsService],
|
||||
})
|
||||
export class AirportsModule {}
|
||||
|
||||
@@ -9,15 +9,18 @@ import {
|
||||
import { activityOf, foundPlacesOf, googleIdsOf, weatherDayOf, weatherOf } from './assistant-cards.helpers';
|
||||
import type { TripCard } from './assistant-context';
|
||||
import { outputText } from './assistant-history';
|
||||
import { toolOutputForProvider } from './assistant-redact';
|
||||
import { MAX_READS_PER_STEP, MAX_STEPS, MAX_WRITES_PER_STEP } from './assistant-tools.constants';
|
||||
import { toolOutputForProvider } from './assistant-output';
|
||||
import { MAX_CARDS_PER_STEP, MAX_READS_PER_STEP, MAX_STEPS, MAX_WRITES_PER_STEP } from './assistant-tools.constants';
|
||||
import type { AssistantToolKind, ToolRun } from './assistant-tools.service';
|
||||
import type { AnswerInput } from './cards/card-types';
|
||||
import { answerCardOf } from './cards/registry';
|
||||
import type { UiStreamWriter } from './ui-stream.writer';
|
||||
import type {
|
||||
AssistantChangePreview,
|
||||
AssistantErrorCode,
|
||||
AssistantMessagePart,
|
||||
AssistantWeatherDay,
|
||||
TrekCard,
|
||||
} from '@trek/shared';
|
||||
|
||||
type ToolArgs = Record<string, unknown>;
|
||||
@@ -66,6 +69,8 @@ export interface AgentDeps {
|
||||
card: TripCard;
|
||||
/** Today in the user's time zone (`YYYY-MM-DD`). */
|
||||
today: string;
|
||||
/** The card a read's answer shows; the registry's (`answerCardOf`) unless a test hands in its own. */
|
||||
answerCard?: (tool: string, input: AnswerInput) => TrekCard | null;
|
||||
}
|
||||
|
||||
export interface AgentInput {
|
||||
@@ -96,8 +101,13 @@ interface StepParts {
|
||||
weather: AssistantWeatherDay[];
|
||||
/** The google place ids the turn has seen so far; a search adds what it found. */
|
||||
knownPlaceIds: Set<string>;
|
||||
/** The answer cards the step has shown; past MAX_CARDS_PER_STEP a read shows none. */
|
||||
cards: number;
|
||||
}
|
||||
|
||||
/** The longest call id a card is kept under; a read under a longer one shows no card. */
|
||||
const MAX_CARD_CALL_ID = 64;
|
||||
|
||||
const UNKNOWN_PLACE_ID =
|
||||
'Unknown place id. Use a google place id from the trip card or from a search result of this turn; ' +
|
||||
'if you have none, call search_place first.';
|
||||
@@ -132,8 +142,9 @@ const GO_ON =
|
||||
|
||||
/**
|
||||
* Runs a read at once and records it: where it looks (streamed only), its
|
||||
* call and answer, and the cards its answer makes. The cards are built from
|
||||
* the tool's own answer, the parts the provider sees from the redacted one.
|
||||
* call and answer, and the cards its answer makes (its package's card first,
|
||||
* then the places strip). The cards are built from the tool's own answer, the
|
||||
* parts the provider sees from the redacted one.
|
||||
*/
|
||||
async function runRead(
|
||||
deps: AgentDeps,
|
||||
@@ -158,6 +169,12 @@ async function runRead(
|
||||
...(run.ok ? {} : { isError: true }),
|
||||
});
|
||||
if (!run.ok) return;
|
||||
const input = { toolCallId: id, args, value: run.value, card: deps.card, today: deps.today };
|
||||
const card = id.length <= MAX_CARD_CALL_ID ? (deps.answerCard ?? answerCardOf)(name, input) : null;
|
||||
if (card && step.cards++ < MAX_CARDS_PER_STEP) {
|
||||
w.card({ toolCallId: id, card });
|
||||
step.parts.push({ type: 'data-trek-card', data: { toolCallId: id, card } });
|
||||
}
|
||||
for (const placeId of googleIdsOf(name, run.value)) step.knownPlaceIds.add(placeId);
|
||||
const places = foundPlacesOf(name, id, run.value, deps.card, deps.today);
|
||||
if (places) {
|
||||
@@ -370,7 +387,7 @@ async function runStep(turn: Turn, step: number): Promise<AgentOutcome | null> {
|
||||
}
|
||||
|
||||
const calls = received.map((call) => ({ ...call, id: uniqueCallId(call, input.usedCallIds, deps.newId) }));
|
||||
const collected: StepParts = { parts, weather: [], knownPlaceIds: turn.knownPlaceIds };
|
||||
const collected: StepParts = { parts, weather: [], knownPlaceIds: turn.knownPlaceIds, cards: 0 };
|
||||
const held = await runCalls(turn, calls, collected);
|
||||
// One weather strip per step, whatever the step held: it is told before the changes.
|
||||
const weather = weatherOf(collected.weather);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { CardPlace, TripCard } from './assistant-context';
|
||||
import { redactForProvider } from './assistant-redact';
|
||||
import { dayLabel, type PreviewContext } from './tool-call-preview.helpers';
|
||||
import { clipped, finite, isLat, isLng, recordOf, type Row } from './cards/card-values';
|
||||
import { dayLabel } from './cards/preview-format';
|
||||
import {
|
||||
haversineMetres,
|
||||
kindOfResult,
|
||||
@@ -26,8 +26,6 @@ import {
|
||||
* a photo is asked for by a key, through TREK's own photo route.
|
||||
*/
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
|
||||
/** Places a search shows as cards; `more` counts the rest. */
|
||||
const PLACES_SHOWN = 20;
|
||||
const MAX_NAME_CHARS = 120;
|
||||
@@ -57,30 +55,6 @@ const PLACE_LISTS: Readonly<Record<string, 'places' | 'pois'>> = {
|
||||
search_pois: 'pois',
|
||||
};
|
||||
|
||||
function recordOf(value: unknown): Row {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Row) : {};
|
||||
}
|
||||
|
||||
function finite(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function isLat(value: number | null): value is number {
|
||||
return value !== null && Math.abs(value) <= 90;
|
||||
}
|
||||
|
||||
function isLng(value: number | null): value is number {
|
||||
return value !== null && Math.abs(value) <= 180;
|
||||
}
|
||||
|
||||
/** A text as a card may show it: on one line, masked like a provider answer, and cut to `max`. */
|
||||
function clipped(value: unknown, max: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const line = String(redactForProvider(value.replace(/\s+/g, ' ').trim()));
|
||||
if (line === '') return null;
|
||||
return line.length > max ? `${line.slice(0, max - 1)}…` : line;
|
||||
}
|
||||
|
||||
/** Whether the trip runs today, the only time a source's "open now" means anything for it. */
|
||||
function tripRunsOn(card: TripCard, today: string): boolean {
|
||||
return !!card.startDate && !!card.endDate && card.startDate <= today && today <= card.endDate;
|
||||
@@ -285,7 +259,11 @@ export function activityOf(toolName: string, toolCallId: string, args: Row, card
|
||||
}
|
||||
|
||||
/** What a mention is called in the trip; null when the trip holds no such thing. */
|
||||
function mentionLabel(ctx: PreviewContext, kind: AssistantMention['kind'], id: number): string | null {
|
||||
function mentionLabel(
|
||||
ctx: { card: TripCard; locale: string },
|
||||
kind: AssistantMention['kind'],
|
||||
id: number,
|
||||
): string | null {
|
||||
if (kind === 'day') return ctx.card.days.some((day) => day.id === id) ? dayLabel(ctx, id) : null;
|
||||
if (kind === 'place') return ctx.card.places.get(id)?.name ?? null;
|
||||
return ctx.card.reservations.get(id)?.title ?? null;
|
||||
@@ -303,7 +281,7 @@ export function mentionItems(
|
||||
): AssistantMentions['items'] {
|
||||
const items: AssistantMentions['items'] = [];
|
||||
const seen = new Set<string>();
|
||||
const ctx = { card, categories: new Map<number, string>(), locale };
|
||||
const ctx = { card, locale };
|
||||
for (const { kind, id } of mentions) {
|
||||
const label = mentionLabel(ctx, kind, id);
|
||||
const shown = clipped(label, MAX_NAME_CHARS);
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { fileKeyOf } from './cards/card-values';
|
||||
import type { AssistantFocus, AssistantMention } from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -58,8 +59,16 @@ interface CardPackingItem {
|
||||
packedQuantity: number | null;
|
||||
}
|
||||
|
||||
/** A trip member: the name the trip shows, and the stored file of their picture (none for one on another host). */
|
||||
interface CardMember {
|
||||
name: string;
|
||||
avatarKey: string | null;
|
||||
}
|
||||
|
||||
export interface TripCard {
|
||||
tripId: number;
|
||||
/** The user the turn runs for; a card compares people with it instead of carrying "me". */
|
||||
viewerId: number;
|
||||
title: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
@@ -69,11 +78,17 @@ export interface TripCard {
|
||||
assignments: Map<number, { dayId: number; placeId: number }>;
|
||||
notes: Map<number, CardNote>;
|
||||
packing: Map<number, CardPackingItem>;
|
||||
members: Map<number, string>;
|
||||
members: Map<number, CardMember>;
|
||||
reservations: Map<number, CardBooking>;
|
||||
focus: AssistantFocus | null;
|
||||
/** What the user named with `@` in the message, as sent; checked against the card when it is rendered. */
|
||||
mentions: readonly AssistantMention[];
|
||||
/**
|
||||
* The raw answers the card was built from. A package that needs more of the
|
||||
* trip than the card holds reads it from here, in its own file (memoised per
|
||||
* card), rather than growing the card for every part of TREK.
|
||||
*/
|
||||
sources: { summary: unknown; places: unknown };
|
||||
}
|
||||
|
||||
/** Places a day lists by name on the card; the rest are counted. */
|
||||
@@ -132,6 +147,7 @@ function placeOf(row: Row): CardPlace | null {
|
||||
*/
|
||||
export function buildTripCard(
|
||||
tripId: number,
|
||||
viewerId: number,
|
||||
summary: unknown,
|
||||
placeList: unknown,
|
||||
focus: AssistantFocus | null,
|
||||
@@ -141,6 +157,7 @@ export function buildTripCard(
|
||||
const trip = recordOf(root.trip);
|
||||
const card: TripCard = {
|
||||
tripId,
|
||||
viewerId,
|
||||
title: str(trip.title) ?? '',
|
||||
startDate: str(trip.start_date),
|
||||
endDate: str(trip.end_date),
|
||||
@@ -154,6 +171,7 @@ export function buildTripCard(
|
||||
reservations: new Map(),
|
||||
focus,
|
||||
mentions,
|
||||
sources: { summary, places: placeList },
|
||||
};
|
||||
for (const row of rowsOf(recordOf(placeList).places)) {
|
||||
const place = placeOf(row);
|
||||
@@ -208,7 +226,8 @@ export function buildTripCard(
|
||||
const members = recordOf(root.members);
|
||||
for (const member of [recordOf(members.owner), ...rowsOf(members.collaborators)]) {
|
||||
const id = num(member.id);
|
||||
if (id !== null) card.members.set(id, str(member.username) ?? `#${id}`);
|
||||
if (id === null) continue;
|
||||
card.members.set(id, { name: str(member.username) ?? `#${id}`, avatarKey: fileKeyOf(member.avatar_url) });
|
||||
}
|
||||
return card;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import { AssistantMessages } from '../../db/entities/AssistantMessages.entity';
|
||||
import { AssistantThreads } from '../../db/entities/AssistantThreads.entity';
|
||||
import { AssistantToolCalls } from '../../db/entities/AssistantToolCalls.entity';
|
||||
import { ASSISTANT_MESSAGE_PARTS, ASSISTANT_TOOL_ARGS, ASSISTANT_TOOL_PREVIEW } from '../../db/json-columns';
|
||||
import {
|
||||
ASSISTANT_MESSAGE_PARTS_WRITE,
|
||||
ASSISTANT_TOOL_ARGS,
|
||||
ASSISTANT_TOOL_PREVIEW_WRITE,
|
||||
} from '../../db/json-columns';
|
||||
import type {
|
||||
AssistantMessageRow,
|
||||
AssistantMessagesRepository,
|
||||
@@ -16,12 +20,19 @@ import { encodeJson } from '../../utils/json-column';
|
||||
import { aiRefusal } from '../ai-connections/ai-connections.constants';
|
||||
import { RateLimitService } from '../common/rate-limit.service';
|
||||
import { UnitOfWork } from '../database/unit-of-work';
|
||||
import { buildTripCard, categoriesFrom, type CardCategory, type CardPlace, type TripCard } from './assistant-context';
|
||||
import {
|
||||
buildTripCard,
|
||||
categoriesFrom,
|
||||
todayIn,
|
||||
type CardCategory,
|
||||
type CardPlace,
|
||||
type TripCard,
|
||||
} from './assistant-context';
|
||||
import { partsOf, toThreadMessages } from './assistant-history';
|
||||
import { AssistantStreamRegistry } from './assistant-stream.registry';
|
||||
import { ADD_FOUND_PLACE_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
|
||||
import { AssistantToolsService } from './assistant-tools.service';
|
||||
import { describeToolCall } from './tool-call-preview.helpers';
|
||||
import { AssistantCardContext } from './cards/card-context';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
@@ -111,6 +122,7 @@ export class AssistantFoundPlacesService {
|
||||
private readonly rateLimit: RateLimitService,
|
||||
private readonly streams: AssistantStreamRegistry,
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly cardContext: AssistantCardContext,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -161,7 +173,10 @@ export class AssistantFoundPlacesService {
|
||||
const id = randomUUID();
|
||||
const args = suggestionArgs(place, req.dayId, card, categories);
|
||||
const names = new Map(categories.map((category): [number, string] => [category.id, category.name]));
|
||||
const preview = describeToolCall(ADD_FOUND_PLACE_TOOL, id, args, { card, categories: names, locale: req.locale });
|
||||
const ctx = this.cardContext.contextOf(card, names, req.locale, todayIn(req.timeZone, new Date()));
|
||||
const [{ preview }] = await this.cardContext.describeProposed(userId, tripId, ctx, [
|
||||
{ id, toolName: ADD_FOUND_PLACE_TOOL, args },
|
||||
]);
|
||||
const parts: AssistantMessagePart[] = [
|
||||
{ type: 'tool-call', toolCallId: id, toolName: ADD_FOUND_PLACE_TOOL, args, approval: { id } },
|
||||
{ type: 'data-trek-change', data: preview },
|
||||
@@ -171,7 +186,7 @@ export class AssistantFoundPlacesService {
|
||||
const messageId = await this.messages.append({
|
||||
threadId,
|
||||
role: 'assistant',
|
||||
parts: encodeJson(ASSISTANT_MESSAGE_PARTS, parts),
|
||||
parts: encodeJson(ASSISTANT_MESSAGE_PARTS_WRITE, parts),
|
||||
providerState: null,
|
||||
status: 'awaiting_approval',
|
||||
model: null,
|
||||
@@ -185,7 +200,7 @@ export class AssistantFoundPlacesService {
|
||||
providerCallId: `found_${id}`,
|
||||
toolName: ADD_FOUND_PLACE_TOOL,
|
||||
args: encodeJson(ASSISTANT_TOOL_ARGS, args),
|
||||
preview: encodeJson(ASSISTANT_TOOL_PREVIEW, preview),
|
||||
preview: encodeJson(ASSISTANT_TOOL_PREVIEW_WRITE, preview),
|
||||
createdAt: now,
|
||||
},
|
||||
]);
|
||||
@@ -201,7 +216,7 @@ export class AssistantFoundPlacesService {
|
||||
const places = await session.read('list_places');
|
||||
const listed = await session.read('list_categories');
|
||||
return {
|
||||
card: buildTripCard(tripId, summary.ok ? summary.value : null, places.ok ? places.value : null, null),
|
||||
card: buildTripCard(tripId, userId, summary.ok ? summary.value : null, places.ok ? places.value : null, null),
|
||||
categories: categoriesFrom(listed.ok ? listed.value : null),
|
||||
};
|
||||
} finally {
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { AssistantToolCallRow } from '../../db/repositories/AssistantToolCa
|
||||
import { parseDbTimestamp, utcSuffix } from '../../db/types';
|
||||
import { decodeJson } from '../../utils/json-column';
|
||||
import type { AiChatMessage, AiToolCall } from '../ai-connections/ai-chat-client.interface';
|
||||
import { fitToSize } from './assistant-redact';
|
||||
import { fitToSize } from './assistant-output';
|
||||
import {
|
||||
ADD_FOUND_PLACE_TOOL,
|
||||
APPROVAL_TTL_MS,
|
||||
|
||||
@@ -0,0 +1,148 @@
|
||||
import { redactForProvider } from './assistant-redact';
|
||||
import { MAX_TOOL_OUTPUT_CHARS } from './assistant-tools.constants';
|
||||
import { PROJECTIONS, refuseCoreOverlap } from './cards/registry';
|
||||
|
||||
/**
|
||||
* A tool's answer as the provider gets it: projected to what the model plans
|
||||
* with (the core's projections here, a package's from its cards), redacted
|
||||
* (assistant-redact.ts), and cut down until it fits. The client is shown the
|
||||
* same, so a stored or streamed answer never carries more than the model saw.
|
||||
*/
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
/** Longest string kept whole; a longer one keeps its start. */
|
||||
const MAX_STRING_CHARS = 4_000;
|
||||
|
||||
/** Array lengths tried, longest first, until the answer fits. */
|
||||
const ARRAY_CAPS = [100, 50, 20, 10, 5, 2, 1];
|
||||
|
||||
function isRecord(value: Json): value is Record<string, unknown> {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function pick(row: Json, keys: readonly string[]): Record<string, unknown> | null {
|
||||
if (!isRecord(row)) return null;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of keys) if (row[key] !== undefined) out[key] = row[key];
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickAll(rows: Json, keys: readonly string[]): Record<string, unknown>[] {
|
||||
return Array.isArray(rows) ? rows.map((row) => pick(row, keys)).filter((row) => row !== null) : [];
|
||||
}
|
||||
|
||||
const ASSIGNMENT_KEYS = ['id', 'place_id', 'order_index', 'assignment_time', 'assignment_end_time', 'notes'] as const;
|
||||
const PLACE_KEYS = ['name', 'address', 'lat', 'lng', 'category_id', 'place_time', 'end_time'] as const;
|
||||
|
||||
function projectDay(day: Json): Record<string, unknown> | null {
|
||||
if (!isRecord(day)) return null;
|
||||
return {
|
||||
...pick(day, ['id', 'day_number', 'date', 'title']),
|
||||
assignments: (Array.isArray(day.assignments) ? day.assignments : [])
|
||||
.filter(isRecord)
|
||||
.map((assignment) => ({ ...pick(assignment, ASSIGNMENT_KEYS), place: pick(assignment.place, PLACE_KEYS) })),
|
||||
notes: pickAll(day.notes, ['id', 'text', 'time', 'icon']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `get_trip_summary` as the model gets it: the plan (days with their places
|
||||
* and notes, stays, bookings, the packing and to-do lists), without the
|
||||
* members' contact details, the shared notes or the budget rows, which have
|
||||
* tools of their own.
|
||||
*/
|
||||
function projectTripSummary(summary: Json): Json {
|
||||
if (!isRecord(summary)) return summary;
|
||||
const members = isRecord(summary.members) ? summary.members : {};
|
||||
const people = [members.owner, ...(Array.isArray(members.collaborators) ? members.collaborators : [])];
|
||||
const packing = isRecord(summary.packing) ? summary.packing : {};
|
||||
return {
|
||||
trip: pick(summary.trip, ['id', 'title', 'description', 'start_date', 'end_date', 'currency']),
|
||||
members: pickAll(people, ['id', 'username']),
|
||||
days: (Array.isArray(summary.days) ? summary.days : []).map(projectDay).filter((day) => day !== null),
|
||||
accommodations: pickAll(summary.accommodations, [
|
||||
'id',
|
||||
'place_id',
|
||||
'start_day_id',
|
||||
'end_day_id',
|
||||
'check_in',
|
||||
'check_out',
|
||||
]),
|
||||
reservations: pickAll(summary.reservations, [
|
||||
'id',
|
||||
'title',
|
||||
'type',
|
||||
'status',
|
||||
'reservation_time',
|
||||
'reservation_end_time',
|
||||
'location',
|
||||
'day_id',
|
||||
'end_day_id',
|
||||
'place_id',
|
||||
'assignment_id',
|
||||
]),
|
||||
packing: pickAll(packing.items, ['id', 'name', 'category', 'checked', 'quantity', 'packed_quantity']),
|
||||
todos: pickAll(summary.todos, ['id', 'name', 'category', 'checked', 'due_date', 'priority', 'assigned_user_id']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `suggest_atlas_countries` as the model gets it: each country with its
|
||||
* cities and the minutes spent in them, without when the user was last seen
|
||||
* in each city. The model offers countries to mark; a time per city would
|
||||
* hand the provider a dated trail of the user's whereabouts for nothing it
|
||||
* plans with.
|
||||
*/
|
||||
function projectAtlasSuggestions(value: Json): Json {
|
||||
if (!isRecord(value) || !Array.isArray(value.countries)) return value;
|
||||
return {
|
||||
...value,
|
||||
countries: value.countries.map((country) =>
|
||||
isRecord(country) ? { ...country, cities: pickAll(country.cities, ['name', 'minutes']) } : country,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
/** The core's projections; the packages' come from their cards (`project`), never for the same tool. */
|
||||
const CORE_PROJECTIONS: Readonly<Record<string, (value: Json) => Json>> = {
|
||||
get_trip_summary: projectTripSummary,
|
||||
suggest_atlas_countries: projectAtlasSuggestions,
|
||||
};
|
||||
refuseCoreOverlap('project', Object.keys(CORE_PROJECTIONS));
|
||||
|
||||
function shrink(value: Json, cap: number, cut: { done: boolean }): Json {
|
||||
if (typeof value === 'string' && value.length > MAX_STRING_CHARS) {
|
||||
cut.done = true;
|
||||
return `${value.slice(0, MAX_STRING_CHARS)}…`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > cap) cut.done = true;
|
||||
return value.slice(0, cap).map((item) => shrink(item, cap, cut));
|
||||
}
|
||||
if (!isRecord(value)) return value;
|
||||
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, shrink(inner, cap, cut)]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The value cut down until its JSON fits `maxChars`: long strings keep their
|
||||
* start, arrays their first entries, and the answer says it was cut
|
||||
* (`truncated: true`), so the model knows to narrow its question rather than
|
||||
* read a list as complete. Never a cut through the middle of the JSON.
|
||||
*/
|
||||
export function fitToSize(value: Json, maxChars = MAX_TOOL_OUTPUT_CHARS): Json {
|
||||
if (JSON.stringify(value).length <= maxChars) return value;
|
||||
for (const cap of ARRAY_CAPS) {
|
||||
const cut = { done: false };
|
||||
const shrunk = shrink(value, cap, cut);
|
||||
const marked = isRecord(shrunk) ? { ...shrunk, truncated: true } : { items: shrunk, truncated: true };
|
||||
if (JSON.stringify(marked).length <= maxChars) return marked;
|
||||
}
|
||||
return { truncated: true, note: 'The answer was too large. Ask for less at once.' };
|
||||
}
|
||||
|
||||
/** A tool's answer as the provider and the client see it: projected, redacted and cut to size. */
|
||||
export function toolOutputForProvider(toolName: string, value: Json, maxChars = MAX_TOOL_OUTPUT_CHARS): Json {
|
||||
const project = CORE_PROJECTIONS[toolName] ?? PROJECTIONS[toolName];
|
||||
return fitToSize(redactForProvider(project ? project(value) : value), maxChars);
|
||||
}
|
||||
@@ -1,10 +1,9 @@
|
||||
import { MAX_TOOL_OUTPUT_CHARS } from './assistant-tools.constants';
|
||||
|
||||
/**
|
||||
* What a tool answer may carry to the provider. The model plans the trip; it
|
||||
* has no use for anyone's e-mail address, a booking's confirmation code or a
|
||||
* phone number, so those are hidden, and the bulky fields (pictures inlined as
|
||||
* data URLs, route geometry) are dropped before the answer is cut to size.
|
||||
* data URLs, route geometry) are dropped before the answer is cut to size
|
||||
* (assistant-output.ts). The cards mask their texts with it too.
|
||||
*/
|
||||
|
||||
const HIDDEN = '[hidden]';
|
||||
@@ -13,16 +12,10 @@ const HIDDEN = '[hidden]';
|
||||
const SECRET_KEYS = new Set(['email', 'confirmation_number', 'confirmation', 'phone', 'feed_token', 'password']);
|
||||
|
||||
/** Keys that cost a lot and tell the model nothing it plans with. */
|
||||
const DROPPED_KEY = /^(image_url|cover_image|avatar|avatar_url|metadata)$|geometry/;
|
||||
const DROPPED_KEY = /^(image_url|cover_image|avatar|avatar_url|metadata|place_image|trip_cover)$|geometry/;
|
||||
|
||||
const EMAIL = /[\w.+-]+@[\w-]+(?:\.[\w-]+)+/g;
|
||||
|
||||
/** Longest string kept whole; a longer one keeps its start. */
|
||||
const MAX_STRING_CHARS = 4_000;
|
||||
|
||||
/** Array lengths tried, longest first, until the answer fits. */
|
||||
const ARRAY_CAPS = [100, 50, 20, 10, 5, 2, 1];
|
||||
|
||||
type Json = unknown;
|
||||
|
||||
function isRecord(value: Json): value is Record<string, unknown> {
|
||||
@@ -41,127 +34,3 @@ export function redactForProvider(value: Json): Json {
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function pick(row: Json, keys: readonly string[]): Record<string, unknown> | null {
|
||||
if (!isRecord(row)) return null;
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const key of keys) if (row[key] !== undefined) out[key] = row[key];
|
||||
return out;
|
||||
}
|
||||
|
||||
function pickAll(rows: Json, keys: readonly string[]): Record<string, unknown>[] {
|
||||
return Array.isArray(rows) ? rows.map((row) => pick(row, keys)).filter((row) => row !== null) : [];
|
||||
}
|
||||
|
||||
const ASSIGNMENT_KEYS = ['id', 'place_id', 'order_index', 'assignment_time', 'assignment_end_time', 'notes'] as const;
|
||||
const PLACE_KEYS = ['name', 'address', 'lat', 'lng', 'category_id', 'place_time', 'end_time'] as const;
|
||||
|
||||
function projectDay(day: Json): Record<string, unknown> | null {
|
||||
if (!isRecord(day)) return null;
|
||||
return {
|
||||
...pick(day, ['id', 'day_number', 'date', 'title']),
|
||||
assignments: (Array.isArray(day.assignments) ? day.assignments : [])
|
||||
.filter(isRecord)
|
||||
.map((assignment) => ({ ...pick(assignment, ASSIGNMENT_KEYS), place: pick(assignment.place, PLACE_KEYS) })),
|
||||
notes: pickAll(day.notes, ['id', 'text', 'time', 'icon']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `get_trip_summary` as the model gets it: the plan (days with their places
|
||||
* and notes, stays, bookings, the packing and to-do lists), without the
|
||||
* members' contact details, the shared notes or the budget rows, which have
|
||||
* tools of their own.
|
||||
*/
|
||||
function projectTripSummary(summary: Json): Json {
|
||||
if (!isRecord(summary)) return summary;
|
||||
const members = isRecord(summary.members) ? summary.members : {};
|
||||
const people = [members.owner, ...(Array.isArray(members.collaborators) ? members.collaborators : [])];
|
||||
const packing = isRecord(summary.packing) ? summary.packing : {};
|
||||
return {
|
||||
trip: pick(summary.trip, ['id', 'title', 'description', 'start_date', 'end_date', 'currency']),
|
||||
members: pickAll(people, ['id', 'username']),
|
||||
days: (Array.isArray(summary.days) ? summary.days : []).map(projectDay).filter((day) => day !== null),
|
||||
accommodations: pickAll(summary.accommodations, [
|
||||
'id',
|
||||
'place_id',
|
||||
'start_day_id',
|
||||
'end_day_id',
|
||||
'check_in',
|
||||
'check_out',
|
||||
]),
|
||||
reservations: pickAll(summary.reservations, [
|
||||
'id',
|
||||
'title',
|
||||
'type',
|
||||
'status',
|
||||
'reservation_time',
|
||||
'reservation_end_time',
|
||||
'location',
|
||||
'day_id',
|
||||
'end_day_id',
|
||||
'place_id',
|
||||
'assignment_id',
|
||||
]),
|
||||
packing: pickAll(packing.items, ['id', 'name', 'category', 'checked', 'quantity', 'packed_quantity']),
|
||||
todos: pickAll(summary.todos, ['id', 'name', 'category', 'checked', 'due_date', 'priority', 'assigned_user_id']),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* `suggest_atlas_countries` as the model gets it: each country with its
|
||||
* cities and the minutes spent in them, without when the user was last seen
|
||||
* in each city. The model offers countries to mark; a time per city would
|
||||
* hand the provider a dated trail of the user's whereabouts for nothing it
|
||||
* plans with.
|
||||
*/
|
||||
function projectAtlasSuggestions(value: Json): Json {
|
||||
if (!isRecord(value) || !Array.isArray(value.countries)) return value;
|
||||
return {
|
||||
...value,
|
||||
countries: value.countries.map((country) =>
|
||||
isRecord(country) ? { ...country, cities: pickAll(country.cities, ['name', 'minutes']) } : country,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const PROJECTIONS: Readonly<Record<string, (value: Json) => Json>> = {
|
||||
get_trip_summary: projectTripSummary,
|
||||
suggest_atlas_countries: projectAtlasSuggestions,
|
||||
};
|
||||
|
||||
function shrink(value: Json, cap: number, cut: { done: boolean }): Json {
|
||||
if (typeof value === 'string' && value.length > MAX_STRING_CHARS) {
|
||||
cut.done = true;
|
||||
return `${value.slice(0, MAX_STRING_CHARS)}…`;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length > cap) cut.done = true;
|
||||
return value.slice(0, cap).map((item) => shrink(item, cap, cut));
|
||||
}
|
||||
if (!isRecord(value)) return value;
|
||||
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, shrink(inner, cap, cut)]));
|
||||
}
|
||||
|
||||
/**
|
||||
* The value cut down until its JSON fits `maxChars`: long strings keep their
|
||||
* start, arrays their first entries, and the answer says it was cut
|
||||
* (`truncated: true`), so the model knows to narrow its question rather than
|
||||
* read a list as complete. Never a cut through the middle of the JSON.
|
||||
*/
|
||||
export function fitToSize(value: Json, maxChars = MAX_TOOL_OUTPUT_CHARS): Json {
|
||||
if (JSON.stringify(value).length <= maxChars) return value;
|
||||
for (const cap of ARRAY_CAPS) {
|
||||
const cut = { done: false };
|
||||
const shrunk = shrink(value, cap, cut);
|
||||
const marked = isRecord(shrunk) ? { ...shrunk, truncated: true } : { items: shrunk, truncated: true };
|
||||
if (JSON.stringify(marked).length <= maxChars) return marked;
|
||||
}
|
||||
return { truncated: true, note: 'The answer was too large. Ask for less at once.' };
|
||||
}
|
||||
|
||||
/** A tool's answer as the provider and the client see it: projected, redacted and cut to size. */
|
||||
export function toolOutputForProvider(toolName: string, value: Json, maxChars = MAX_TOOL_OUTPUT_CHARS): Json {
|
||||
const project = PROJECTIONS[toolName];
|
||||
return fitToSize(redactForProvider(project ? project(value) : value), maxChars);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { McpRegistryListing } from '../../nest-mcp';
|
||||
import type { AiToolSpec } from '../ai-connections/ai-chat-client.interface';
|
||||
import { PACKAGE_HELD_READS, PACKAGE_TOOL_CONTROLLERS } from './cards/registry';
|
||||
|
||||
/**
|
||||
* Which of TREK's MCP tools the assistant is offered, and how. Every tool the
|
||||
@@ -81,11 +82,15 @@ const SHARE_LIKE_SAFE: ReadonlySet<string> = new Set([
|
||||
]);
|
||||
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. */
|
||||
/**
|
||||
* Read-only tools that still wait for the user: they fetch an address that
|
||||
* came from the web or a message. The packages add theirs in their cards.
|
||||
*/
|
||||
export const HELD_READS: ReadonlySet<string> = new Set([
|
||||
'resolve_maps_url',
|
||||
'preview_google_maps_route',
|
||||
'preview_google_maps_walk',
|
||||
...PACKAGE_HELD_READS,
|
||||
]);
|
||||
|
||||
export function isOfferedTool(name: string): boolean {
|
||||
@@ -103,12 +108,15 @@ export function checkDenylist(names: Iterable<string>): string[] {
|
||||
return problems;
|
||||
}
|
||||
|
||||
type ToolGroups = Readonly<Record<string, { about: string; controllers: readonly string[] }>>;
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* listed here, nor added by a package (`toolControllers` in its cards),
|
||||
* belongs to the core, which every step offers.
|
||||
*/
|
||||
export const ASSISTANT_TOOL_GROUPS: Readonly<Record<string, { about: string; controllers: readonly string[] }>> = {
|
||||
const CORE_TOOL_GROUPS: ToolGroups = {
|
||||
tours: {
|
||||
about:
|
||||
'Tours: hikes, bike tours, city walks, paddling and ski routes; plan, create, change and export them ' +
|
||||
@@ -184,6 +192,29 @@ export const ASSISTANT_TOOL_GROUPS: Readonly<Record<string, { about: string; con
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The groups with the controllers the packages added to them. A package that
|
||||
* names a group this file does not know, or a controller a group lists
|
||||
* already, stops the server at load.
|
||||
*/
|
||||
export function withPackageControllers(
|
||||
core: ToolGroups,
|
||||
added: Readonly<Record<string, readonly string[]>>,
|
||||
): ToolGroups {
|
||||
const groups = Object.fromEntries(Object.entries(core).map(([group, value]) => [group, { ...value }]));
|
||||
const listed = new Set(Object.values(core).flatMap((group) => group.controllers));
|
||||
for (const [group, controllers] of Object.entries(added)) {
|
||||
const known = groups[group];
|
||||
if (!known) throw new Error(`Assistant tool groups: no group "${group}" for ${controllers.join(', ')}`);
|
||||
const twice = controllers.find((controller) => listed.has(controller));
|
||||
if (twice) throw new Error(`Assistant tool groups: controller "${twice}" is listed already`);
|
||||
known.controllers = [...known.controllers, ...controllers];
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
export const ASSISTANT_TOOL_GROUPS = withPackageControllers(CORE_TOOL_GROUPS, PACKAGE_TOOL_CONTROLLERS);
|
||||
|
||||
const GROUP_OF_CONTROLLER = new Map(
|
||||
Object.entries(ASSISTANT_TOOL_GROUPS).flatMap(([group, { controllers }]) =>
|
||||
controllers.map((controller) => [controller, group] as const),
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { ToolUndo } from './cards/card-types';
|
||||
import { argId, idIn } from './cards/card-values';
|
||||
import type { AssistantApplied, AssistantChangeVerbKey } from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -13,6 +15,9 @@ export const MAX_WRITES_PER_STEP = 50;
|
||||
/** Lookups one step may run; the rest come back to the model as an error (searches bill the instance's maps key). */
|
||||
export const MAX_READS_PER_STEP = 30;
|
||||
|
||||
/** Answer cards one step shows; the reads past it answer the model as usual, without a card. */
|
||||
export const MAX_CARDS_PER_STEP = 12;
|
||||
|
||||
/** What a tool answer may cost the conversation, as JSON text, after it was cut down to its structure. */
|
||||
export const MAX_TOOL_OUTPUT_CHARS = 40_000;
|
||||
|
||||
@@ -70,18 +75,19 @@ export const ASSISTANT_SCOPES: readonly string[] = [
|
||||
* applied. A `quiet` one is decided without a model turn after it: the user
|
||||
* proposed it, so there is nothing for the model to carry on with.
|
||||
*/
|
||||
interface AssistantWriteTool {
|
||||
export interface AssistantWriteTool {
|
||||
verbKey: AssistantChangeVerbKey;
|
||||
entity: AssistantApplied['entity'];
|
||||
quiet?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* The core writing tools with a change card of their own: its verb and what
|
||||
* it touches once applied. The packages add theirs (cards/registry.ts merges
|
||||
* them into ASSISTANT_WRITE_TOOLS). 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<Record<string, AssistantWriteTool>> = {
|
||||
export const CORE_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>> = {
|
||||
create_place: { verbKey: 'addPlace', entity: 'place' },
|
||||
create_and_assign_place: { verbKey: 'addPlaceToDay', entity: 'place' },
|
||||
update_place: { verbKey: 'updatePlace', entity: 'place' },
|
||||
@@ -96,11 +102,6 @@ export const ASSISTANT_WRITE_TOOLS: Readonly<Record<string, AssistantWriteTool>>
|
||||
create_budget_item: { verbKey: 'addBudgetItem', entity: 'budget' },
|
||||
update_budget_item: { verbKey: 'updateBudgetItem', entity: 'budget' },
|
||||
create_reservation: { verbKey: 'addReservation', entity: 'reservation' },
|
||||
// A tour is a place with a route: its id is its place's.
|
||||
create_hike: { verbKey: 'addTour', entity: 'tour' },
|
||||
create_city_walk: { verbKey: 'addTour', entity: 'tour' },
|
||||
update_tour: { verbKey: 'updateTour', entity: 'tour' },
|
||||
import_tour_gpx: { verbKey: 'importTour', entity: 'tour' },
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -179,9 +180,9 @@ export const PINNED_ARG = 'tripId';
|
||||
* (place details stay on the cheap lookup, which the instance pays for);
|
||||
* `stripped` ones are taken away (a picture URL would be loaded by every
|
||||
* member's browser, a tracking pixel the model could plant). Neither appears
|
||||
* in the schema the model sees.
|
||||
* in the schema the model sees. The packages add theirs (ASSISTANT_ARG_POLICY).
|
||||
*/
|
||||
export const ASSISTANT_ARG_POLICY: Readonly<
|
||||
export const CORE_ARG_POLICY: Readonly<
|
||||
Record<string, { forced?: Readonly<Record<string, unknown>>; stripped?: readonly string[] }>
|
||||
> = {
|
||||
get_place_details: { forced: { expand: false, refresh: false } },
|
||||
@@ -192,49 +193,16 @@ export const ASSISTANT_ARG_POLICY: Readonly<
|
||||
update_trip: { stripped: ['cover_image'] },
|
||||
};
|
||||
|
||||
type ToolArgs = Record<string, unknown>;
|
||||
|
||||
function idIn(value: unknown, key: string): number | null {
|
||||
const record = value && typeof value === 'object' ? (value as Record<string, unknown>)[key] : undefined;
|
||||
const id = record && typeof record === 'object' ? (record as Record<string, unknown>).id : undefined;
|
||||
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/** The place id of a tour a create answered with (`{ tour: { place_id } }`). */
|
||||
function tourIdIn(value: unknown): number | null {
|
||||
const tour = value && typeof value === 'object' ? (value as Record<string, unknown>).tour : undefined;
|
||||
const id = tour && typeof tour === 'object' ? (tour as Record<string, unknown>).place_id : undefined;
|
||||
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
function argId(args: ToolArgs, key: string): number | null {
|
||||
const id = args[key];
|
||||
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The way back from an applied change, for the server only: the tool that
|
||||
* removes what the change created, and its arguments, from the arguments the
|
||||
* change ran with and the result it gave. Null when the result does not name
|
||||
* what was created, which offers no undo. The model is never offered these
|
||||
* tools, except the removal it may also propose itself; they run in a session
|
||||
* of their own that holds nothing else.
|
||||
* of their own that holds nothing else. The packages add theirs
|
||||
* (ASSISTANT_UNDO_MAP).
|
||||
*/
|
||||
interface AssistantUndo {
|
||||
tool: string;
|
||||
args: (args: ToolArgs, result: unknown) => ToolArgs | null;
|
||||
}
|
||||
|
||||
/** A created tour taken back with its place, which takes its route and points along. */
|
||||
const DELETE_CREATED_TOUR: AssistantUndo = {
|
||||
tool: 'delete_place',
|
||||
args: (_args, result) => {
|
||||
const placeId = tourIdIn(result);
|
||||
return placeId ? { placeId } : null;
|
||||
},
|
||||
};
|
||||
|
||||
export const ASSISTANT_UNDO_MAP: Readonly<Record<string, AssistantUndo>> = {
|
||||
export const CORE_UNDO_MAP: Readonly<Record<string, ToolUndo>> = {
|
||||
create_place: {
|
||||
tool: 'delete_place',
|
||||
args: (_args, result) => {
|
||||
@@ -286,6 +254,4 @@ export const ASSISTANT_UNDO_MAP: Readonly<Record<string, AssistantUndo>> = {
|
||||
return itemId ? { itemId } : null;
|
||||
},
|
||||
},
|
||||
create_hike: DELETE_CREATED_TOUR,
|
||||
create_city_walk: DELETE_CREATED_TOUR,
|
||||
};
|
||||
|
||||
@@ -13,15 +13,15 @@ import {
|
||||
USE_TOOLS_SPEC,
|
||||
USE_TOOLS_TOOL,
|
||||
} from './assistant-tool-offer';
|
||||
import { ASSISTANT_SCOPES, ASSISTANT_SERVER_TOOLS, PINNED_ARG, REMEMBER_TOOL } from './assistant-tools.constants';
|
||||
import type { ToolCards } from './cards/card-types';
|
||||
import {
|
||||
ASSISTANT_ARG_POLICY,
|
||||
ASSISTANT_SCOPES,
|
||||
ASSISTANT_SERVER_TOOLS,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
PINNED_ARG,
|
||||
REMEMBER_TOOL,
|
||||
} from './assistant-tools.constants';
|
||||
PACKAGE_HELD_READS,
|
||||
TOOL_CARDS,
|
||||
} from './cards/registry';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
@@ -58,20 +58,50 @@ interface ListedTool {
|
||||
type SessionMode = 'turn' | 'undo';
|
||||
|
||||
/**
|
||||
* What is wrong with the tool catalog the assistant relies on, given what the
|
||||
* 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.
|
||||
* The tools the packages' undo checks read. A package's `reads` names its
|
||||
* tools whatever the arguments, so asking with none shows which they are.
|
||||
*/
|
||||
export function checkToolCatalog(listing: readonly McpRegistryListing[]): string[] {
|
||||
function undoReadTools(cards: Readonly<Record<string, ToolCards>>): string[] {
|
||||
return Object.values(cards).flatMap((entry) => {
|
||||
try {
|
||||
return (entry.undo?.reads?.({}, {}) ?? []).map((read) => read.tool);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* What is wrong with the tool catalog the assistant relies on, given what the
|
||||
* registry holds: a tool with a card of its own, a package's cards for a tool,
|
||||
* an undo target, an undo read or a package's held read 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[],
|
||||
cards: Readonly<Record<string, ToolCards>> = TOOL_CARDS,
|
||||
heldReads: Iterable<string> = PACKAGE_HELD_READS,
|
||||
): string[] {
|
||||
const tools = new Set(listing.filter((entry) => entry.kind === 'tool').map((entry) => entry.name));
|
||||
const problems: string[] = [];
|
||||
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 name of Object.keys(cards)) {
|
||||
if (!tools.has(name) && !ASSISTANT_SERVER_TOOLS.has(name)) {
|
||||
problems.push(`tool "${name}" with cards of a package is neither registered nor the server's own`);
|
||||
}
|
||||
}
|
||||
for (const undo of Object.values(ASSISTANT_UNDO_MAP)) {
|
||||
if (!tools.has(undo.tool)) problems.push(`undo tool "${undo.tool}" is not registered`);
|
||||
}
|
||||
for (const name of undoReadTools(cards)) {
|
||||
if (!tools.has(name)) problems.push(`undo read "${name}" is not registered`);
|
||||
}
|
||||
for (const name of heldReads) {
|
||||
if (!tools.has(name)) problems.push(`held read "${name}" of a package is not registered`);
|
||||
}
|
||||
// The server's own steps are told apart from tool calls by name alone.
|
||||
for (const name of [...ASSISTANT_SERVER_TOOLS, USE_TOOLS_TOOL]) {
|
||||
if (tools.has(name)) problems.push(`"${name}" is the server's own step`);
|
||||
|
||||
@@ -1,100 +1,42 @@
|
||||
import type { AssistantToolSession } from './assistant-tools.service';
|
||||
import type { ToolArgs, UndoSnapshot } from './cards/card-types';
|
||||
import { readKey, recordOf, type Row } from './cards/card-values';
|
||||
import { TOOL_CARDS, undoReadsOf } from './cards/registry';
|
||||
import {
|
||||
byId,
|
||||
placeUnchanged,
|
||||
rowsOf,
|
||||
same,
|
||||
snapshotAssignments,
|
||||
snapshotDays,
|
||||
STABLE_FIELDS,
|
||||
} from './cards/undo-snapshot';
|
||||
|
||||
/**
|
||||
* Whether an applied change may still be taken back: what it created must be
|
||||
* as it was then. An undo removes the created row with everything that hangs
|
||||
* off it, so a place someone planned on another day since, or booked, or
|
||||
* linked an expense to, is not removed by an undo the user meant for the bare
|
||||
* place. The trip is read as the tools answer it (`get_trip_summary` raw, and
|
||||
* `list_places` for places).
|
||||
* `list_places` for places), plus the reads a package's undo asks for.
|
||||
*/
|
||||
|
||||
type Row = Record<string, unknown>;
|
||||
/** The trip as an undo reads it; the package reads are left out where none were made. */
|
||||
export type TripSnapshot = Omit<UndoSnapshot, 'reads'> & { reads?: UndoSnapshot['reads'] };
|
||||
|
||||
export interface TripSnapshot {
|
||||
summary: unknown;
|
||||
places: unknown;
|
||||
}
|
||||
/** The trip reads every undo makes before it checks. */
|
||||
const TRIP_READS = ['get_trip_summary', 'list_places'] as const;
|
||||
|
||||
function recordOf(value: unknown): Row {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Row) : {};
|
||||
}
|
||||
|
||||
function rowsOf(value: unknown): Row[] {
|
||||
return Array.isArray(value) ? value.map(recordOf) : [];
|
||||
}
|
||||
|
||||
/** The fields that say a row is still the one that was created; a field either side lacks is not compared. */
|
||||
const STABLE_FIELDS = {
|
||||
place: ['name', 'updated_at', 'lat', 'lng', 'address', 'category_id', 'description', 'notes'],
|
||||
assignment: ['day_id', 'place_id'],
|
||||
note: ['text', 'time', 'icon', 'color'],
|
||||
packing: ['name', 'updated_at', 'checked', 'quantity', 'category'],
|
||||
todo: ['name', 'checked', 'due_date', 'description', 'priority', 'assigned_user_id', 'category'],
|
||||
budget: ['name', 'total_price', 'currency', 'category', 'note', 'expense_date'],
|
||||
} as const;
|
||||
|
||||
function same(created: Row, current: Row | undefined, fields: readonly string[]): boolean {
|
||||
if (!current) return false;
|
||||
return fields.every((field) => !(field in created) || !(field in current) || created[field] === current[field]);
|
||||
}
|
||||
|
||||
function byId(rows: Row[], id: unknown): Row | undefined {
|
||||
return rows.find((row) => row.id === id);
|
||||
}
|
||||
|
||||
function days(snapshot: TripSnapshot): Row[] {
|
||||
return rowsOf(recordOf(snapshot.summary).days);
|
||||
}
|
||||
|
||||
function assignments(snapshot: TripSnapshot): Row[] {
|
||||
return days(snapshot).flatMap((day) => rowsOf(day.assignments));
|
||||
}
|
||||
|
||||
/** A created place, unchanged and still only where the change put it: on no other day, in no booking or expense. */
|
||||
function placeUnchanged(result: Row, snapshot: TripSnapshot): boolean {
|
||||
const created = recordOf(result.place);
|
||||
const current = byId(rowsOf(recordOf(snapshot.places).places), created.id);
|
||||
if (!same(created, current, STABLE_FIELDS.place)) return false;
|
||||
const ownAssignment = recordOf(result.assignment).id;
|
||||
const plannedElsewhere = assignments(snapshot).some((a) => a.place_id === created.id && a.id !== ownAssignment);
|
||||
const summary = recordOf(snapshot.summary);
|
||||
const referenced = [
|
||||
...rowsOf(summary.reservations),
|
||||
...rowsOf(summary.accommodations),
|
||||
...rowsOf(recordOf(summary.budget).items),
|
||||
].some((row) => row.place_id === created.id);
|
||||
return !plannedElsewhere && !referenced;
|
||||
}
|
||||
|
||||
/**
|
||||
* A created tour, unchanged and on no day: its place still has the name and
|
||||
* description it was created with, has not been written since it was made,
|
||||
* and nothing plans, books or pays for it. Every write of a tour's route or
|
||||
* name (a change over MCP, the planner's save, a rename) stamps its place's
|
||||
* `updated_at`, which the create left equal to `created_at`; the revision is
|
||||
* not in the snapshot. A tour planned on a day since is kept too.
|
||||
*/
|
||||
function tourUnchanged(result: Row, snapshot: TripSnapshot): boolean {
|
||||
const tour = recordOf(result.tour);
|
||||
const current = byId(rowsOf(recordOf(snapshot.places).places), tour.place_id);
|
||||
const untouched = typeof current?.created_at === 'string' && current.updated_at === current.created_at;
|
||||
return (
|
||||
untouched &&
|
||||
placeUnchanged({ place: { id: tour.place_id, name: tour.name, description: tour.description ?? null } }, snapshot)
|
||||
);
|
||||
}
|
||||
|
||||
const CHECKS: Readonly<Record<string, (result: Row, snapshot: TripSnapshot) => boolean>> = {
|
||||
const CORE_CHECKS: Readonly<Record<string, (result: Row, snapshot: UndoSnapshot) => boolean>> = {
|
||||
create_place: placeUnchanged,
|
||||
create_and_assign_place: placeUnchanged,
|
||||
create_hike: tourUnchanged,
|
||||
create_city_walk: tourUnchanged,
|
||||
assign_place_to_day: (result, snapshot) => {
|
||||
const created = recordOf(result.assignment);
|
||||
return same(created, byId(assignments(snapshot), created.id), STABLE_FIELDS.assignment);
|
||||
return same(created, byId(snapshotAssignments(snapshot), created.id), STABLE_FIELDS.assignment);
|
||||
},
|
||||
create_day_note: (result, snapshot) => {
|
||||
const created = recordOf(result.note);
|
||||
const notes = days(snapshot).flatMap((day) => rowsOf(day.notes));
|
||||
const notes = snapshotDays(snapshot).flatMap((day) => rowsOf(day.notes));
|
||||
return same(created, byId(notes, created.id), STABLE_FIELDS.note);
|
||||
},
|
||||
create_packing_item: (result, snapshot) => {
|
||||
@@ -113,8 +55,47 @@ const CHECKS: Readonly<Record<string, (result: Row, snapshot: TripSnapshot) => b
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* The core checks and each package's `undo.unchanged`, which the registry
|
||||
* guards (one that throws reads as changed) and keeps off the core's tools.
|
||||
*/
|
||||
const CHECKS: Readonly<Record<string, (result: Row, snapshot: UndoSnapshot) => boolean>> = {
|
||||
...CORE_CHECKS,
|
||||
...Object.fromEntries(
|
||||
Object.entries(TOOL_CARDS).flatMap(([tool, cards]) => {
|
||||
const undo = cards.undo;
|
||||
return undo ? [[tool, (result: Row, snapshot: UndoSnapshot) => undo.unchanged(result, snapshot)]] : [];
|
||||
}),
|
||||
),
|
||||
};
|
||||
|
||||
/** True when the change `toolName` made (its stored `result`) is still as it left it. */
|
||||
export function unchangedSinceApplied(toolName: string, result: unknown, snapshot: TripSnapshot): boolean {
|
||||
const check = CHECKS[toolName];
|
||||
return !!check && check(recordOf(result), snapshot);
|
||||
return !!check && check(recordOf(result), { ...snapshot, reads: snapshot.reads ?? new Map() });
|
||||
}
|
||||
|
||||
/** The tools an undo's session holds: the removing tool, the trip reads and what the change's package reads. */
|
||||
export function undoTargetsOf(undoTool: string, toolName: string, args: ToolArgs, result: unknown): Set<string> {
|
||||
return new Set([undoTool, ...TRIP_READS, ...undoReadsOf(toolName, args, result).map((read) => read.tool)]);
|
||||
}
|
||||
|
||||
/** The trip as the undo checks it, read through the undo's session; null when a trip read failed. */
|
||||
export async function undoSnapshotOf(
|
||||
session: Pick<AssistantToolSession, 'read'>,
|
||||
toolName: string,
|
||||
args: ToolArgs,
|
||||
result: unknown,
|
||||
reads = undoReadsOf(toolName, args, result),
|
||||
): Promise<UndoSnapshot | null> {
|
||||
const summary = await session.read('get_trip_summary');
|
||||
const places = await session.read('list_places');
|
||||
if (!summary.ok || !places.ok) return null;
|
||||
const extra = await Promise.all(
|
||||
reads.map(async (read): Promise<[string, unknown]> => {
|
||||
const run = await session.read(read.tool, read.args);
|
||||
return [readKey(read.tool, read.args), run.ok ? run.value : null];
|
||||
}),
|
||||
);
|
||||
return { summary: summary.value, places: places.value, reads: new Map(extra) };
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { AssistantThreads } from '../../db/entities/AssistantThreads.entity';
|
||||
import { AssistantToolCalls } from '../../db/entities/AssistantToolCalls.entity';
|
||||
import { AddonsModule } from '../addons/addons.module';
|
||||
import { AiConnectionsModule } from '../ai-connections/ai-connections.module';
|
||||
import { AirportsModule } from '../airports/airports.module';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { BookingImportModule } from '../booking-import/booking-import.module';
|
||||
import { CollabModule } from '../collab/collab.module';
|
||||
@@ -21,6 +22,7 @@ import { AssistantStreamRegistry, InMemoryAssistantStreamRegistry } from './assi
|
||||
import { AssistantToolsService } from './assistant-tools.service';
|
||||
import { AssistantController } from './assistant.controller';
|
||||
import { AssistantService } from './assistant.service';
|
||||
import { AssistantCardContext } from './cards/card-context';
|
||||
import { MikroOrmModule } from '@mikro-orm/nestjs';
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
@@ -29,13 +31,15 @@ import { Module } from '@nestjs/common';
|
||||
* model the user's connection reaches (AiConnectionsModule), working the trip
|
||||
* through the MCP tools of the global registry. A file attached in the chat
|
||||
* is read through the booking import, and an answer shared in the trip's
|
||||
* chat is posted through the collab chat. Its conversations are kept per user
|
||||
* and trip and swept by the retention job.
|
||||
* chat is posted through the collab chat. A card names an airport through the
|
||||
* airports' data. Its conversations are kept per user and trip and swept by
|
||||
* the retention job.
|
||||
*/
|
||||
@Module({
|
||||
imports: [
|
||||
AddonsModule,
|
||||
AiConnectionsModule,
|
||||
AirportsModule,
|
||||
AuditModule,
|
||||
BookingImportModule,
|
||||
CollabModule,
|
||||
@@ -57,6 +61,7 @@ import { Module } from '@nestjs/common';
|
||||
AssistantFoundPlacesService,
|
||||
AssistantShareService,
|
||||
AssistantRetentionJob,
|
||||
AssistantCardContext,
|
||||
{ provide: AssistantStreamRegistry, useClass: InMemoryAssistantStreamRegistry },
|
||||
],
|
||||
})
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { CARD_PROMPT_LINES } from './cards/registry';
|
||||
|
||||
/**
|
||||
* The assistant's standing instructions. They stay the same for every turn
|
||||
* of a conversation (the provider caches them); what changes from turn to
|
||||
@@ -33,7 +35,10 @@ How you work:
|
||||
- For the wind on a day (a paddle, a sail, a ridge walk), use get_wind_forecast: it has the gusts and the direction hour by hour.
|
||||
- If the request is unclear, ask one short question instead of guessing.`;
|
||||
|
||||
/** The instructions with the packages' lines about their cards, built once so the provider's cache holds. */
|
||||
const STANDING = INSTRUCTIONS + CARD_PROMPT_LINES.map((line) => `\n- ${line}`).join('');
|
||||
|
||||
/** The standing instructions, telling the model which language to answer in. */
|
||||
export function systemPrompt(language: string): string {
|
||||
return INSTRUCTIONS.replace('{language}', language);
|
||||
return STANDING.replace('{language}', language);
|
||||
}
|
||||
|
||||
@@ -2,10 +2,11 @@ import { AssistantMessages } from '../../db/entities/AssistantMessages.entity';
|
||||
import { AssistantThreads } from '../../db/entities/AssistantThreads.entity';
|
||||
import { AssistantToolCalls } from '../../db/entities/AssistantToolCalls.entity';
|
||||
import {
|
||||
ASSISTANT_MESSAGE_PARTS,
|
||||
ASSISTANT_MESSAGE_PARTS_WRITE,
|
||||
ASSISTANT_PROVIDER_STATE,
|
||||
ASSISTANT_TOOL_ARGS,
|
||||
ASSISTANT_TOOL_PREVIEW,
|
||||
ASSISTANT_TOOL_PREVIEW_WRITE,
|
||||
ASSISTANT_TOOL_RESULT,
|
||||
} from '../../db/json-columns';
|
||||
import type {
|
||||
@@ -34,18 +35,17 @@ import { SettingsService } from '../settings/settings.service';
|
||||
import { runAgentLoop, type AgentStore, type HeldCall, type StepRecord } from './assistant-agent';
|
||||
import { AssistantBookingImportService } from './assistant-booking-import.service';
|
||||
import { mentionItems } from './assistant-cards.helpers';
|
||||
import { buildTripCard, categoriesFrom, languageName, todayIn, turnContext, type TripCard } from './assistant-context';
|
||||
import { buildTripCard, languageName, todayIn, turnContext, type TripCard } from './assistant-context';
|
||||
import { DISCARDED, NOT_RUN, type Outcome, withCancelled, withOutcomes } from './assistant-decisions.helpers';
|
||||
import { messageIdOf, outputText, partsOf, toChatHistory, toThreadMessages } from './assistant-history';
|
||||
import { rememberForTrip } from './assistant-memory';
|
||||
import { toolOutputForProvider } from './assistant-redact';
|
||||
import { toolOutputForProvider } from './assistant-output';
|
||||
import { AssistantStreamRegistry } from './assistant-stream.registry';
|
||||
import { titleFrom } from './assistant-title';
|
||||
import {
|
||||
ADD_FOUND_PLACE_TOOL,
|
||||
APPROVAL_TTL_MS,
|
||||
ASSISTANT_SERVER_WRITE_TOOLS,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
foundPlaceArgs,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
READ_BOOKING_FILE_TOOL,
|
||||
@@ -55,9 +55,11 @@ import {
|
||||
writeToolOf,
|
||||
} from './assistant-tools.constants';
|
||||
import { AssistantToolsService, type AssistantToolSession, type ToolRun } from './assistant-tools.service';
|
||||
import { unchangedSinceApplied } from './assistant-undo.helpers';
|
||||
import { unchangedSinceApplied, undoSnapshotOf, undoTargetsOf } from './assistant-undo.helpers';
|
||||
import { systemPrompt } from './assistant.prompt';
|
||||
import { appliedChange, describeToolCall } from './tool-call-preview.helpers';
|
||||
import { AssistantCardContext } from './cards/card-context';
|
||||
import { ASSISTANT_UNDO_MAP } from './cards/registry';
|
||||
import { appliedChange } from './tool-call-preview.helpers';
|
||||
import { UiStreamWriter } from './ui-stream.writer';
|
||||
import { MikroORM } from '@mikro-orm/core';
|
||||
import { InjectRepository } from '@mikro-orm/nestjs';
|
||||
@@ -65,6 +67,7 @@ import { Injectable } from '@nestjs/common';
|
||||
import type {
|
||||
AssistantAttachmentRequest,
|
||||
AssistantChatRequest,
|
||||
AssistantClientCtx,
|
||||
AssistantDecision,
|
||||
AssistantDecisionsRequest,
|
||||
AssistantFocus,
|
||||
@@ -133,7 +136,7 @@ function summaryOf(thread: AssistantThreadRow): AssistantThreadSummary {
|
||||
}
|
||||
|
||||
function encodeParts(parts: AssistantMessagePart[]): string {
|
||||
return encodeJson(ASSISTANT_MESSAGE_PARTS, parts);
|
||||
return encodeJson(ASSISTANT_MESSAGE_PARTS_WRITE, parts);
|
||||
}
|
||||
|
||||
const threadNotFound = () => aiRefusal('not_found', 'Conversation not found');
|
||||
@@ -166,6 +169,7 @@ export class AssistantService {
|
||||
private readonly streams: AssistantStreamRegistry,
|
||||
private readonly uow: UnitOfWork,
|
||||
private readonly orm: MikroORM,
|
||||
private readonly cardContext: AssistantCardContext,
|
||||
) {}
|
||||
|
||||
async listThreads(userId: number, tripId: number): Promise<AssistantThreadListResponse> {
|
||||
@@ -265,7 +269,8 @@ export class AssistantService {
|
||||
if (begun.created) w.thread(begun.threadId, begun.title);
|
||||
const stopKeepalive = armKeepalive(stream);
|
||||
try {
|
||||
const turn = { userId, tripId, threadId: begun.threadId, rowId: begun.rowId, locale: req.locale, name };
|
||||
const { locale, timeZone } = req;
|
||||
const turn = { userId, tripId, threadId: begun.threadId, rowId: begun.rowId, locale, timeZone, name };
|
||||
await this.proposeBookings(turn, file, w, signal);
|
||||
} catch (err) {
|
||||
const reason = err instanceof Error ? err.message : String(err);
|
||||
@@ -416,19 +421,13 @@ export class AssistantService {
|
||||
const tool = writeToolOf(call.tool_name, args);
|
||||
const undo = ASSISTANT_UNDO_MAP[tool];
|
||||
const result = decodeJson(ASSISTANT_TOOL_RESULT, call.result, `tool call ${call.id}`);
|
||||
const undoArgs = undo && call.state === 'executed' ? undo.args(args, result) : null;
|
||||
const preview = decodeJson(ASSISTANT_TOOL_PREVIEW, call.preview, `tool call ${call.id}`);
|
||||
const undoArgs = undo && call.state === 'executed' ? undo.args(args, result, preview) : null;
|
||||
if (!undo || !undoArgs) throw stale();
|
||||
const session = await this.tools.open(userId, tripId, new Set([undo.tool, 'get_trip_summary', 'list_places']));
|
||||
const session = await this.tools.open(userId, tripId, undoTargetsOf(undo.tool, tool, args, result));
|
||||
try {
|
||||
const summary = await session.read('get_trip_summary');
|
||||
const places = await session.read('list_places');
|
||||
if (
|
||||
!summary.ok ||
|
||||
!places.ok ||
|
||||
!unchangedSinceApplied(tool, result, { summary: summary.value, places: places.value })
|
||||
) {
|
||||
throw stale();
|
||||
}
|
||||
const snapshot = await undoSnapshotOf(session, tool, args, result);
|
||||
if (!snapshot || !unchangedSinceApplied(tool, result, snapshot)) throw stale();
|
||||
if (!(await this.toolCalls.claimUndo(call.id, userId, dbNow()))) throw stale();
|
||||
const run = await session.call(undo.tool, undoArgs);
|
||||
if (!run.ok) {
|
||||
@@ -530,7 +529,7 @@ export class AssistantService {
|
||||
* transaction of its own.
|
||||
*/
|
||||
private async proposeBookings(
|
||||
turn: { userId: number; tripId: number; threadId: number; rowId: number; locale: string; name: string },
|
||||
turn: { userId: number; tripId: number; threadId: number; rowId: number; name: string } & AssistantClientCtx,
|
||||
file: Express.Multer.File,
|
||||
w: UiStreamWriter,
|
||||
signal: AbortSignal,
|
||||
@@ -574,18 +573,14 @@ export class AssistantService {
|
||||
return;
|
||||
}
|
||||
const card = await this.tripCardOf(turn.userId, turn.tripId);
|
||||
const ctx = { card, categories: new Map<number, string>(), locale: turn.locale };
|
||||
const held: HeldCall[] = items.map((item) => {
|
||||
const id = randomUUID();
|
||||
const args = item as Record<string, unknown>;
|
||||
return {
|
||||
id,
|
||||
providerCallId: `import_${id}`,
|
||||
toolName: IMPORT_BOOKING_TOOL,
|
||||
args,
|
||||
preview: describeToolCall(IMPORT_BOOKING_TOOL, id, args, ctx),
|
||||
};
|
||||
});
|
||||
const ctx = this.cardContext.contextOf(card, new Map(), turn.locale, todayIn(turn.timeZone, new Date()));
|
||||
const proposed = items.map((item) => ({
|
||||
id: randomUUID(),
|
||||
toolName: IMPORT_BOOKING_TOOL,
|
||||
args: item as Record<string, unknown>,
|
||||
}));
|
||||
const described = await this.cardContext.describeProposed(turn.userId, turn.tripId, ctx, proposed);
|
||||
const held: HeldCall[] = described.map((change) => ({ ...change, providerCallId: `import_${change.id}` }));
|
||||
const parts: AssistantMessagePart[] = [
|
||||
readPart(found),
|
||||
...held.map((change): AssistantMessagePart => ({
|
||||
@@ -613,7 +608,7 @@ export class AssistantService {
|
||||
try {
|
||||
const summary = await session.read('get_trip_summary');
|
||||
const places = await session.read('list_places');
|
||||
return buildTripCard(tripId, summary.ok ? summary.value : null, places.ok ? places.value : null, null);
|
||||
return buildTripCard(tripId, userId, summary.ok ? summary.value : null, places.ok ? places.value : null, null);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
@@ -714,7 +709,8 @@ export class AssistantService {
|
||||
const args = decodeJson(ASSISTANT_TOOL_ARGS, call.args, `tool call ${call.id}`);
|
||||
const ran = await run(call.tool_name, args);
|
||||
const output = toolOutputForProvider(call.tool_name, ran.value);
|
||||
const undoable = ran.ok && !!ASSISTANT_UNDO_MAP[writeToolOf(call.tool_name, args)]?.args(args, ran.value);
|
||||
const preview = decodeJson(ASSISTANT_TOOL_PREVIEW, call.preview, `tool call ${call.id}`);
|
||||
const undoable = ran.ok && !!ASSISTANT_UNDO_MAP[writeToolOf(call.tool_name, args)]?.args(args, ran.value, preview);
|
||||
const undoUntil = undoable ? dbNow(new Date(Date.now() + UNDO_WINDOW_MS)) : null;
|
||||
await this.inContext(() =>
|
||||
this.toolCalls.recordOutcome(
|
||||
@@ -731,7 +727,6 @@ export class AssistantService {
|
||||
return;
|
||||
}
|
||||
w.toolOutput(call.id, output);
|
||||
const preview = decodeJson(ASSISTANT_TOOL_PREVIEW, call.preview, `tool call ${call.id}`);
|
||||
const applied = appliedChange(
|
||||
call.tool_name,
|
||||
call.id,
|
||||
@@ -744,6 +739,7 @@ export class AssistantService {
|
||||
w.applied(applied);
|
||||
receipts.push({ type: 'data-trek-applied', data: applied });
|
||||
}
|
||||
this.cardContext.receipt(w, receipts, call, args, ran.value, preview);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -832,21 +828,17 @@ export class AssistantService {
|
||||
const providerIds = new Map([...calls.values()].map((call) => [call.id, call.provider_call_id]));
|
||||
const history = toChatHistory(rows, providerIds);
|
||||
const prefs = await this.inContext(() => this.settings.getUserSettings(spec.userId));
|
||||
let categories: Map<number, string> | null = null;
|
||||
const today = todayIn(spec.timeZone, new Date());
|
||||
const outcome = await runAgentLoop(
|
||||
{
|
||||
client: this.chatClients.create(spec.connection),
|
||||
tools: session,
|
||||
store: this.stepStore(spec),
|
||||
describe: async (toolName, toolCallId, args) => {
|
||||
categories ??= await this.categoriesOf(session);
|
||||
const hintOf = (name: string) => session.hintOf(name);
|
||||
return describeToolCall(toolName, toolCallId, args, { card, categories, locale: spec.locale, hintOf });
|
||||
},
|
||||
describe: this.cardContext.forTurn(session, card, spec.locale, today).describe,
|
||||
newId: randomUUID,
|
||||
quota: spec.quota,
|
||||
card,
|
||||
today: todayIn(spec.timeZone, new Date()),
|
||||
today,
|
||||
},
|
||||
{
|
||||
system: systemPrompt(languageName(spec.locale)),
|
||||
@@ -890,13 +882,7 @@ export class AssistantService {
|
||||
const summary = await session.read('get_trip_summary');
|
||||
const places = await session.read('list_places');
|
||||
const [found, listed] = [summary.ok ? summary.value : null, places.ok ? places.value : null];
|
||||
return buildTripCard(spec.tripId, found, listed, spec.focus, spec.mentions);
|
||||
}
|
||||
|
||||
private async categoriesOf(session: AssistantToolSession): Promise<Map<number, string>> {
|
||||
const run = await session.read('list_categories');
|
||||
const categories = categoriesFrom(run.ok ? run.value : null);
|
||||
return new Map(categories.map((category): [number, string] => [category.id, category.name]));
|
||||
return buildTripCard(spec.tripId, spec.userId, found, listed, spec.focus, spec.mentions);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -973,7 +959,7 @@ export class AssistantService {
|
||||
providerCallId: change.providerCallId,
|
||||
toolName: change.toolName,
|
||||
args: encodeJson(ASSISTANT_TOOL_ARGS, change.args),
|
||||
preview: encodeJson(ASSISTANT_TOOL_PREVIEW, change.preview),
|
||||
preview: encodeJson(ASSISTANT_TOOL_PREVIEW_WRITE, change.preview),
|
||||
createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { writeToolOf } from '../assistant-tools.constants';
|
||||
import type { UiStreamWriter } from '../ui-stream.writer';
|
||||
import type { ToolArgs } from './card-types';
|
||||
import { appliedCardOf } from './registry';
|
||||
import type { AssistantChangePreview, AssistantMessagePart, TrekCard } from '@trek/shared';
|
||||
|
||||
/** Builds the card of an applied change of `tool`; null when the tool has none. */
|
||||
type AppliedCardBuilder = (
|
||||
tool: string,
|
||||
input: { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null },
|
||||
) => TrekCard | null;
|
||||
|
||||
/**
|
||||
* The card of an applied change, streamed and kept beside its receipt under
|
||||
* the write's own id, so the client puts it in place of the preview card. Its
|
||||
* builder gets no trip card (the decisions run before the turn reads one): it
|
||||
* builds on the stored preview card and the tool's raw answer.
|
||||
*/
|
||||
export function receiptCard(
|
||||
w: Pick<UiStreamWriter, 'card'>,
|
||||
receipts: AssistantMessagePart[],
|
||||
call: { id: string; tool_name: string },
|
||||
args: ToolArgs,
|
||||
result: unknown,
|
||||
preview: AssistantChangePreview | null,
|
||||
build: AppliedCardBuilder = appliedCardOf,
|
||||
): void {
|
||||
const card = build(writeToolOf(call.tool_name, args), { args, result, preview });
|
||||
if (!card) return;
|
||||
const data = { toolCallId: call.id, card };
|
||||
w.card(data);
|
||||
receipts.push({ type: 'data-trek-card', data });
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
|
||||
/** The cards of the atlas: countries, regions and the bucket list. None yet: this package fills them in. */
|
||||
export const ATLAS_CARDS: DomainCards = { id: 'atlas', tools: {} };
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
|
||||
/** The cards of the bookings: flights, trains, stays and other reservations. None yet: this package fills them in. */
|
||||
export const BOOKING_CARDS: DomainCards = { id: 'booking', tools: {} };
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
|
||||
/** The cards of the budget: expenses, payments and who settles with whom. None yet: this package fills them in. */
|
||||
export const BUDGET_CARDS: DomainCards = { id: 'budget', tools: {} };
|
||||
@@ -0,0 +1,182 @@
|
||||
import { AirportsService } from '../../airports/airports.service';
|
||||
import { logWarn } from '../../audit/audit-log.logger';
|
||||
import { categoriesFrom, type TripCard } from '../assistant-context';
|
||||
import { writeToolOf } from '../assistant-tools.constants';
|
||||
import { AssistantToolsService, type AssistantToolSession } from '../assistant-tools.service';
|
||||
import { describeToolCall } from '../tool-call-preview.helpers';
|
||||
import type { UiStreamWriter } from '../ui-stream.writer';
|
||||
import { receiptCard } from './applied-cards';
|
||||
import type { CardAirport, CardReader, PreviewContext, ToolArgs, ToolCards } from './card-types';
|
||||
import { readKey } from './card-values';
|
||||
import { appliedCardOf, TOOL_CARDS } from './registry';
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import type { AssistantChangePreview, AssistantMessagePart, TrekCard } from '@trek/shared';
|
||||
|
||||
/** Reads one held change's lookup may make; the ones past it read as null. */
|
||||
export const MAX_CARD_READS = 4;
|
||||
|
||||
/** What the turn's reads go through: the session, unaudited. */
|
||||
type ReadSession = Pick<AssistantToolSession, 'read'>;
|
||||
|
||||
/** A change the server proposes itself, before its card is written. */
|
||||
interface ProposedCall {
|
||||
id: string;
|
||||
toolName: string;
|
||||
args: ToolArgs;
|
||||
}
|
||||
|
||||
/** A turn's describer of held calls, as the agent loop asks for it. */
|
||||
interface TurnCards {
|
||||
describe(tool: string, toolCallId: string, args: ToolArgs): Promise<AssistantChangePreview>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads through the session, each tool and set of arguments once per turn:
|
||||
* the second card that asks for the same read gets the first one's answer.
|
||||
* A read that failed reads as null.
|
||||
*/
|
||||
export function memoisedReader(session: ReadSession): (tool: string, args?: ToolArgs) => Promise<unknown> {
|
||||
const memo = new Map<string, Promise<unknown>>();
|
||||
return (tool, args = {}) => {
|
||||
const key = readKey(tool, args);
|
||||
let read = memo.get(key);
|
||||
if (!read) {
|
||||
read = session.read(tool, args).then(
|
||||
(run) => (run.ok ? run.value : null),
|
||||
() => null,
|
||||
);
|
||||
memo.set(key, read);
|
||||
}
|
||||
return read;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The reader one held change's lookup gets: the turn's, cut off after
|
||||
* MAX_CARD_READS reads. A read the memo answers counts too, so a lookup asks
|
||||
* at most that often whatever the turn read before it.
|
||||
*/
|
||||
function cappedReader(read: CardReader, tool: string): CardReader {
|
||||
let left = MAX_CARD_READS;
|
||||
return (name, args) => {
|
||||
if (left <= 0) {
|
||||
logWarn(`[assistant] the lookup of ${tool} asked for more than ${MAX_CARD_READS} reads`);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
left -= 1;
|
||||
return read(name, args);
|
||||
};
|
||||
}
|
||||
|
||||
/** What a package's lookup found for a held call, or null when it has none or it failed. */
|
||||
export async function loadFor(
|
||||
tool: string,
|
||||
args: ToolArgs,
|
||||
read: CardReader,
|
||||
registry: Readonly<Record<string, ToolCards>> = TOOL_CARDS,
|
||||
): Promise<unknown> {
|
||||
const cards = registry[tool];
|
||||
if (!cards?.load) return null;
|
||||
try {
|
||||
return (await cards.load(args, cappedReader(read, tool))) ?? null;
|
||||
} catch (err) {
|
||||
logWarn(`[assistant] the lookup of ${tool} failed: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* What the assistant makes its cards with beyond the tool's own answer: the
|
||||
* context a change card is written in (the trip card, the categories, the
|
||||
* user's locale and day, the airports), the lookups a package's card makes
|
||||
* before it is shown, and the card of an applied change.
|
||||
*/
|
||||
@Injectable()
|
||||
export class AssistantCardContext {
|
||||
constructor(
|
||||
private readonly airports: AirportsService,
|
||||
private readonly tools: AssistantToolsService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* The describer of one turn's held calls. The categories are read once,
|
||||
* when the first change needs them; a package's lookup reads through the
|
||||
* turn's memo, at most MAX_CARD_READS reads per change.
|
||||
*/
|
||||
forTurn(session: ReadSession & Pick<AssistantToolSession, 'hintOf'>, card: TripCard, locale: string, today: string) {
|
||||
const read = memoisedReader(session);
|
||||
const hintOf = (name: string) => session.hintOf(name);
|
||||
let categories: Promise<ReadonlyMap<number, string>> | null = null;
|
||||
const turn: TurnCards = {
|
||||
describe: async (tool, toolCallId, args) => {
|
||||
categories ??= read('list_categories').then(
|
||||
(value) => new Map(categoriesFrom(value).map((category): [number, string] => [category.id, category.name])),
|
||||
);
|
||||
const ctx = this.contextOf(card, await categories, locale, today, hintOf);
|
||||
const loaded = await loadFor(writeToolOf(tool, args), args, read);
|
||||
return describeToolCall(tool, toolCallId, args, ctx, loaded);
|
||||
},
|
||||
};
|
||||
return turn;
|
||||
}
|
||||
|
||||
/** The context a change card is written in, for a change the server proposes without a turn. */
|
||||
contextOf(
|
||||
card: TripCard,
|
||||
categories: ReadonlyMap<number, string>,
|
||||
locale: string,
|
||||
today: string,
|
||||
hintOf?: PreviewContext['hintOf'],
|
||||
): PreviewContext {
|
||||
return { card, categories, locale, today, ...(hintOf ? { hintOf } : {}), airport: (code) => this.airport(code) };
|
||||
}
|
||||
|
||||
/**
|
||||
* The change cards of changes the server proposes without a turn (a booking
|
||||
* from a file, a found place), each with what its package looked up. A
|
||||
* session for the lookups is opened only when one of the tools has a lookup.
|
||||
*/
|
||||
async describeProposed<C extends ProposedCall>(
|
||||
userId: number,
|
||||
tripId: number,
|
||||
ctx: PreviewContext,
|
||||
calls: readonly C[],
|
||||
registry: Readonly<Record<string, ToolCards>> = TOOL_CARDS,
|
||||
): Promise<Array<C & { preview: AssistantChangePreview }>> {
|
||||
const looksUp = calls.some((call) => !!registry[writeToolOf(call.toolName, call.args)]?.load);
|
||||
const session = looksUp ? await this.tools.open(userId, tripId) : null;
|
||||
try {
|
||||
const read: CardReader = session ? memoisedReader(session) : () => Promise.resolve(null);
|
||||
return await Promise.all(
|
||||
calls.map(async (call) => {
|
||||
const loaded = await loadFor(writeToolOf(call.toolName, call.args), call.args, read, registry);
|
||||
return { ...call, preview: describeToolCall(call.toolName, call.id, call.args, ctx, loaded) };
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session?.close();
|
||||
}
|
||||
}
|
||||
|
||||
/** The card of an applied change beside its receipt, streamed and kept on the proposing message. */
|
||||
receipt(
|
||||
w: Pick<UiStreamWriter, 'card'>,
|
||||
receipts: AssistantMessagePart[],
|
||||
call: { id: string; tool_name: string },
|
||||
args: ToolArgs,
|
||||
result: unknown,
|
||||
preview: AssistantChangePreview | null,
|
||||
): void {
|
||||
receiptCard(w, receipts, call, args, result, preview, (tool, input) => this.appliedCard(tool, input));
|
||||
}
|
||||
|
||||
/** The guarded applied card of `tool`; the e2e suite stubs it here. */
|
||||
appliedCard(tool: string, input: Parameters<typeof appliedCardOf>[1]): TrekCard | null {
|
||||
return appliedCardOf(tool, input);
|
||||
}
|
||||
|
||||
private airport(code: string): CardAirport | null {
|
||||
const found = /^[A-Za-z]{3}$/.test(code) ? this.airports.findByIata(code.toUpperCase()) : null;
|
||||
return found ? { city: found.city || found.name, lat: found.lat, lng: found.lng, tz: found.tz } : null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { TripCard } from '../assistant-context';
|
||||
import type { AssistantWriteTool } from '../assistant-tools.constants';
|
||||
import type { ToolHint } from '../assistant-tools.service';
|
||||
import type { Rows } from './preview-format';
|
||||
import type { AssistantChangeEffect, AssistantChangePreview, AssistantChangeVerbKey, TrekCard } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* What a part of TREK hands the assistant for its tools: one `ToolCards`
|
||||
* entry per tool, gathered per package in `cards/<pkg>/index.ts` and merged
|
||||
* by `registry.ts`. Types only, so any file may import them.
|
||||
*/
|
||||
|
||||
export type ToolArgs = Record<string, unknown>;
|
||||
|
||||
/** The packages that bring cards of their own, one folder each. */
|
||||
type CardPackageId =
|
||||
'tour' | 'roadtrip' | 'budget' | 'booking' | 'collab' | 'collection' | 'atlas' | 'vacay' | 'journey' | 'packing';
|
||||
|
||||
/**
|
||||
* A read a card may make while a change is described: unaudited, made once
|
||||
* per turn for the same tool and arguments, null on any failure.
|
||||
*/
|
||||
export type CardReader = (tool: string, args?: ToolArgs) => Promise<unknown>;
|
||||
|
||||
/** An airport as a card names it, by its IATA code. */
|
||||
export interface CardAirport {
|
||||
city: string;
|
||||
lat: number;
|
||||
lng: number;
|
||||
tz: string;
|
||||
}
|
||||
|
||||
/** What a change card is written with. */
|
||||
export interface PreviewContext {
|
||||
card: TripCard;
|
||||
categories: ReadonlyMap<number, string>;
|
||||
locale: string;
|
||||
/** Today in the user's time zone (`YYYY-MM-DD`). */
|
||||
today: string;
|
||||
/** What a tool without a card of its own says about itself, for the card built from its arguments. */
|
||||
hintOf?: (tool: string) => ToolHint | null;
|
||||
airport: (code: string) => CardAirport | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A change described in plain words. `verbKey`, `tool` and `domain` override
|
||||
* what the tool's `write` entry and the generic card would say: a verb that
|
||||
* depends on the arguments, the tool's own label, the part of TREK it works in.
|
||||
*/
|
||||
export interface Described {
|
||||
subject: string;
|
||||
rows: Rows;
|
||||
dayId: number | null;
|
||||
placeIds: number[];
|
||||
verbKey?: AssistantChangeVerbKey;
|
||||
tool?: string;
|
||||
domain?: string;
|
||||
}
|
||||
|
||||
/** What an answer card is built from: a read that ran, with its raw value (never the redacted one). */
|
||||
export interface AnswerInput {
|
||||
toolCallId: string;
|
||||
args: ToolArgs;
|
||||
value: unknown;
|
||||
card: TripCard;
|
||||
today: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* What an undo is checked against: the trip as `get_trip_summary` and
|
||||
* `list_places` answer it, and the reads the change's `undo.reads` asked for,
|
||||
* by `readKey(tool, args)` (null for one that failed).
|
||||
*/
|
||||
export interface UndoSnapshot {
|
||||
summary: unknown;
|
||||
places: unknown;
|
||||
reads: ReadonlyMap<string, unknown>;
|
||||
}
|
||||
|
||||
/** The way back from an applied change: the removing tool and its arguments, null when there is none. */
|
||||
export interface ToolUndo {
|
||||
tool: string;
|
||||
args(args: ToolArgs, result: unknown, preview: AssistantChangePreview | null): ToolArgs | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Everything a package says about one tool. Every member is optional and
|
||||
* written as a method, so a `ToolCards<X>` fits where a `ToolCards` is asked.
|
||||
* A builder that throws, returns a card the union refuses or one past
|
||||
* MAX_CARD_CHARS gives no card (logged), never a broken turn; any other
|
||||
* member that throws is logged and read as saying nothing (registry.ts).
|
||||
*/
|
||||
export interface ToolCards<L = unknown> {
|
||||
/** A curated change card: its verb and what the applied change touches. */
|
||||
write?: AssistantWriteTool;
|
||||
/** Arguments the model never chooses: `forced` ones are set, `stripped` ones taken away. */
|
||||
argPolicy?: { forced?: ToolArgs; stripped?: readonly string[] };
|
||||
/** The answer as the provider gets it, before redaction and fitting. */
|
||||
project?(value: unknown): unknown;
|
||||
/** The card a read shows beside the answer; only for a read that ran well. */
|
||||
answer?(input: AnswerInput): TrekCard | null;
|
||||
/** What describing a held call needs beyond the trip card: at most four reads, null on failure. */
|
||||
load?(args: ToolArgs, read: CardReader): Promise<L | null>;
|
||||
/** The change card's words; must give a valid card for `{}` arguments too. */
|
||||
describe?(ctx: PreviewContext, args: ToolArgs, loaded: L | null): Described;
|
||||
/** What the change does to the day plan, for the map and the day's timeline. */
|
||||
effect?(ctx: PreviewContext, args: ToolArgs, loaded: L | null): AssistantChangeEffect | null;
|
||||
/** The card a held call shows inside its change card. */
|
||||
change?(ctx: PreviewContext, args: ToolArgs, loaded: L | null): TrekCard | null;
|
||||
/** The card of the applied change, from the stored preview card and the tool's raw answer. */
|
||||
applied?(input: { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null }): TrekCard | null;
|
||||
/** The ids the receipt names, when the answer does not name them under the usual keys. */
|
||||
appliedIds?(args: ToolArgs, result: unknown): number[];
|
||||
/** The way back, with what it reads first and how it tells that nothing changed since. */
|
||||
undo?: ToolUndo & {
|
||||
/**
|
||||
* Reads the undo makes before it checks; every tool named here must be
|
||||
* registered. The boot check asks with empty arguments and result, so the
|
||||
* tools it names may not depend on them (their arguments may).
|
||||
*/
|
||||
reads?(args: ToolArgs, result: unknown): ReadonlyArray<{ tool: string; args: ToolArgs }>;
|
||||
unchanged(result: unknown, snapshot: UndoSnapshot): boolean;
|
||||
};
|
||||
/** The name of an id argument the trip card cannot name, for the generic card. */
|
||||
nameArg?(key: string, value: number, card: TripCard): string | null;
|
||||
}
|
||||
|
||||
/** One package's cards. */
|
||||
export interface DomainCards {
|
||||
readonly id: CardPackageId;
|
||||
readonly tools: Readonly<Record<string, ToolCards>>;
|
||||
/** At most two lines for the standing instructions, added once at load so the prompt cache stays stable. */
|
||||
readonly prompt?: readonly string[];
|
||||
/** Tool groups whose `placeId` arguments are no trip places (collections, atlas). */
|
||||
readonly foreignIdGroups?: readonly string[];
|
||||
/**
|
||||
* MCP controllers the package added, by the tool group they load with (a
|
||||
* key of ASSISTANT_TOOL_GROUPS); a controller listed nowhere is offered on
|
||||
* every step as part of the core.
|
||||
*/
|
||||
readonly toolControllers?: Readonly<Record<string, readonly string[]>>;
|
||||
/** Read-only tools that still wait for the user: they fetch an address that came from the web or a message. */
|
||||
readonly heldReads?: readonly string[];
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
import type { TripCard } from '../assistant-context';
|
||||
import { redactForProvider } from '../assistant-redact';
|
||||
import { cardFileKeySchema, type CardPerson } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The readers every card is built with. They take a tool's own answer or a
|
||||
* call's arguments as they come, trust no shape, and give null for what is
|
||||
* not there. Text from a RESULT is masked like a provider answer (`clipped`,
|
||||
* `blockText` with `mask`), so an e-mail address in a name or a note never
|
||||
* reaches a card; text from ARGS is only cut, since it is what will be stored.
|
||||
*/
|
||||
|
||||
export type Row = Record<string, unknown>;
|
||||
|
||||
export function recordOf(value: unknown): Row {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Row) : {};
|
||||
}
|
||||
|
||||
export function finite(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
export function isLat(value: number | null): value is number {
|
||||
return value !== null && Math.abs(value) <= 90;
|
||||
}
|
||||
|
||||
export function isLng(value: number | null): value is number {
|
||||
return value !== null && Math.abs(value) <= 180;
|
||||
}
|
||||
|
||||
function cut(text: string, max: number): string {
|
||||
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
||||
}
|
||||
|
||||
/** A text as a card may show it: on one line, masked like a provider answer, and cut to `max`. */
|
||||
export function clipped(value: unknown, max: number): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const line = String(redactForProvider(value.replace(/\s+/g, ' ').trim()));
|
||||
return line === '' ? null : cut(line, max);
|
||||
}
|
||||
|
||||
/**
|
||||
* A longer text (a note, a message) with its line breaks kept: spaces run
|
||||
* together, at most one empty line between paragraphs, cut to `max`. Masked
|
||||
* when it comes from a result.
|
||||
*/
|
||||
export function blockText(value: unknown, max: number, { mask }: { mask: boolean }): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const lines = value
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => line.replace(/[^\S\n]+/g, ' ').trim());
|
||||
const text = lines
|
||||
.join('\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim();
|
||||
const shown = mask ? String(redactForProvider(text)) : text;
|
||||
return shown === '' ? null : cut(shown, max);
|
||||
}
|
||||
|
||||
function positiveId(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
/** The id of the row an answer names under `key` (`{ place: { id } }`). */
|
||||
export function idIn(value: unknown, key: string): number | null {
|
||||
return positiveId(recordOf(recordOf(value)[key]).id);
|
||||
}
|
||||
|
||||
/** An argument read as an id. */
|
||||
export function argId(args: Row, key: string): number | null {
|
||||
return positiveId(args[key]);
|
||||
}
|
||||
|
||||
const CLOCK = /^(?:\d{4}-\d{2}-\d{2}[T ])?([01]?\d|2[0-3]):([0-5]\d)(?::[0-5]\d(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})?$/;
|
||||
|
||||
/** A time of day as `HH:MM`, from a clock (`9:30`, `09:30:00`) or the clock of a date and time as written. */
|
||||
export function clockOf(value: unknown): string | null {
|
||||
if (typeof value !== 'string') return null;
|
||||
const match = CLOCK.exec(value.trim());
|
||||
return match ? `${match[1].padStart(2, '0')}:${match[2]}` : null;
|
||||
}
|
||||
|
||||
const ZONED = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d+)?)?(?:Z|[+-]\d{2}:?\d{2})$/;
|
||||
|
||||
/**
|
||||
* A moment as `YYYY-MM-DDTHH:MM:SSZ`, from a date and time that names its
|
||||
* zone. One without a zone is a local time and no moment, so it gives null.
|
||||
*/
|
||||
export function instantOf(value: unknown): string | null {
|
||||
if (typeof value !== 'string' || !ZONED.test(value.trim())) return null;
|
||||
const ms = Date.parse(value.trim());
|
||||
return Number.isNaN(ms) ? null : `${new Date(ms).toISOString().slice(0, 19)}Z`;
|
||||
}
|
||||
|
||||
/** A colour the data carries, `#rrggbb`; anything else gives null. */
|
||||
export function hexOrNull(value: unknown): string | null {
|
||||
return typeof value === 'string' && /^#[0-9a-fA-F]{6}$/.test(value) ? value : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The stored file name behind one of TREK's own upload paths
|
||||
* (`/uploads/<dir>/<file>`), for the client to build the path again. Anything
|
||||
* else, a picture on another host among them, gives null.
|
||||
*/
|
||||
export function fileKeyOf(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const match = /^\/uploads\/[\w-]+\/([^/?#]+)$/.exec(raw);
|
||||
return match && cardFileKeySchema.safeParse(match[1]).success ? match[1] : null;
|
||||
}
|
||||
|
||||
/** A trip member as a card shows them, from the trip card; null for someone not in the trip. */
|
||||
export function personOf(card: TripCard, id: number): CardPerson | null {
|
||||
const member = card.members.get(id);
|
||||
const name = member ? clipped(member.name, 80) : null;
|
||||
return member && name ? { id, name, avatarKey: member.avatarKey } : null;
|
||||
}
|
||||
|
||||
/** JSON with the keys of every object in order, so the same arguments always give the same text. */
|
||||
export function canonicalJson(value: unknown): string {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
||||
if (value && typeof value === 'object') {
|
||||
const entries = Object.entries(value as Row)
|
||||
.filter(([, inner]) => inner !== undefined)
|
||||
.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
||||
return `{${entries.map(([key, inner]) => `${JSON.stringify(key)}:${canonicalJson(inner)}`).join(',')}}`;
|
||||
}
|
||||
return JSON.stringify(value) ?? 'null';
|
||||
}
|
||||
|
||||
/** How one read is named: its tool and its arguments, the same for the same read whatever the key order. */
|
||||
export function readKey(tool: string, args: Row = {}): string {
|
||||
return `${tool} ${canonicalJson(args)}`;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
|
||||
/** The cards of the trip chat: messages, polls and shared notes. None yet: this package fills them in. */
|
||||
export const COLLAB_CARDS: DomainCards = { id: 'collab', tools: {} };
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
|
||||
/** The cards of the collections and their places. None yet: this package fills them in. */
|
||||
export const COLLECTION_CARDS: DomainCards = { id: 'collection', tools: {} };
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
|
||||
/** The cards of the travel journal. None yet: this package fills them in. */
|
||||
export const JOURNEY_CARDS: DomainCards = { id: 'journey', tools: {} };
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
|
||||
/** The cards of packing lists and to-dos. None yet: this package fills them in. */
|
||||
export const PACKING_CARDS: DomainCards = { id: 'packing', tools: {} };
|
||||
@@ -0,0 +1,115 @@
|
||||
import type { PreviewContext } from './card-types';
|
||||
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* How a change card writes what it shows: a value cut to what a row holds,
|
||||
* an argument read as an id, dates and amounts in the user's locale, days,
|
||||
* places, people and categories by their names. Shared by the core cards
|
||||
* (tool-call-preview.helpers.ts) and every package's own.
|
||||
*/
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/** What naming a day or a place needs of the context. */
|
||||
type Naming = Pick<PreviewContext, 'card' | 'locale'>;
|
||||
|
||||
/** Longest value a row shows; URLs keep their start, so the host stays visible. */
|
||||
const MAX_VALUE_CHARS = 160;
|
||||
|
||||
const CHECKED = '✓';
|
||||
export const UNCHECKED = '✗';
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function id(args: Args, key: string): number | null {
|
||||
const value = args[key];
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function text(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'boolean') return value ? CHECKED : UNCHECKED;
|
||||
return clip(String(value));
|
||||
}
|
||||
|
||||
export class Rows {
|
||||
readonly rows: AssistantChangeRow[] = [];
|
||||
|
||||
add(labelKey: AssistantChangeLabelKey, after: string): this {
|
||||
if (after !== '') this.rows.push({ op: 'add', labelKey, before: null, after });
|
||||
return this;
|
||||
}
|
||||
|
||||
change(labelKey: AssistantChangeLabelKey, before: string | null, after: string): this {
|
||||
this.rows.push({ op: 'change', labelKey, before: before || null, after });
|
||||
return this;
|
||||
}
|
||||
|
||||
move(labelKey: AssistantChangeLabelKey, before: string, after: string): this {
|
||||
this.rows.push({ op: 'move', labelKey, before: before || null, after });
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
/** A locale Intl accepts, the user's when it can. */
|
||||
export function localeOf(ctx: Pick<PreviewContext, 'locale'>): string {
|
||||
try {
|
||||
return Intl.getCanonicalLocales(ctx.locale)[0] ?? 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDate(ctx: Pick<PreviewContext, 'locale'>, isoDate: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
||||
if (!match) return isoDate;
|
||||
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||||
return new Intl.DateTimeFormat(localeOf(ctx), {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
export function formatDateTime(ctx: Pick<PreviewContext, 'locale'>, value: string): string {
|
||||
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
|
||||
return match ? `${formatDate(ctx, match[1])}, ${match[2]}` : value;
|
||||
}
|
||||
|
||||
/** A day as the planner names it: its date (and title), or its title, or its number. */
|
||||
export function dayLabel(ctx: Naming, dayId: number | null): string {
|
||||
if (dayId === null) return '';
|
||||
const day = ctx.card.days.find((d) => d.id === dayId);
|
||||
if (!day) return `#${dayId}`;
|
||||
if (day.date) return day.title ? `${formatDate(ctx, day.date)}, ${day.title}` : formatDate(ctx, day.date);
|
||||
return day.title ?? `#${day.number}`;
|
||||
}
|
||||
|
||||
export function placeName(ctx: Pick<PreviewContext, 'card'>, placeId: number | null): string {
|
||||
if (placeId === null) return '';
|
||||
return ctx.card.places.get(placeId)?.name ?? `#${placeId}`;
|
||||
}
|
||||
|
||||
export function money(ctx: Pick<PreviewContext, 'locale'>, amount: number, currency: string | null): string {
|
||||
if (currency) {
|
||||
try {
|
||||
return new Intl.NumberFormat(localeOf(ctx), { style: 'currency', currency }).format(amount);
|
||||
} catch {
|
||||
return `${amount} ${currency}`;
|
||||
}
|
||||
}
|
||||
return new Intl.NumberFormat(localeOf(ctx)).format(amount);
|
||||
}
|
||||
|
||||
export function memberNames(ctx: Pick<PreviewContext, 'card'>, ids: unknown): string {
|
||||
if (!Array.isArray(ids)) return '';
|
||||
return clip(ids.map((memberId) => ctx.card.members.get(Number(memberId))?.name ?? `#${String(memberId)}`).join(', '));
|
||||
}
|
||||
|
||||
export function categoryName(ctx: Pick<PreviewContext, 'categories'>, categoryId: number | null): string {
|
||||
return categoryId === null ? '' : (ctx.categories.get(categoryId) ?? `#${categoryId}`);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { logWarn } from '../../audit/audit-log.logger';
|
||||
import type { TripCard } from '../assistant-context';
|
||||
import {
|
||||
ASSISTANT_SERVER_WRITE_TOOLS,
|
||||
CORE_ARG_POLICY,
|
||||
CORE_UNDO_MAP,
|
||||
CORE_WRITE_TOOLS,
|
||||
type AssistantWriteTool,
|
||||
} from '../assistant-tools.constants';
|
||||
import { ATLAS_CARDS } from './atlas';
|
||||
import { BOOKING_CARDS } from './booking';
|
||||
import { BUDGET_CARDS } from './budget';
|
||||
import type {
|
||||
AnswerInput,
|
||||
Described,
|
||||
DomainCards,
|
||||
PreviewContext,
|
||||
ToolArgs,
|
||||
ToolCards,
|
||||
ToolUndo,
|
||||
UndoSnapshot,
|
||||
} from './card-types';
|
||||
import { COLLAB_CARDS } from './collab';
|
||||
import { COLLECTION_CARDS } from './collection';
|
||||
import { JOURNEY_CARDS } from './journey';
|
||||
import { PACKING_CARDS } from './packing';
|
||||
import { ROADTRIP_CARDS } from './roadtrip';
|
||||
import { TOUR_CARDS } from './tour';
|
||||
import { VACAY_CARDS } from './vacay';
|
||||
import { idSchema, MAX_CARD_CHARS, trekCardSchema, type AssistantChangePreview, type TrekCard } from '@trek/shared';
|
||||
|
||||
import type { ZodType } from 'zod';
|
||||
|
||||
/**
|
||||
* Every package's cards, merged by tool name, and the calls the assistant
|
||||
* makes on them. A package claims the fields of the tools it knows; two
|
||||
* packages claiming one field of one tool, a package taking over a core
|
||||
* table's entry, or a describer without a verb stop the server at load,
|
||||
* before a turn could pick one of them at random. Every hook a package hands
|
||||
* in is guarded: one that throws is logged and says nothing, never a broken
|
||||
* turn, decision or undo.
|
||||
*/
|
||||
|
||||
/** The packages, in the order their prompt lines are told. */
|
||||
export const DOMAINS: readonly DomainCards[] = [
|
||||
TOUR_CARDS,
|
||||
ROADTRIP_CARDS,
|
||||
BUDGET_CARDS,
|
||||
BOOKING_CARDS,
|
||||
COLLAB_CARDS,
|
||||
COLLECTION_CARDS,
|
||||
ATLAS_CARDS,
|
||||
VACAY_CARDS,
|
||||
JOURNEY_CARDS,
|
||||
PACKING_CARDS,
|
||||
];
|
||||
|
||||
/** Prompt lines one package may add. */
|
||||
const MAX_PROMPT_LINES = 2;
|
||||
|
||||
type Undo = NonNullable<ToolCards['undo']>;
|
||||
type UndoReads = ReadonlyArray<{ tool: string; args: ToolArgs }>;
|
||||
|
||||
/** The registry the assistant reads, merged from the packages. */
|
||||
interface MergedCards {
|
||||
tools: Readonly<Record<string, ToolCards>>;
|
||||
writes: Readonly<Record<string, AssistantWriteTool>>;
|
||||
undo: Readonly<Record<string, ToolUndo>>;
|
||||
argPolicy: Readonly<Record<string, NonNullable<ToolCards['argPolicy']>>>;
|
||||
projections: Readonly<Record<string, (value: unknown) => unknown>>;
|
||||
promptLines: readonly string[];
|
||||
foreignIdGroups: ReadonlySet<string>;
|
||||
toolControllers: Readonly<Record<string, readonly string[]>>;
|
||||
heldReads: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
/** The core's own tables a package may not take an entry of. */
|
||||
interface CoreTables {
|
||||
writes: Readonly<Record<string, unknown>>;
|
||||
undo: Readonly<Record<string, unknown>>;
|
||||
argPolicy: Readonly<Record<string, unknown>>;
|
||||
}
|
||||
|
||||
const CORE: CoreTables = {
|
||||
writes: { ...CORE_WRITE_TOOLS, ...ASSISTANT_SERVER_WRITE_TOOLS },
|
||||
undo: CORE_UNDO_MAP,
|
||||
argPolicy: CORE_ARG_POLICY,
|
||||
};
|
||||
|
||||
/** One field of every tool that has it, by tool name. */
|
||||
function fieldOf<T>(tools: Readonly<Record<string, ToolCards>>, read: (cards: ToolCards) => T | undefined) {
|
||||
const found: Record<string, T> = {};
|
||||
for (const [tool, cards] of Object.entries(tools)) {
|
||||
const value = read(cards);
|
||||
if (value !== undefined) found[tool] = value;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
function reason(err: unknown): string {
|
||||
return err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
|
||||
/** `run`, or `fallback` and a log line when it throws. */
|
||||
function safely<T>(tool: string, what: string, fallback: T, run: () => T): T {
|
||||
try {
|
||||
return run();
|
||||
} catch (err) {
|
||||
logWarn(`[assistant] the ${what} of ${tool} failed: ${reason(err)}`);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function guardUndo(tool: string, undo: Undo): Undo {
|
||||
return {
|
||||
tool: undo.tool,
|
||||
args: (args: ToolArgs, result: unknown, preview: AssistantChangePreview | null) =>
|
||||
safely(tool, 'undo arguments', null, () => undo.args(args, result, preview)),
|
||||
...(undo.reads
|
||||
? {
|
||||
reads: (args: ToolArgs, result: unknown) =>
|
||||
safely<UndoReads>(tool, 'undo reads', [], () => undo.reads?.(args, result) ?? []),
|
||||
}
|
||||
: {}),
|
||||
unchanged: (result: unknown, snapshot: UndoSnapshot) =>
|
||||
safely(tool, 'undo check', false, () => undo.unchanged(result, snapshot)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* A package's entry with the hooks a turn, a decision or an undo calls
|
||||
* outside the card guard wrapped: one that throws is logged and reads as
|
||||
* saying nothing (the answer as it came, no effect, no ids, no way back,
|
||||
* "changed since"), never a broken turn or a write left unrecorded. Ids that
|
||||
* are no row ids are left out. The describer has its own guard, `describedOf`,
|
||||
* since the generic card stands in for it.
|
||||
*/
|
||||
function guardTool(tool: string, cards: ToolCards): ToolCards {
|
||||
return {
|
||||
...cards,
|
||||
...(cards.project
|
||||
? { project: (value: unknown) => safely(tool, 'projection', value, () => cards.project?.(value)) }
|
||||
: {}),
|
||||
...(cards.effect
|
||||
? {
|
||||
effect: (ctx: PreviewContext, args: ToolArgs, loaded: unknown) =>
|
||||
safely(tool, 'effect', null, () => cards.effect?.(ctx, args, loaded) ?? null),
|
||||
}
|
||||
: {}),
|
||||
...(cards.appliedIds
|
||||
? {
|
||||
appliedIds: (args: ToolArgs, result: unknown) =>
|
||||
safely<number[]>(tool, 'applied ids', [], () =>
|
||||
(cards.appliedIds?.(args, result) ?? []).filter((value) => idSchema.safeParse(value).success),
|
||||
),
|
||||
}
|
||||
: {}),
|
||||
...(cards.nameArg
|
||||
? {
|
||||
nameArg: (key: string, value: number, card: TripCard) =>
|
||||
safely(tool, `name of ${key}`, null, () => cards.nameArg?.(key, value, card) ?? null),
|
||||
}
|
||||
: {}),
|
||||
...(cards.undo ? { undo: guardUndo(tool, cards.undo) } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
/** Merges the packages' cards, refusing every overlap at load. */
|
||||
export function mergeDomains(domains: readonly DomainCards[], core: CoreTables = CORE): MergedCards {
|
||||
const tools: Record<string, ToolCards> = {};
|
||||
const owners = new Map<string, string>();
|
||||
const ids = new Set<string>();
|
||||
for (const domain of domains) {
|
||||
if (ids.has(domain.id)) throw new Error(`Assistant cards: package "${domain.id}" is registered twice`);
|
||||
ids.add(domain.id);
|
||||
if ((domain.prompt ?? []).length > MAX_PROMPT_LINES) {
|
||||
throw new Error(`Assistant cards: package "${domain.id}" adds more than ${MAX_PROMPT_LINES} prompt lines`);
|
||||
}
|
||||
for (const [tool, cards] of Object.entries(domain.tools)) {
|
||||
for (const field of Object.keys(cards)) {
|
||||
const owner = owners.get(`${tool}.${field}`);
|
||||
if (owner) throw new Error(`Assistant cards: "${field}" of "${tool}" is claimed by ${owner} and ${domain.id}`);
|
||||
owners.set(`${tool}.${field}`, domain.id);
|
||||
}
|
||||
tools[tool] = { ...tools[tool], ...guardTool(tool, cards) };
|
||||
}
|
||||
}
|
||||
for (const [field, table] of [
|
||||
['write', core.writes],
|
||||
['undo', core.undo],
|
||||
['argPolicy', core.argPolicy],
|
||||
] as const) {
|
||||
refuseCoreOverlap(field, Object.keys(table), tools);
|
||||
}
|
||||
// A curated card is a verb with its words: one without the other is half a card.
|
||||
for (const [tool, cards] of Object.entries(tools)) {
|
||||
if (!cards.describe !== !cards.write) {
|
||||
throw new Error(`Assistant cards: "${tool}" needs both a write entry and a describer, or neither`);
|
||||
}
|
||||
}
|
||||
return {
|
||||
tools,
|
||||
writes: { ...CORE_WRITE_TOOLS, ...fieldOf(tools, (cards) => cards.write) },
|
||||
undo: { ...CORE_UNDO_MAP, ...fieldOf(tools, (cards): ToolUndo | undefined => cards.undo) },
|
||||
argPolicy: { ...CORE_ARG_POLICY, ...fieldOf(tools, (cards) => cards.argPolicy) },
|
||||
projections: fieldOf(tools, (cards) =>
|
||||
cards.project ? (value: unknown): unknown => cards.project?.(value) : undefined,
|
||||
),
|
||||
promptLines: domains.flatMap((domain) => domain.prompt ?? []),
|
||||
foreignIdGroups: new Set(domains.flatMap((domain) => domain.foreignIdGroups ?? [])),
|
||||
toolControllers: controllersOf(domains),
|
||||
heldReads: new Set(domains.flatMap((domain) => domain.heldReads ?? [])),
|
||||
};
|
||||
}
|
||||
|
||||
/** The controllers the packages add, by tool group; one controller in two places is refused. */
|
||||
function controllersOf(domains: readonly DomainCards[]): Record<string, string[]> {
|
||||
const groups: Record<string, string[]> = {};
|
||||
const seen = new Set<string>();
|
||||
for (const domain of domains) {
|
||||
for (const [group, controllers] of Object.entries(domain.toolControllers ?? {})) {
|
||||
for (const controller of controllers) {
|
||||
if (seen.has(controller)) throw new Error(`Assistant cards: controller "${controller}" is listed twice`);
|
||||
seen.add(controller);
|
||||
(groups[group] ??= []).push(controller);
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuses a package entry for `field` on a tool the core already covers with
|
||||
* its own table (`names`): the core describers and effects, projections and
|
||||
* the like, which live beside the files that read them.
|
||||
*/
|
||||
export function refuseCoreOverlap(
|
||||
field: keyof ToolCards,
|
||||
names: Iterable<string>,
|
||||
tools: Readonly<Record<string, ToolCards>> = TOOL_CARDS,
|
||||
): void {
|
||||
for (const name of names) {
|
||||
if (tools[name]?.[field] !== undefined) {
|
||||
throw new Error(`Assistant cards: "${field}" of "${name}" is the core's own`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const MERGED = mergeDomains(DOMAINS);
|
||||
|
||||
export const TOOL_CARDS = MERGED.tools;
|
||||
/** Every writing tool with a card of its own: the core's and the packages'. */
|
||||
export const ASSISTANT_WRITE_TOOLS = MERGED.writes;
|
||||
export const ASSISTANT_UNDO_MAP = MERGED.undo;
|
||||
export const ASSISTANT_ARG_POLICY = MERGED.argPolicy;
|
||||
/** The packages' provider projections; the core's live in assistant-output.ts. */
|
||||
export const PROJECTIONS = MERGED.projections;
|
||||
/** The packages' lines for the standing instructions, in package order. */
|
||||
export const CARD_PROMPT_LINES = MERGED.promptLines;
|
||||
/** Tool groups whose place ids are no trip places. */
|
||||
export const FOREIGN_ID_GROUPS = MERGED.foreignIdGroups;
|
||||
/** The MCP controllers the packages add to the tool groups (assistant-tool-offer.ts merges them). */
|
||||
export const PACKAGE_TOOL_CONTROLLERS = MERGED.toolControllers;
|
||||
/** The packages' read-only tools that wait for the user like a change. */
|
||||
export const PACKAGE_HELD_READS = MERGED.heldReads;
|
||||
|
||||
/** How a card is checked before it goes out: the union, and the most its JSON may weigh. */
|
||||
export interface CardCheck {
|
||||
schema: ZodType<TrekCard>;
|
||||
maxChars: number;
|
||||
}
|
||||
|
||||
const UNION_CHECK: CardCheck = { schema: trekCardSchema, maxChars: MAX_CARD_CHARS };
|
||||
|
||||
/** The card checked against the union and the size cap; null, and a log line, for anything wrong. */
|
||||
function checked(tool: string, what: string, check: CardCheck, build: () => TrekCard | null): TrekCard | null {
|
||||
let card: TrekCard | null;
|
||||
try {
|
||||
card = build();
|
||||
} catch (err) {
|
||||
logWarn(`[assistant] the ${what} card of ${tool} failed: ${reason(err)}`);
|
||||
return null;
|
||||
}
|
||||
if (!card) return null;
|
||||
const parsed = check.schema.safeParse(card);
|
||||
if (!parsed.success) {
|
||||
const issue = parsed.error.issues[0];
|
||||
logWarn(
|
||||
`[assistant] the ${what} card of ${tool} does not fit its kind: ${issue?.path.join('.')} ${issue?.message}`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
const size = JSON.stringify(parsed.data).length;
|
||||
if (size > check.maxChars) {
|
||||
logWarn(`[assistant] the ${what} card of ${tool} is too large (${size} characters)`);
|
||||
return null;
|
||||
}
|
||||
return parsed.data;
|
||||
}
|
||||
|
||||
/** The guarded calls over one registry; a builder that throws or misbuilds gives no card, never a broken turn. */
|
||||
export function guardedCards(tools: Readonly<Record<string, ToolCards>>, check: CardCheck = UNION_CHECK) {
|
||||
return {
|
||||
answerCardOf(tool: string, input: AnswerInput): TrekCard | null {
|
||||
const cards = tools[tool];
|
||||
return cards?.answer ? checked(tool, 'answer', check, () => cards.answer?.(input) ?? null) : null;
|
||||
},
|
||||
changeCardOf(tool: string, ctx: PreviewContext, args: ToolArgs, loaded: unknown): TrekCard | null {
|
||||
const cards = tools[tool];
|
||||
return cards?.change ? checked(tool, 'change', check, () => cards.change?.(ctx, args, loaded) ?? null) : null;
|
||||
},
|
||||
appliedCardOf(
|
||||
tool: string,
|
||||
input: { args: ToolArgs; result: unknown; preview: AssistantChangePreview | null },
|
||||
): TrekCard | null {
|
||||
const cards = tools[tool];
|
||||
return cards?.applied ? checked(tool, 'applied', check, () => cards.applied?.(input) ?? null) : null;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const GUARDED = guardedCards(TOOL_CARDS);
|
||||
export const answerCardOf = GUARDED.answerCardOf;
|
||||
export const changeCardOf = GUARDED.changeCardOf;
|
||||
export const appliedCardOf = GUARDED.appliedCardOf;
|
||||
|
||||
/** What a package calls an id argument the trip card cannot name; null when it has no name for it. */
|
||||
export function nameArgOf(tool: string, key: string, value: number, card: TripCard): string | null {
|
||||
return TOOL_CARDS[tool]?.nameArg?.(key, value, card) ?? null;
|
||||
}
|
||||
|
||||
/** The reads an undo of `tool` makes before its check; none when its package asks for none or fails to say. */
|
||||
export function undoReadsOf(
|
||||
tool: string,
|
||||
args: ToolArgs,
|
||||
result: unknown,
|
||||
): UndoReads {
|
||||
return TOOL_CARDS[tool]?.undo?.reads?.(args, result) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* The words of a package's change card for `tool`; null when it has no
|
||||
* describer, or a log line and null when its describer throws, so the card
|
||||
* built from the arguments stands in.
|
||||
*/
|
||||
export function describedOf(tool: string, ctx: PreviewContext, args: ToolArgs, loaded: unknown): Described | null {
|
||||
const cards = TOOL_CARDS[tool];
|
||||
if (!cards?.describe) return null;
|
||||
return safely(tool, 'describer', null, () => cards.describe?.(ctx, args, loaded) ?? null);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
|
||||
/** The cards of the road trip, its drives, charging and transit. None yet: this package fills them in. */
|
||||
export const ROADTRIP_CARDS: DomainCards = { id: 'roadtrip', tools: {} };
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
import { TOUR_DESCRIBERS } from './tour-describe';
|
||||
import { DELETE_CREATED_TOUR } from './tour-undo';
|
||||
|
||||
/**
|
||||
* The tours in the assistant chat. A tour is a place with a route: its id is
|
||||
* its place's, so a created one is taken back by deleting that place.
|
||||
*/
|
||||
export const TOUR_CARDS: DomainCards = {
|
||||
id: 'tour',
|
||||
tools: {
|
||||
create_hike: {
|
||||
write: { verbKey: 'addTour', entity: 'tour' },
|
||||
describe: TOUR_DESCRIBERS.create_hike,
|
||||
undo: DELETE_CREATED_TOUR,
|
||||
},
|
||||
create_city_walk: {
|
||||
write: { verbKey: 'addTour', entity: 'tour' },
|
||||
describe: TOUR_DESCRIBERS.create_city_walk,
|
||||
undo: DELETE_CREATED_TOUR,
|
||||
},
|
||||
update_tour: { write: { verbKey: 'updateTour', entity: 'tour' }, describe: TOUR_DESCRIBERS.update_tour },
|
||||
import_tour_gpx: { write: { verbKey: 'importTour', entity: 'tour' }, describe: TOUR_DESCRIBERS.import_tour_gpx },
|
||||
},
|
||||
};
|
||||
+5
-10
@@ -1,5 +1,6 @@
|
||||
import { clip, id, Rows, text } from './preview-rows.helpers';
|
||||
import type { Describer, PreviewContext } from './tool-call-preview.helpers';
|
||||
import type { Described, PreviewContext, ToolArgs } from '../card-types';
|
||||
import { recordOf } from '../card-values';
|
||||
import { clip, id, Rows, text } from '../preview-format';
|
||||
import { TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
|
||||
|
||||
/**
|
||||
@@ -9,12 +10,6 @@ import { TOURS_MAX_HIKING_DIFFICULTY } from '@trek/shared';
|
||||
* grade (T1 to T6), a planned time in hours and minutes.
|
||||
*/
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
function recordOf(value: unknown): Args {
|
||||
return value && typeof value === 'object' && !Array.isArray(value) ? (value as Args) : {};
|
||||
}
|
||||
|
||||
/** A point's name: its own, else the name of the trip's place it stands for. */
|
||||
function pointName(ctx: PreviewContext, point: unknown): string {
|
||||
const record = recordOf(point);
|
||||
@@ -56,7 +51,7 @@ function gpxName(gpx: unknown): string {
|
||||
return close < 0 ? '' : text(gpx.slice(open + '<name>'.length, close));
|
||||
}
|
||||
|
||||
export const TOUR_DESCRIBERS: Readonly<Record<string, Describer>> = {
|
||||
export const TOUR_DESCRIBERS = {
|
||||
create_hike: (ctx, args) => {
|
||||
const rows = new Rows()
|
||||
.add('route', routeOf(ctx, args.waypoints, args.round_trip))
|
||||
@@ -91,4 +86,4 @@ export const TOUR_DESCRIBERS: Readonly<Record<string, Describer>> = {
|
||||
const rows = new Rows().add('type', text(args.tour_type));
|
||||
return { subject: text(args.file_name) || gpxName(args.gpx) || 'GPX', rows, dayId: null, placeIds: [] };
|
||||
},
|
||||
};
|
||||
} satisfies Readonly<Record<string, (ctx: PreviewContext, args: ToolArgs) => Described>>;
|
||||
@@ -0,0 +1,37 @@
|
||||
import type { ToolCards, UndoSnapshot } from '../card-types';
|
||||
import { recordOf } from '../card-values';
|
||||
import { byId, placeUnchanged, snapshotPlaces } from '../undo-snapshot';
|
||||
|
||||
/** The place id of a tour a create answered with (`{ tour: { place_id } }`). */
|
||||
function tourIdIn(value: unknown): number | null {
|
||||
const id = recordOf(recordOf(value).tour).place_id;
|
||||
return typeof id === 'number' && Number.isInteger(id) && id > 0 ? id : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A created tour, unchanged and on no day: its place still has the name and
|
||||
* description it was created with, has not been written since it was made,
|
||||
* and nothing plans, books or pays for it. Every write of a tour's route or
|
||||
* name (a change over MCP, the planner's save, a rename) stamps its place's
|
||||
* `updated_at`, which the create left equal to `created_at`; the revision is
|
||||
* not in the snapshot. A tour planned on a day since is kept too.
|
||||
*/
|
||||
function tourUnchanged(result: unknown, snapshot: UndoSnapshot): boolean {
|
||||
const tour = recordOf(recordOf(result).tour);
|
||||
const current = byId(snapshotPlaces(snapshot), tour.place_id);
|
||||
const untouched = typeof current?.created_at === 'string' && current.updated_at === current.created_at;
|
||||
return (
|
||||
untouched &&
|
||||
placeUnchanged({ place: { id: tour.place_id, name: tour.name, description: tour.description ?? null } }, snapshot)
|
||||
);
|
||||
}
|
||||
|
||||
/** A created tour taken back with its place, which takes its route and points along. */
|
||||
export const DELETE_CREATED_TOUR: NonNullable<ToolCards['undo']> = {
|
||||
tool: 'delete_place',
|
||||
args: (_args, result) => {
|
||||
const placeId = tourIdIn(result);
|
||||
return placeId ? { placeId } : null;
|
||||
},
|
||||
unchanged: tourUnchanged,
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import type { UndoSnapshot } from './card-types';
|
||||
import { recordOf, type Row } from './card-values';
|
||||
|
||||
/**
|
||||
* The pieces an undo check reads the trip with, for the core checks
|
||||
* (assistant-undo.helpers.ts) and every package's own `undo.unchanged`. An
|
||||
* undo removes the created row with everything that hangs off it, so a check
|
||||
* holds that row to what the change created and asks whether anything else
|
||||
* has come to depend on it since.
|
||||
*/
|
||||
|
||||
type TripReads = Pick<UndoSnapshot, 'summary' | 'places'>;
|
||||
|
||||
export function rowsOf(value: unknown): Row[] {
|
||||
return Array.isArray(value) ? value.map(recordOf) : [];
|
||||
}
|
||||
|
||||
/** The fields that say a row is still the one that was created; a field either side lacks is not compared. */
|
||||
export const STABLE_FIELDS = {
|
||||
place: ['name', 'updated_at', 'lat', 'lng', 'address', 'category_id', 'description', 'notes'],
|
||||
assignment: ['day_id', 'place_id'],
|
||||
note: ['text', 'time', 'icon', 'color'],
|
||||
packing: ['name', 'updated_at', 'checked', 'quantity', 'category'],
|
||||
todo: ['name', 'checked', 'due_date', 'description', 'priority', 'assigned_user_id', 'category'],
|
||||
budget: ['name', 'total_price', 'currency', 'category', 'note', 'expense_date'],
|
||||
} as const;
|
||||
|
||||
export function same(created: Row, current: Row | undefined, fields: readonly string[]): boolean {
|
||||
if (!current) return false;
|
||||
return fields.every((field) => !(field in created) || !(field in current) || created[field] === current[field]);
|
||||
}
|
||||
|
||||
export function byId(rows: Row[], id: unknown): Row | undefined {
|
||||
return rows.find((row) => row.id === id);
|
||||
}
|
||||
|
||||
export function snapshotDays(snapshot: TripReads): Row[] {
|
||||
return rowsOf(recordOf(snapshot.summary).days);
|
||||
}
|
||||
|
||||
export function snapshotAssignments(snapshot: TripReads): Row[] {
|
||||
return snapshotDays(snapshot).flatMap((day) => rowsOf(day.assignments));
|
||||
}
|
||||
|
||||
/** The trip's places as `list_places` answered. */
|
||||
export function snapshotPlaces(snapshot: TripReads): Row[] {
|
||||
return rowsOf(recordOf(snapshot.places).places);
|
||||
}
|
||||
|
||||
/** A created place, unchanged and still only where the change put it: on no other day, in no booking or expense. */
|
||||
export function placeUnchanged(result: Row, snapshot: TripReads): boolean {
|
||||
const created = recordOf(result.place);
|
||||
const current = byId(snapshotPlaces(snapshot), created.id);
|
||||
if (!same(created, current, STABLE_FIELDS.place)) return false;
|
||||
const ownAssignment = recordOf(result.assignment).id;
|
||||
const plannedElsewhere = snapshotAssignments(snapshot).some(
|
||||
(a) => a.place_id === created.id && a.id !== ownAssignment,
|
||||
);
|
||||
const summary = recordOf(snapshot.summary);
|
||||
const referenced = [
|
||||
...rowsOf(summary.reservations),
|
||||
...rowsOf(summary.accommodations),
|
||||
...rowsOf(recordOf(summary.budget).items),
|
||||
].some((row) => row.place_id === created.id);
|
||||
return !plannedElsewhere && !referenced;
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import type { DomainCards } from '../card-types';
|
||||
|
||||
/** The cards of the vacation planner. None yet: this package fills them in. */
|
||||
export const VACAY_CARDS: DomainCards = { id: 'vacay', tools: {} };
|
||||
@@ -1,51 +0,0 @@
|
||||
import type { AssistantChangeLabelKey, AssistantChangeRow } from '@trek/shared';
|
||||
|
||||
/**
|
||||
* The pieces every change card is written with: a value cut to what a row
|
||||
* shows, an argument read as an id, and the rows themselves. Shared by the
|
||||
* cards of the trip's own tools (tool-call-preview.helpers.ts) and of the
|
||||
* tours (tour-call-preview.helpers.ts).
|
||||
*/
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/** Longest value a row shows; URLs keep their start, so the host stays visible. */
|
||||
const MAX_VALUE_CHARS = 160;
|
||||
|
||||
const CHECKED = '✓';
|
||||
export const UNCHECKED = '✗';
|
||||
|
||||
export 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;
|
||||
}
|
||||
|
||||
export function id(args: Args, key: string): number | null {
|
||||
const value = args[key];
|
||||
return typeof value === 'number' && Number.isInteger(value) && value > 0 ? value : null;
|
||||
}
|
||||
|
||||
export function text(value: unknown): string {
|
||||
if (value === null || value === undefined) return '';
|
||||
if (typeof value === 'boolean') return value ? CHECKED : UNCHECKED;
|
||||
return clip(String(value));
|
||||
}
|
||||
|
||||
export class Rows {
|
||||
readonly rows: AssistantChangeRow[] = [];
|
||||
|
||||
add(labelKey: AssistantChangeLabelKey, after: string): this {
|
||||
if (after !== '') this.rows.push({ op: 'add', labelKey, before: null, after });
|
||||
return this;
|
||||
}
|
||||
|
||||
change(labelKey: AssistantChangeLabelKey, before: string | null, after: string): this {
|
||||
this.rows.push({ op: 'change', labelKey, before: before || null, after });
|
||||
return this;
|
||||
}
|
||||
|
||||
move(labelKey: AssistantChangeLabelKey, before: string, after: string): this {
|
||||
this.rows.push({ op: 'move', labelKey, before: before || null, after });
|
||||
return this;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { TripCard } from './assistant-context';
|
||||
import type { ToolHint } from './assistant-tools.service';
|
||||
import { FOREIGN_ID_GROUPS, nameArgOf } from './cards/registry';
|
||||
import type {
|
||||
AssistantChangeLabelKey,
|
||||
AssistantChangePreview,
|
||||
@@ -15,11 +16,21 @@ import type {
|
||||
* the labels the other cards use (time, end, notes, day …), with ids named and
|
||||
* lists of ids read as the names in their order. The verb comes from what the
|
||||
* tool says it does; the client names the tool's action where it knows it. It
|
||||
* is never undoable: only the tools with a matching removal are.
|
||||
* is never undoable: only the tools with a matching removal are. A package
|
||||
* names the ids the trip card cannot (`nameArg`), and the place ids of a tool
|
||||
* that works outside the trip (a collection's, the atlas') are never read as
|
||||
* the trip's places.
|
||||
*/
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
/** What naming one tool's arguments needs: the trip, the tool, and whether its place ids are the trip's. */
|
||||
interface Naming {
|
||||
card: TripCard;
|
||||
toolName: string;
|
||||
foreign: boolean;
|
||||
}
|
||||
|
||||
const MAX_ROWS = 12;
|
||||
const MAX_VALUE_CHARS = 160;
|
||||
const NAME_KEYS = ['name', 'title', 'text', 'label', 'question', 'content', 'region', 'country_code'] as const;
|
||||
@@ -98,35 +109,37 @@ function dayName(card: TripCard, dayId: number): string | null {
|
||||
}
|
||||
|
||||
/** What one id argument names in the trip: a place, a day, a note, a booking, an item, a person. */
|
||||
function nameOf(card: TripCard, key: string, value: number, toolName: string): string | null {
|
||||
function nameOf({ card, toolName, foreign }: Naming, key: string, value: number): string | null {
|
||||
const named = nameArgOf(toolName, key, value, card);
|
||||
if (named !== null) return named;
|
||||
const k = key.toLowerCase().replace(/_/g, '');
|
||||
if (k === 'assignmentid') {
|
||||
const assignment = card.assignments.get(value);
|
||||
return assignment ? (card.places.get(assignment.placeId)?.name ?? null) : null;
|
||||
}
|
||||
if (k === 'placeid' || k === 'sourceplaceid') return card.places.get(value)?.name ?? null;
|
||||
if (k === 'placeid' || k === 'sourceplaceid') return foreign ? null : (card.places.get(value)?.name ?? null);
|
||||
if (k === 'dayid' || k === 'enddayid' || k === 'startdayid') return dayName(card, value);
|
||||
if (k === 'noteid') return card.notes.get(value)?.text ?? null;
|
||||
if (k === 'reservationid' || k === 'accommodationid') return card.reservations.get(value)?.title ?? null;
|
||||
if (k === 'itemid' && toolName.includes('packing')) return card.packing.get(value)?.name ?? null;
|
||||
if (k === 'userid' || k === 'memberid' || k === 'assigneduserid') return card.members.get(value) ?? null;
|
||||
if (k === 'userid' || k === 'memberid' || k === 'assigneduserid') return card.members.get(value)?.name ?? null;
|
||||
return null;
|
||||
}
|
||||
|
||||
/** An id list in its order, named where the card knows the ids (a new order of a day's places). */
|
||||
function namesOf(card: TripCard, key: string, values: unknown[], toolName: string): string | null {
|
||||
function namesOf(naming: Naming, key: string, values: unknown[]): string | null {
|
||||
const singular = key.replace(/(Ids|_ids)$/, (m) => (m === 'Ids' ? 'Id' : '_id'));
|
||||
const names = values.map((v) => (typeof v === 'number' ? nameOf(card, singular, v, toolName) : null));
|
||||
const names = values.map((v) => (typeof v === 'number' ? nameOf(naming, singular, v) : null));
|
||||
return names.some((n) => n === null) ? null : names.join(', ');
|
||||
}
|
||||
|
||||
function valueText(card: TripCard, key: string, value: unknown, toolName: string): string {
|
||||
function valueText(naming: Naming, key: string, value: unknown): string {
|
||||
if (typeof value === 'number') {
|
||||
const name = nameOf(card, key, value, toolName);
|
||||
const name = nameOf(naming, key, value);
|
||||
if (name) return clip(name);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
const names = namesOf(card, key, value, toolName);
|
||||
const names = namesOf(naming, key, value);
|
||||
if (names) return clip(names);
|
||||
return clip(value.map((item) => (typeof item === 'object' ? JSON.stringify(item) : String(item))).join(', '));
|
||||
}
|
||||
@@ -136,31 +149,31 @@ function valueText(card: TripCard, key: string, value: unknown, toolName: string
|
||||
}
|
||||
|
||||
/** The subject: what the tool is given a name for, else the first thing of the trip it works on. */
|
||||
function subjectOf(card: TripCard, args: Args, toolName: string): { text: string; key: string | null } {
|
||||
function subjectOf(naming: Naming, args: Args): { text: string; key: string | null } {
|
||||
const named = NAME_KEYS.find((key) => typeof args[key] === 'string' && (args[key] as string).trim());
|
||||
if (named) return { text: String(args[named]), key: named };
|
||||
const order = ['assignmentId', 'placeId', 'noteId', 'reservationId', 'itemId', 'dayId', 'day_id'];
|
||||
for (const key of order) {
|
||||
const value = args[key];
|
||||
const name = typeof value === 'number' ? nameOf(card, key, value, toolName) : null;
|
||||
const name = typeof value === 'number' ? nameOf(naming, key, value) : null;
|
||||
if (name) return { text: name, key };
|
||||
}
|
||||
const list = Object.entries(args).find(([key, value]) => /(Ids|_ids)$/.test(key) && Array.isArray(value));
|
||||
if (list) {
|
||||
const names = namesOf(card, list[0], list[1] as unknown[], toolName);
|
||||
const names = namesOf(naming, list[0], list[1] as unknown[]);
|
||||
if (names) return { text: names, key: list[0] };
|
||||
}
|
||||
return { text: humanToolName(toolName), key: null };
|
||||
return { text: humanToolName(naming.toolName), key: null };
|
||||
}
|
||||
|
||||
function rowOf(card: TripCard, key: string, value: unknown, toolName: string): AssistantChangeRow {
|
||||
function rowOf(naming: Naming, key: string, value: unknown): AssistantChangeRow {
|
||||
const labelKey = LABEL_OF[key] ?? 'field';
|
||||
return {
|
||||
op: 'add',
|
||||
labelKey,
|
||||
...(labelKey === 'field' ? { field: key.slice(0, 60) } : {}),
|
||||
before: null,
|
||||
after: valueText(card, key, value, toolName),
|
||||
after: valueText(naming, key, value),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,12 +184,13 @@ export function describeGeneric(
|
||||
card: TripCard,
|
||||
hint: ToolHint | null,
|
||||
): AssistantChangePreview {
|
||||
const subject = subjectOf(card, args, toolName);
|
||||
const naming: Naming = { card, toolName, foreign: !!hint?.group && FOREIGN_ID_GROUPS.has(hint.group) };
|
||||
const subject = subjectOf(naming, args);
|
||||
const rows = Object.entries(args)
|
||||
.filter(([key]) => key !== subject.key && !HIDDEN_KEYS.has(key))
|
||||
.filter(([, value]) => value !== undefined && value !== null && value !== '')
|
||||
.slice(0, MAX_ROWS)
|
||||
.map(([key, value]) => rowOf(card, key, value, toolName));
|
||||
.map(([key, value]) => rowOf(naming, key, value));
|
||||
const dayArg = args.dayId ?? args.day_id;
|
||||
const assignment = typeof args.assignmentId === 'number' ? card.assignments.get(args.assignmentId) : undefined;
|
||||
const dayId = typeof dayArg === 'number' ? dayArg : (assignment?.dayId ?? null);
|
||||
@@ -187,7 +201,7 @@ export function describeGeneric(
|
||||
subject: clip(subject.text),
|
||||
rows,
|
||||
dayId: dayId !== null && card.days.some((d) => d.id === dayId) ? dayId : null,
|
||||
placeIds: placeId !== null && card.places.has(placeId) ? [placeId] : [],
|
||||
placeIds: placeId !== null && !naming.foreign && card.places.has(placeId) ? [placeId] : [],
|
||||
undoable: false,
|
||||
effect: null,
|
||||
tool: toolName,
|
||||
|
||||
@@ -1,17 +1,36 @@
|
||||
import type { CardPlace, TripCard } from './assistant-context';
|
||||
import type { CardPlace } from './assistant-context';
|
||||
import { noteOf } from './assistant-memory';
|
||||
import {
|
||||
ASSISTANT_SERVER_WRITE_TOOLS,
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
IMPORT_BOOKING_TOOL,
|
||||
REMEMBER_TOOL,
|
||||
writeToolOf,
|
||||
} from './assistant-tools.constants';
|
||||
import type { ToolHint } from './assistant-tools.service';
|
||||
import { clip, id, Rows, text, UNCHECKED } from './preview-rows.helpers';
|
||||
import type { Described, PreviewContext } from './cards/card-types';
|
||||
import {
|
||||
categoryName,
|
||||
clip,
|
||||
dayLabel,
|
||||
formatDate,
|
||||
formatDateTime,
|
||||
id,
|
||||
localeOf,
|
||||
memberNames,
|
||||
money,
|
||||
placeName,
|
||||
Rows,
|
||||
text,
|
||||
UNCHECKED,
|
||||
} from './cards/preview-format';
|
||||
import {
|
||||
ASSISTANT_UNDO_MAP,
|
||||
ASSISTANT_WRITE_TOOLS,
|
||||
changeCardOf,
|
||||
describedOf,
|
||||
refuseCoreOverlap,
|
||||
TOOL_CARDS,
|
||||
} from './cards/registry';
|
||||
import { describeGeneric } from './tool-call-generic.helpers';
|
||||
import { TOUR_DESCRIBERS } from './tour-call-preview.helpers';
|
||||
import type {
|
||||
AssistantApplied,
|
||||
AssistantChangeEffect,
|
||||
@@ -23,76 +42,18 @@ import type {
|
||||
* The change card of a held tool call: what it does and to what, in plain
|
||||
* words. Days, places, categories and people are named instead of numbered,
|
||||
* dates and amounts are written in the user's locale, and an update shows
|
||||
* what the field held before.
|
||||
* what the field held before. A package's cards (cards/registry.ts) add the
|
||||
* describers, effects and drawn cards of their tools.
|
||||
*/
|
||||
|
||||
type Args = Record<string, unknown>;
|
||||
|
||||
export interface PreviewContext {
|
||||
card: TripCard;
|
||||
categories: ReadonlyMap<number, string>;
|
||||
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;
|
||||
}
|
||||
|
||||
const MAX_ROWS = 12;
|
||||
|
||||
function has(args: Args, key: string): boolean {
|
||||
return args[key] !== undefined;
|
||||
}
|
||||
|
||||
/** A locale Intl accepts, the user's when it can. */
|
||||
function localeOf(ctx: PreviewContext): string {
|
||||
try {
|
||||
return Intl.getCanonicalLocales(ctx.locale)[0] ?? 'en';
|
||||
} catch {
|
||||
return 'en';
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(ctx: PreviewContext, isoDate: string): string {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(isoDate);
|
||||
if (!match) return isoDate;
|
||||
const date = new Date(Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])));
|
||||
return new Intl.DateTimeFormat(localeOf(ctx), {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function formatDateTime(ctx: PreviewContext, value: string): string {
|
||||
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/.exec(value);
|
||||
return match ? `${formatDate(ctx, match[1])}, ${match[2]}` : value;
|
||||
}
|
||||
|
||||
/** A day as the planner names it: its date (and title), or its title, or its number. */
|
||||
export function dayLabel(ctx: PreviewContext, dayId: number | null): string {
|
||||
if (dayId === null) return '';
|
||||
const day = ctx.card.days.find((d) => d.id === dayId);
|
||||
if (!day) return `#${dayId}`;
|
||||
if (day.date) return day.title ? `${formatDate(ctx, day.date)}, ${day.title}` : formatDate(ctx, day.date);
|
||||
return day.title ?? `#${day.number}`;
|
||||
}
|
||||
|
||||
function placeName(ctx: PreviewContext, placeId: number | null): string {
|
||||
if (placeId === null) return '';
|
||||
return ctx.card.places.get(placeId)?.name ?? `#${placeId}`;
|
||||
}
|
||||
|
||||
function money(ctx: PreviewContext, amount: number, currency: string | null): string {
|
||||
if (currency) {
|
||||
try {
|
||||
return new Intl.NumberFormat(localeOf(ctx), { style: 'currency', currency }).format(amount);
|
||||
} catch {
|
||||
return `${amount} ${currency}`;
|
||||
}
|
||||
}
|
||||
return new Intl.NumberFormat(localeOf(ctx)).format(amount);
|
||||
}
|
||||
|
||||
function openingHours(ctx: PreviewContext, value: unknown): string {
|
||||
if (!Array.isArray(value)) return '';
|
||||
const weekday = new Intl.DateTimeFormat(localeOf(ctx), { weekday: 'short', timeZone: 'UTC' });
|
||||
@@ -107,20 +68,11 @@ function openingHours(ctx: PreviewContext, value: unknown): string {
|
||||
);
|
||||
}
|
||||
|
||||
function memberNames(ctx: PreviewContext, ids: unknown): string {
|
||||
if (!Array.isArray(ids)) return '';
|
||||
return clip(ids.map((memberId) => ctx.card.members.get(Number(memberId)) ?? `#${String(memberId)}`).join(', '));
|
||||
}
|
||||
|
||||
function coordinates(args: Args): string {
|
||||
const { lat, lng } = args;
|
||||
return typeof lat === 'number' && typeof lng === 'number' ? `${lat.toFixed(5)}, ${lng.toFixed(5)}` : '';
|
||||
}
|
||||
|
||||
function categoryName(ctx: PreviewContext, categoryId: number | null): string {
|
||||
return categoryId === null ? '' : (ctx.categories.get(categoryId) ?? `#${categoryId}`);
|
||||
}
|
||||
|
||||
/** The fields a new place carries, shared by both ways of creating one. */
|
||||
function newPlaceRows(ctx: PreviewContext, args: Args, rows: Rows, notesKey: string): void {
|
||||
rows
|
||||
@@ -227,14 +179,7 @@ function describeBooking(ctx: PreviewContext, args: Args): Described {
|
||||
return { subject: text(args.title), rows, dayId, placeIds: [] };
|
||||
}
|
||||
|
||||
interface Described {
|
||||
subject: string;
|
||||
rows: Rows;
|
||||
dayId: number | null;
|
||||
placeIds: number[];
|
||||
}
|
||||
|
||||
export type Describer = (ctx: PreviewContext, args: Args) => Described;
|
||||
type Describer = (ctx: PreviewContext, args: Args) => Described;
|
||||
|
||||
/** Who shares an expense: the uneven split's members, else the equal one's. */
|
||||
function splitOf(args: Args): unknown {
|
||||
@@ -391,7 +336,6 @@ const DESCRIBERS: Readonly<Record<string, Describer>> = {
|
||||
};
|
||||
},
|
||||
[IMPORT_BOOKING_TOOL]: describeBooking,
|
||||
...TOUR_DESCRIBERS,
|
||||
};
|
||||
|
||||
/** A held write of the model's tools or of the server's own (a booking from a file). */
|
||||
@@ -445,34 +389,52 @@ const EFFECTS: Readonly<Record<string, (ctx: PreviewContext, args: Args) => Assi
|
||||
},
|
||||
};
|
||||
|
||||
// A tool's words and its effect come from the core or from its package, never from both.
|
||||
refuseCoreOverlap('describe', Object.keys(DESCRIBERS));
|
||||
refuseCoreOverlap('effect', Object.keys(EFFECTS));
|
||||
|
||||
/**
|
||||
* The change card for a held call of one of the writing tools, or of a
|
||||
* change the server proposed itself (a booking from a file, a found place).
|
||||
* `args` are the arguments as stored (the trip id is not among them). A found
|
||||
* place reads as the tool it runs through.
|
||||
* place reads as the tool it runs through. `loaded` is what the tool's
|
||||
* package looked up for it (card-context.ts), null when it looked up nothing.
|
||||
*/
|
||||
export function describeToolCall(
|
||||
toolName: string,
|
||||
toolCallId: string,
|
||||
args: Args,
|
||||
ctx: PreviewContext,
|
||||
loaded: unknown = null,
|
||||
): AssistantChangePreview {
|
||||
const runs = writeToolOf(toolName, args);
|
||||
const tool = writeTool(runs);
|
||||
const describe = DESCRIBERS[runs];
|
||||
const cards = TOOL_CARDS[runs];
|
||||
const describe: ((c: PreviewContext, a: Args) => Described | null) | undefined = cards?.describe
|
||||
? (c, a) => describedOf(runs, c, a, loaded)
|
||||
: DESCRIBERS[runs];
|
||||
// The server's own steps all have a card; one without is a call that names no real tool.
|
||||
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);
|
||||
const card = changeCardOf(runs, ctx, args, loaded);
|
||||
const drawn = card ? { card } : {};
|
||||
// A package describer that failed gives way to the card built from the arguments.
|
||||
const described = tool && describe ? describe(ctx, args) : null;
|
||||
if (!tool || !described) {
|
||||
return { ...describeGeneric(runs, toolCallId, args, ctx.card, ctx.hintOf?.(runs) ?? null), ...drawn };
|
||||
}
|
||||
const planned = cards?.effect ? cards.effect(ctx, args, loaded) : EFFECTS[runs]?.(ctx, args);
|
||||
return {
|
||||
toolCallId,
|
||||
verbKey: tool.verbKey,
|
||||
verbKey: described.verbKey ?? tool.verbKey,
|
||||
subject: clip(described.subject),
|
||||
rows: described.rows.rows.slice(0, MAX_ROWS),
|
||||
dayId: described.dayId,
|
||||
placeIds: described.placeIds,
|
||||
undoable: runs in ASSISTANT_UNDO_MAP,
|
||||
effect: EFFECTS[runs]?.(ctx, args) ?? null,
|
||||
effect: planned ?? null,
|
||||
...(described.tool ? { tool: described.tool } : {}),
|
||||
...(described.domain ? { domain: described.domain } : {}),
|
||||
...drawn,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -525,8 +487,10 @@ export function appliedChange(
|
||||
undoUntil: string | null,
|
||||
): AssistantApplied | 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 created = createdIds(result);
|
||||
const runs = writeToolOf(toolName, args);
|
||||
const tool = writeTool(runs) ?? { entity: 'other' as const };
|
||||
const own = TOOL_CARDS[runs]?.appliedIds?.(args, result) ?? [];
|
||||
const created = own.length > 0 ? own : createdIds(result);
|
||||
const named = ARG_KEYS.map((key) => id(args, key)).filter((value): value is number => value !== null);
|
||||
return {
|
||||
toolCallId,
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
UI_STREAM_DONE,
|
||||
type AssistantActivity,
|
||||
type AssistantApplied,
|
||||
type AssistantCard,
|
||||
type AssistantChangePreview,
|
||||
type AssistantErrorCode,
|
||||
type AssistantPlaces,
|
||||
@@ -96,6 +97,11 @@ export class UiStreamWriter {
|
||||
this.send({ type: 'data-trek-weather', data: weather });
|
||||
}
|
||||
|
||||
/** The card of one tool call: a read's beside the answer, an applied write's beside its receipt. */
|
||||
card(data: AssistantCard): void {
|
||||
this.send({ type: 'data-trek-card', data });
|
||||
}
|
||||
|
||||
/** Where a step that is about to run looks; only streamed, the message never keeps it. */
|
||||
activity(activity: AssistantActivity): void {
|
||||
this.endText();
|
||||
|
||||
Reference in New Issue
Block a user