feat: add ship tools

Add vessels_in_area, find_vessel and get_vessel_track over the live
vessel source, and a ships section in situation_brief. Without an AIS
key the source's own message reaches clients as an unavailable feed.
This commit is contained in:
Sameh Khamis committed 2026-10-01 12:32:09 -07:00
1 parent 0a74e36ce8
commit e6cbcc31ad
10 files changed
+393 -5

No files matched your search

+7 -1
View File
@@ -5,6 +5,7 @@ import { AREA_SCHEMA, areaCenter, resolveArea } from '../area.js';
import { aircraftInArea } from './aviation.js';
import { getCyclones, getWeather } from './environment.js';
import { getActiveFires, getEarthquakes } from './hazards.js';
import { vesselsInArea } from './maritime.js';
const SECTION_LIMIT = 5;
@@ -32,6 +33,11 @@ const SECTIONS = [
tool: aircraftInArea,
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
},
{
key: 'vessels',
tool: vesselsInArea,
args: (area) => ({ area: area.argument, limit: SECTION_LIMIT }),
},
{
key: 'cyclones',
tool: getCyclones,
@@ -91,7 +97,7 @@ export const situationBrief = defineTool({
title: 'Situation brief',
description:
'One overview of an area: current weather, recent earthquakes, active ' +
'fires, aircraft overhead and tropical cyclones, each summarized with ' +
'fires, aircraft overhead, ships and tropical cyclones, each summarized with ' +
'its top items. Sections that are unavailable are marked as such.',
inputSchema: {
type: 'object',
+182
View File
@@ -0,0 +1,182 @@
/** Maritime queries over the live vessel source (src/sources/live). */
import { defineTool, ToolError } from '../catalog.js';
import {
AREA_SCHEMA,
areaCenter,
areaContains,
distanceKm,
resolveArea,
} from '../area.js';
import {
LIMIT_SCHEMA,
capRows,
countNoun,
isoTime,
thinEvenly,
} from '../results.js';
const MMSI_SCHEMA = Object.freeze({
type: 'string',
pattern: '^\\d{5,10}$',
description: 'Maritime Mobile Service Identity, such as "366999712".',
});
const MAX_TRACK_POINTS = 200;
const round = (value, digits) =>
Number.isFinite(value) ? Number(value.toFixed(digits)) : null;
const text = (value) => (typeof value === 'string' && value ? value : null);
function vesselRow(record, from) {
const point = { lat: record.latitude, lon: record.longitude };
return {
mmsi: record.id,
name: text(record.name),
imo: text(record.imo),
type: text(record.type),
destination: text(record.destination),
lat: round(record.latitude, 5),
lon: round(record.longitude, 5),
speed_mps: round(record.speedMps, 1),
course_deg: round(record.courseDeg, 0),
heading_deg: round(record.headingDeg, 0),
observed_at: isoTime(record.observedAtMs),
...(from ? { distance_km: round(distanceKm(from, point), 1) } : {}),
};
}
function snapshotInfo(snapshot) {
return {
source: snapshot.source ?? null,
coverage: snapshot.coverage ?? null,
observed_at: isoTime(snapshot.observedAtMs),
freshness: snapshot.freshness ?? 'unknown',
};
}
export const vesselsInArea = defineTool({
name: 'vessels_in_area',
title: 'Vessels in an area',
description:
'Ships currently reported by AIS inside an area, nearest the center ' +
'first, optionally filtered by vessel type.',
inputSchema: {
type: 'object',
properties: {
area: AREA_SCHEMA,
type: {
type: 'string',
minLength: 1,
maxLength: 40,
description: 'Vessel type to match, such as "tanker" or "cargo".',
},
limit: LIMIT_SCHEMA,
},
required: ['area'],
additionalProperties: false,
},
requires: ['vessels'],
async run(args, { services, signal }) {
const area = await resolveArea(args.area, { services, signal });
const center = areaCenter(area);
const snapshot = await services.vessels.getSnapshot({}, { signal });
const wanted = args.type?.toLowerCase();
const rows = snapshot.records
.filter(
(record) =>
areaContains(area, { lat: record.latitude, lon: record.longitude }) &&
(!wanted || String(record.type).toLowerCase().includes(wanted)),
)
.map((record) => vesselRow(record, center))
.sort((a, b) => a.distance_km - b.distance_km);
const kind = args.type ? `${args.type} vessel` : 'vessel';
return {
summary:
`${countNoun(rows.length, kind)} in ${area.label}` +
(snapshot.freshness === 'stale' ? ' (data may be stale).' : '.'),
data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) },
};
},
});
export const findVessel = defineTool({
name: 'find_vessel',
title: 'Find a vessel',
description:
'Find ships currently reported anywhere by MMSI, IMO number or name. ' +
'Names match when they contain the given text.',
inputSchema: {
type: 'object',
properties: {
mmsi: MMSI_SCHEMA,
imo: { type: 'string', pattern: '^\\d{7}$' },
name: { type: 'string', minLength: 2, maxLength: 60 },
limit: LIMIT_SCHEMA,
},
additionalProperties: false,
},
requires: ['vessels'],
async run(args, { services, signal }) {
const given = ['mmsi', 'imo', 'name'].filter((key) => args[key] != null);
if (given.length !== 1)
throw new ToolError(
'invalid_arguments',
'Give exactly one of mmsi, imo or name',
);
const [key] = given;
const wanted = args[key].trim().toUpperCase();
const matches = {
mmsi: (record) => record.id === wanted,
imo: (record) => String(record.imo).replace(/^IMO/i, '') === wanted,
name: (record) => String(record.name).toUpperCase().includes(wanted),
}[key];
const snapshot = await services.vessels.getSnapshot({}, { signal });
const rows = snapshot.records
.filter(matches)
.map((record) => vesselRow(record));
return {
summary: rows.length
? `Found ${countNoun(rows.length, 'vessel')} with ${key} ${args[key]}.`
: `No vessel with ${key} ${args[key]} is currently reported.`,
data: { ...capRows(rows, args.limit), ...snapshotInfo(snapshot) },
};
},
});
export const getVesselTrack = defineTool({
name: 'get_vessel_track',
title: 'Vessel track',
description:
'Recent positions of one ship by MMSI, oldest first. Long tracks are ' +
`evenly thinned to at most ${MAX_TRACK_POINTS} points.`,
inputSchema: {
type: 'object',
properties: { mmsi: MMSI_SCHEMA },
required: ['mmsi'],
additionalProperties: false,
},
requires: ['vessels'],
async run(args, { services, signal }) {
const track = await services.vessels.getTrack(args.mmsi, { signal });
const points = track.records
.filter((point) => Number.isFinite(point.observedAtMs))
.sort((a, b) => a.observedAtMs - b.observedAtMs);
const kept = thinEvenly(points, MAX_TRACK_POINTS);
return {
summary: points.length
? `${countNoun(points.length, 'position')} for ${args.mmsi} from ${isoTime(points[0].observedAtMs)} to ${isoTime(points.at(-1).observedAtMs)}.`
: `No recent track is available for ${args.mmsi}.`,
data: {
mmsi: args.mmsi,
total: points.length,
returned: kept.length,
thinned: kept.length < points.length,
points: kept.map((point) => ({
time: isoTime(point.observedAtMs),
lat: round(point.latitude, 5),
lon: round(point.longitude, 5),
})),
},
};
},
});
+181
View File
@@ -0,0 +1,181 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { composeCatalog, coreTools } from '../index.js';
import { LiveSourceError } from '../../sources/live/contract.js';
const vessel = (id, latitude, longitude, extra = {}) => ({
id,
reference: id,
latitude,
longitude,
name: `SHIP ${id}`,
imo: '',
type: 'Cargo',
destination: '',
speedMps: 6.17,
courseDeg: 181.2,
headingDeg: 180,
observedAtMs: Date.UTC(2026, 0, 1),
altitudeDatum: 'sea-surface',
...extra,
});
const records = [
vessel('366999712', 37.81, -122.41, {
name: 'GOLDEN BEAR',
imo: '9123456',
type: 'Passenger',
}),
vessel('538001234', 37.75, -122.3, {
name: 'PACIFIC TRADER',
imo: 'IMO7654321',
type: 'Tanker',
destination: 'OAKLAND',
}),
vessel('235000001', 51.5, 1.5),
];
const vessels = (extra = {}) => ({
async getSnapshot() {
return {
records,
source: 'AISStream',
coverage: 'received AIS positions',
observedAtMs: Date.UTC(2026, 0, 1),
freshness: 'current',
...extra,
};
},
async getTrack(mmsi) {
return {
records: [
{
latitude: 37.8,
longitude: -122.4,
observedAtMs: Date.UTC(2026, 0, 1, 1),
},
{
latitude: 37.7,
longitude: -122.5,
observedAtMs: Date.UTC(2026, 0, 1, 0),
},
{ latitude: 37.6, longitude: -122.6, observedAtMs: null },
].filter(() => mmsi === '366999712'),
complete: false,
};
},
});
const bay = { lat: 37.78, lon: -122.4, radius_km: 20 };
test('vessels in an area are nearest first and filterable by type', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: vessels() },
});
const all = await catalog.call('vessels_in_area', { area: bay });
assert.equal(all.summary, '2 vessels in 20 km around 37.780, -122.400.');
assert.deepEqual(
all.data.rows.map((row) => row.mmsi),
['366999712', '538001234'],
);
assert.deepEqual(all.data.rows[0], {
mmsi: '366999712',
name: 'GOLDEN BEAR',
imo: '9123456',
type: 'Passenger',
destination: null,
lat: 37.81,
lon: -122.41,
speed_mps: 6.2,
course_deg: 181,
heading_deg: 180,
observed_at: '2026-01-01T00:00:00.000Z',
distance_km: 3.4,
});
assert.equal(all.data.source, 'AISStream');
const tankers = await catalog.call('vessels_in_area', {
area: bay,
type: 'tanker',
});
assert.equal(
tankers.summary,
'1 tanker vessel in 20 km around 37.780, -122.400.',
);
const stale = composeCatalog({
tools: coreTools,
services: { vessels: vessels({ freshness: 'stale' }) },
});
assert.match(
(await stale.call('vessels_in_area', { area: bay })).summary,
/\(data may be stale\)\.$/,
);
});
test('vessels are found by exactly one identifier', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: vessels() },
});
assert.deepEqual(
(await catalog.call('find_vessel', { mmsi: '366999712' })).data.rows.map(
(row) => row.name,
),
['GOLDEN BEAR'],
);
assert.deepEqual(
(await catalog.call('find_vessel', { imo: '7654321' })).data.rows.map(
(row) => row.mmsi,
),
['538001234'],
);
const byName = await catalog.call('find_vessel', { name: 'trader' });
assert.equal(byName.summary, 'Found 1 vessel with name trader.');
assert.equal(
(await catalog.call('find_vessel', { mmsi: '99999' })).summary,
'No vessel with mmsi 99999 is currently reported.',
);
await assert.rejects(
catalog.call('find_vessel', { mmsi: '366999712', name: 'xy' }),
/exactly one/,
);
await assert.rejects(
catalog.call('find_vessel', { mmsi: 'abc' }),
/must match/,
);
});
test('vessel tracks are ordered and drop untimed positions', async () => {
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: vessels() },
});
const track = await catalog.call('get_vessel_track', { mmsi: '366999712' });
assert.equal(
track.summary,
'2 positions for 366999712 from 2026-01-01T00:00:00.000Z to 2026-01-01T01:00:00.000Z.',
);
assert.deepEqual(
track.data.points.map((point) => point.lat),
[37.7, 37.8],
);
assert.equal(
(await catalog.call('get_vessel_track', { mmsi: '235000001' })).summary,
'No recent track is available for 235000001.',
);
});
test('a missing AIS key surfaces as an unavailable feed', async () => {
const keyless = {
getSnapshot: async () => {
throw new LiveSourceError('unavailable', 'AISSTREAM_API_KEY not set');
},
};
const catalog = composeCatalog({
tools: coreTools,
services: { vessels: keyless },
});
await assert.rejects(
catalog.call('vessels_in_area', { area: bay }),
(error) =>
error.code === 'unavailable' &&
error.message === 'AISSTREAM_API_KEY not set',
);
});