@qvac/harness 0.7.2 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -8,6 +8,30 @@ While the major version is `0`, a breaking change bumps the minor version.
8
8
 
9
9
  ## [Unreleased]
10
10
 
11
+ ## [0.7.3] - 2026-10-01
12
+
13
+ ### Added
14
+
15
+ - A tool result may carry `answer: true` beside its `content` to end the turn
16
+ with that content as the reply, without another round through the model.
17
+ The `tool-result` event still fires. `weather_lookup` uses it for an
18
+ ambiguous or unknown place: the question that lists the namesakes, or the
19
+ not-found line, is the reply.
20
+
21
+ ### Fixed
22
+
23
+ - `weather_lookup` finds the place before it asks for the weather. It looks the
24
+ city up on Open-Meteo's geocoder, `geocoding-api.open-meteo.com`, a new
25
+ outbound host, and asks wttr.in for the forecast at the coordinates of the
26
+ place it picked, which leads the report. A bare "Nassau" now reports Nassau,
27
+ Bahamas. It used to ask wttr.in by name, whose own geocoder put it in
28
+ Westbury, New York. A name that places of a similar size share, such as
29
+ Springfield, gets a question listing up to four of them instead of a
30
+ forecast: any namesake with a tenth of the largest one's population counts.
31
+ A country or state in the request picks among them. When the geocoder is
32
+ blocked, slow or failing, or knows no such place, the lookup asks wttr.in by
33
+ name, as before. The resource label names both hosts.
34
+
11
35
  ## [0.7.2] - 2026-10-01
12
36
 
13
37
  ### Added
@@ -1041,7 +1065,8 @@ Tagged but never published to npm; these changes first shipped in 0.2.12.
1041
1065
  - GPU stats are read through `bare-gpu-info`, so every dependency installs from
1042
1066
  the public npm registry. (#12)
1043
1067
 
1044
- [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.7.2...HEAD
1068
+ [Unreleased]: https://github.com/tetherto/qvac-harness/compare/v0.7.3...HEAD
1069
+ [0.7.3]: https://github.com/tetherto/qvac-harness/compare/v0.7.2...v0.7.3
1045
1070
  [0.7.2]: https://github.com/tetherto/qvac-harness/compare/v0.7.1...v0.7.2
1046
1071
  [0.7.1]: https://github.com/tetherto/qvac-harness/compare/v0.7.0...v0.7.1
1047
1072
  [0.7.0]: https://github.com/tetherto/qvac-harness/compare/v0.6.0...v0.7.0
@@ -232,6 +232,15 @@ function callSucceeded(result, outcome) {
232
232
  const exitCode = isRecord(result) ? result.exitCode : undefined;
233
233
  return typeof exitCode !== 'number' || exitCode === 0;
234
234
  }
235
+ function isAnswerResult(result) {
236
+ return (isRecord(result) &&
237
+ result.answer === true &&
238
+ typeof result.content === 'string' &&
239
+ result.content.trim().length > 0);
240
+ }
241
+ function answerOf(result) {
242
+ return isAnswerResult(result) ? result.content : undefined;
243
+ }
235
244
  function retryHints(result) {
236
245
  if (!result || typeof result !== 'object' || Array.isArray(result))
237
246
  return {};
@@ -1038,6 +1047,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1038
1047
  ];
1039
1048
  let cappedCalls = 0;
1040
1049
  let progressed = false;
1050
+ let answer;
1041
1051
  runOutcomes.beginRound();
1042
1052
  for (const { call, tool, args, rawCall, rejection, intent } of resolved) {
1043
1053
  if (signal?.aborted)
@@ -1144,6 +1154,7 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1144
1154
  }
1145
1155
  else if (execution.executed && callSucceeded(output, executionOutcome)) {
1146
1156
  progressed = true;
1157
+ answer ??= answerOf(output);
1147
1158
  }
1148
1159
  runOutcomes.record(output, executionOutcome);
1149
1160
  if (searchCapped) {
@@ -1197,6 +1208,13 @@ export async function* completion(sdk, { modelId, acquireRound, model, agent, ct
1197
1208
  failed.clear();
1198
1209
  blockedRepeats = 0;
1199
1210
  }
1211
+ if (answer !== undefined) {
1212
+ yield { type: 'content', text: answer };
1213
+ history.push({ role: 'assistant', content: answer });
1214
+ said = answer;
1215
+ outcome = 'answered';
1216
+ break;
1217
+ }
1200
1218
  history.push({ role: 'user', content: TOOL_CONTINUATION_NUDGE });
1201
1219
  if (blockedRepeats >= MAX_BLOCKED_REPEATS) {
1202
1220
  break;
@@ -0,0 +1,26 @@
1
+ import { type AbortSignal as BareAbortSignal } from 'bare-abort-controller';
2
+ import type { FetchFn } from '../http-request.ts';
3
+ import { type HostResolver } from '../url-guard.ts';
4
+ import { type ResolvedPlace } from './place.ts';
5
+ export declare const GEOCODER_HOST = "geocoding-api.open-meteo.com";
6
+ export declare const GEOCODE_TIMEOUT_MS = 5000;
7
+ export interface Candidate extends ResolvedPlace {
8
+ readonly latitude: number;
9
+ readonly longitude: number;
10
+ readonly population: number | undefined;
11
+ }
12
+ export interface Chosen {
13
+ readonly place: Candidate;
14
+ readonly verified: boolean;
15
+ }
16
+ export type Choice = Chosen | {
17
+ readonly contenders: readonly Candidate[];
18
+ };
19
+ export interface GeocodeRequest {
20
+ readonly fetch: FetchFn;
21
+ readonly resolveHost: HostResolver;
22
+ readonly signal: BareAbortSignal;
23
+ readonly timeoutMs: number;
24
+ }
25
+ export declare function geocode(city: string, lookup: GeocodeRequest): Promise<Candidate[]>;
26
+ export declare function choose(city: string, qualifier: string | null, candidates: readonly Candidate[]): Choice | null;
@@ -0,0 +1,110 @@
1
+ import { AbortController } from 'bare-abort-controller';
2
+ import { z } from 'zod';
3
+ import { errorMessage } from '../../guards.js';
4
+ import { hlog } from '../../log.js';
5
+ import { resolveOutboundUrl } from '../url-guard.js';
6
+ import { checkQualifier, describePlace, fold } from './place.js';
7
+ export const GEOCODER_HOST = 'geocoding-api.open-meteo.com';
8
+ const MAX_BODY_BYTES = 200_000;
9
+ export const GEOCODE_TIMEOUT_MS = 5_000;
10
+ const CONTENDER_RATIO = 10;
11
+ const MAX_LISTED = 4;
12
+ const GeocodeSchema = z.object({
13
+ results: z
14
+ .array(z.object({
15
+ name: z.string(),
16
+ latitude: z.number(),
17
+ longitude: z.number(),
18
+ country: z.string().optional(),
19
+ admin1: z.string().optional(),
20
+ population: z.number().optional()
21
+ }))
22
+ .optional()
23
+ });
24
+ export async function geocode(city, lookup) {
25
+ const controller = new AbortController();
26
+ const timeout = setTimeout(() => controller.abort(undefined), lookup.timeoutMs);
27
+ const onLookupAbort = () => controller.abort(undefined);
28
+ lookup.signal.addEventListener('abort', onLookupAbort);
29
+ try {
30
+ return await search(city, lookup, controller.signal);
31
+ }
32
+ catch (err) {
33
+ if (lookup.signal.aborted) {
34
+ throw err;
35
+ }
36
+ hlog('warn: weather geocoder failed, asking wttr.in by name', { err: errorMessage(err) });
37
+ return [];
38
+ }
39
+ finally {
40
+ clearTimeout(timeout);
41
+ lookup.signal.removeEventListener('abort', onLookupAbort);
42
+ }
43
+ }
44
+ async function search(city, { fetch, resolveHost }, signal) {
45
+ const query = `name=${encodeURIComponent(city)}&count=10&language=en&format=json`;
46
+ const url = new URL(`https://${GEOCODER_HOST}/v1/search?${query}`);
47
+ const check = await resolveOutboundUrl(url, resolveHost, signal);
48
+ if (check.blocked) {
49
+ throw new Error(check.blocked);
50
+ }
51
+ const response = await fetch(url, {
52
+ method: 'GET',
53
+ headers: { Accept: 'application/json' },
54
+ signal,
55
+ redirect: 'manual',
56
+ validatedAddresses: check.addresses,
57
+ maxBytes: MAX_BODY_BYTES
58
+ });
59
+ if (response.status !== 200) {
60
+ throw new Error(`geocoder answered HTTP ${response.status}`);
61
+ }
62
+ const { results = [] } = GeocodeSchema.parse(JSON.parse(await response.text()));
63
+ return results.map((result) => ({
64
+ area: result.name,
65
+ region: result.admin1 ?? '',
66
+ country: result.country ?? '',
67
+ latitude: result.latitude,
68
+ longitude: result.longitude,
69
+ population: result.population
70
+ }));
71
+ }
72
+ export function choose(city, qualifier, candidates) {
73
+ const exact = candidates.filter((candidate) => fold(candidate.area) === fold(city));
74
+ const top = byPopulation(exact)[0] ?? candidates[0];
75
+ if (top === undefined) {
76
+ return null;
77
+ }
78
+ if (qualifier !== null) {
79
+ return qualified(qualifier, { exact, candidates, top });
80
+ }
81
+ const contenders = contendersOf(exact);
82
+ if (contenders.length > 1) {
83
+ return { contenders };
84
+ }
85
+ return { place: top, verified: exact.includes(top) };
86
+ }
87
+ function qualified(qualifier, { exact, candidates, top }) {
88
+ const named = (pool) => pool.find((candidate) => checkQualifier(qualifier, candidate) === 'match');
89
+ const namesake = named(byPopulation(exact));
90
+ if (namesake !== undefined) {
91
+ return { place: namesake, verified: true };
92
+ }
93
+ return { place: named(candidates) ?? top, verified: false };
94
+ }
95
+ function contendersOf(exact) {
96
+ const pool = byPopulation(exact);
97
+ const top = pool[0]?.population;
98
+ if (top === undefined) {
99
+ return [];
100
+ }
101
+ const lines = pool.map(describePlace);
102
+ return pool
103
+ .filter((candidate, index) => candidate.population !== undefined &&
104
+ candidate.population * CONTENDER_RATIO >= top &&
105
+ lines.indexOf(describePlace(candidate)) === index)
106
+ .slice(0, MAX_LISTED);
107
+ }
108
+ function byPopulation(candidates) {
109
+ return [...candidates].sort((a, b) => (b.population ?? -1) - (a.population ?? -1));
110
+ }
@@ -17,6 +17,8 @@ const QUALIFIER_ALIASES = [
17
17
  ['united arab emirates', 'united arab emirates'],
18
18
  ['uae', 'united arab emirates'],
19
19
  ['holland', 'netherlands'],
20
+ ['schweiz', 'switzerland'],
21
+ ['suisse', 'switzerland'],
20
22
  ['deutschland', 'germany'],
21
23
  ['espana', 'spain'],
22
24
  ['italia', 'italy']
@@ -103,7 +105,9 @@ export function fold(value) {
103
105
  .trim();
104
106
  }
105
107
  export function describePlace(place) {
106
- return [place.area, place.region, place.country].filter((part) => part.length > 0).join(', ');
108
+ return [place.area, place.region, place.country]
109
+ .filter((part, index, parts) => part.length > 0 && parts.indexOf(part) === index)
110
+ .join(', ');
107
111
  }
108
112
  export function qualifierOf(location) {
109
113
  const comma = location.indexOf(',');
@@ -4,5 +4,6 @@ import { type HostResolver } from './url-guard.ts';
4
4
  export interface WeatherLookupToolOptions {
5
5
  fetch?: FetchFn;
6
6
  resolveHost?: HostResolver;
7
+ geocodeTimeoutMs?: number;
7
8
  }
8
- export declare function createWeatherLookupTool({ fetch: fetchImpl, resolveHost }?: WeatherLookupToolOptions): ToolDefinition;
9
+ export declare function createWeatherLookupTool({ fetch, resolveHost, geocodeTimeoutMs }?: WeatherLookupToolOptions): ToolDefinition;
@@ -4,12 +4,13 @@ import { lookupHost } from '../dns-lookup.js';
4
4
  import { errorMessage } from '../guards.js';
5
5
  import { isRedirectStatus } from './http-request/request.js';
6
6
  import { resolveOutboundUrl } from './url-guard.js';
7
+ import { choose, geocode, GEOCODE_TIMEOUT_MS, GEOCODER_HOST } from './weather-lookup/geocode.js';
7
8
  import { checkQualifier, cityMatches, describePlace, qualifierOf } from './weather-lookup/place.js';
8
9
  import { placeOf, renderReport, WeatherJsonSchema } from './weather-lookup/report.js';
9
10
  const WEATHER_HOST = 'wttr.in';
10
11
  const LOOKUP_TIMEOUT_MS = 15_000;
11
12
  const MAX_BODY_BYTES = 400_000;
12
- export function createWeatherLookupTool({ fetch: fetchImpl, resolveHost = lookupHost } = {}) {
13
+ export function createWeatherLookupTool({ fetch = noRedirectFetch, resolveHost = lookupHost, geocodeTimeoutMs = GEOCODE_TIMEOUT_MS } = {}) {
13
14
  return {
14
15
  name: 'weather_lookup',
15
16
  toolset: 'http-request',
@@ -17,7 +18,8 @@ export function createWeatherLookupTool({ fetch: fetchImpl, resolveHost = lookup
17
18
  autoApprove: true,
18
19
  description: 'Look up the current weather and a three-day forecast for a place, via wttr.in. ' +
19
20
  'Returns the place the service resolved on its first line, then the conditions now and ' +
20
- 'one line per day. Use it for any question about weather, temperature or forecast.',
21
+ 'one line per day. Use it for any question about weather, temperature or forecast. ' +
22
+ 'When a name matches several places it answers with a question listing them.',
21
23
  parameters: {
22
24
  type: 'object',
23
25
  properties: {
@@ -30,93 +32,122 @@ export function createWeatherLookupTool({ fetch: fetchImpl, resolveHost = lookup
30
32
  required: ['location']
31
33
  },
32
34
  effect: () => 'read',
33
- resource: () => ({ kind: 'host', label: WEATHER_HOST }),
34
- async execute(args, ctx) {
35
- const raw = args.location;
36
- if (typeof raw !== 'string' || raw.trim().length === 0) {
37
- return { error: 'location must be a non-empty string' };
38
- }
39
- const location = raw.trim();
40
- if (ctx?.signal?.aborted) {
41
- return { error: 'weather_lookup cancelled' };
42
- }
43
- const url = new URL(`https://${WEATHER_HOST}/${encodeURIComponent(location)}?format=j1`);
44
- const fetch = fetchImpl ?? noRedirectFetch;
45
- const controller = new AbortController();
46
- const timeout = setTimeout(() => controller.abort(undefined), LOOKUP_TIMEOUT_MS);
47
- const onCallerAbort = () => controller.abort(undefined);
48
- ctx?.signal?.addEventListener('abort', onCallerAbort);
49
- try {
50
- const check = await resolveOutboundUrl(url, resolveHost, ctx?.signal);
51
- if (check.blocked) {
52
- return { error: check.blocked };
53
- }
54
- const response = await fetch(url, {
55
- method: 'GET',
56
- headers: { Accept: 'application/json' },
57
- signal: controller.signal,
58
- redirect: 'manual',
59
- validatedAddresses: check.addresses,
60
- maxBytes: MAX_BODY_BYTES
61
- });
62
- if (isRedirectStatus(response.status)) {
63
- return { error: `${WEATHER_HOST} redirected the lookup for "${location}"` };
64
- }
65
- const body = await response.text();
66
- if (/location not found/i.test(body)) {
67
- return { content: notFound(location) };
68
- }
69
- if (response.status !== 200) {
70
- return { error: unavailable(`it answered HTTP ${response.status}`), code: 'transport' };
71
- }
72
- let json;
73
- try {
74
- json = WeatherJsonSchema.parse(JSON.parse(body));
75
- }
76
- catch {
77
- return { error: unavailable('it did not return a forecast') };
78
- }
79
- const place = placeOf(json);
80
- if (place === null) {
81
- return { content: notFound(location) };
82
- }
83
- const qualifier = qualifierOf(location);
84
- const verdict = qualifier === null ? null : checkQualifier(qualifier, place);
85
- const named = cityMatches(location, place);
86
- if (verdict === 'contradiction' && !named) {
87
- return {
88
- content: `No such place: "${location}". ${WEATHER_HOST} resolved it to ` +
89
- `${describePlace(place)}, which is somewhere else.`
90
- };
91
- }
92
- const verified = qualifier === null ? named : verdict === 'match';
93
- return { content: renderReport(json, place, { asked: location, verified }) };
94
- }
95
- catch (err) {
96
- if (ctx?.signal?.aborted) {
97
- return { error: 'weather_lookup cancelled' };
98
- }
99
- if (controller.signal.aborted) {
100
- return {
101
- error: `weather_lookup timed out after ${LOOKUP_TIMEOUT_MS} ms`,
102
- code: 'timeout'
103
- };
104
- }
105
- return {
106
- error: unavailable(errorMessage(err)),
107
- code: 'transport'
108
- };
109
- }
110
- finally {
111
- clearTimeout(timeout);
112
- ctx?.signal?.removeEventListener('abort', onCallerAbort);
113
- }
114
- }
35
+ resource: () => ({ kind: 'host', label: `${GEOCODER_HOST}, ${WEATHER_HOST}` }),
36
+ execute: (args, ctx) => execute(args.location, ctx?.signal, { fetch, resolveHost, timeoutMs: geocodeTimeoutMs })
115
37
  };
116
38
  }
39
+ async function execute(raw, callerSignal, deps) {
40
+ if (typeof raw !== 'string' || raw.trim().length === 0) {
41
+ return { error: 'location must be a non-empty string' };
42
+ }
43
+ const location = raw.trim();
44
+ if (callerSignal?.aborted) {
45
+ return { error: 'weather_lookup cancelled' };
46
+ }
47
+ const controller = new AbortController();
48
+ const timeout = setTimeout(() => controller.abort(undefined), LOOKUP_TIMEOUT_MS);
49
+ const onCallerAbort = () => controller.abort(undefined);
50
+ callerSignal?.addEventListener('abort', onCallerAbort);
51
+ try {
52
+ return await lookup(location, { ...deps, signal: controller.signal });
53
+ }
54
+ catch (err) {
55
+ return failure(err, controller.signal, callerSignal);
56
+ }
57
+ finally {
58
+ clearTimeout(timeout);
59
+ callerSignal?.removeEventListener('abort', onCallerAbort);
60
+ }
61
+ }
62
+ async function lookup(location, request) {
63
+ const city = (location.split(',')[0] ?? '').trim();
64
+ const candidates = city.length === 0 ? [] : await geocode(city, request);
65
+ const choice = choose(city, qualifierOf(location), candidates);
66
+ if (choice === null) {
67
+ return byName(location, request);
68
+ }
69
+ if ('contenders' in choice) {
70
+ return reply(which(city, choice.contenders));
71
+ }
72
+ return byCoordinates(location, choice, request);
73
+ }
74
+ async function byCoordinates(location, { place, verified }, request) {
75
+ const fetched = await forecast(`${place.latitude},${place.longitude}`, location, request);
76
+ if (!('json' in fetched)) {
77
+ return fetched;
78
+ }
79
+ return { content: renderReport(fetched.json, place, { asked: location, verified }) };
80
+ }
81
+ async function byName(location, request) {
82
+ const fetched = await forecast(encodeURIComponent(location), location, request);
83
+ if (!('json' in fetched)) {
84
+ return fetched;
85
+ }
86
+ const place = placeOf(fetched.json);
87
+ if (place === null) {
88
+ return reply(notFound(location));
89
+ }
90
+ const qualifier = qualifierOf(location);
91
+ const verdict = qualifier === null ? null : checkQualifier(qualifier, place);
92
+ const named = cityMatches(location, place);
93
+ if (verdict === 'contradiction' && !named) {
94
+ return reply(`No such place: "${location}". ${WEATHER_HOST} resolved it to ` +
95
+ `${describePlace(place)}, which is somewhere else.`);
96
+ }
97
+ const verified = qualifier === null ? named : verdict === 'match';
98
+ return { content: renderReport(fetched.json, place, { asked: location, verified }) };
99
+ }
100
+ async function forecast(path, location, request) {
101
+ const url = new URL(`https://${WEATHER_HOST}/${path}?format=j1`);
102
+ const check = await resolveOutboundUrl(url, request.resolveHost, request.signal);
103
+ if (check.blocked) {
104
+ return { error: check.blocked };
105
+ }
106
+ const response = await request.fetch(url, {
107
+ method: 'GET',
108
+ headers: { Accept: 'application/json' },
109
+ signal: request.signal,
110
+ redirect: 'manual',
111
+ validatedAddresses: check.addresses,
112
+ maxBytes: MAX_BODY_BYTES
113
+ });
114
+ if (isRedirectStatus(response.status)) {
115
+ return { error: `${WEATHER_HOST} redirected the lookup for "${location}"` };
116
+ }
117
+ return readForecast(response.status, await response.text(), location);
118
+ }
119
+ function readForecast(status, body, location) {
120
+ if (/location not found/i.test(body)) {
121
+ return reply(notFound(location));
122
+ }
123
+ if (status !== 200) {
124
+ return { error: unavailable(`it answered HTTP ${status}`), code: 'transport' };
125
+ }
126
+ try {
127
+ return { json: WeatherJsonSchema.parse(JSON.parse(body)) };
128
+ }
129
+ catch {
130
+ return { error: unavailable('it did not return a forecast') };
131
+ }
132
+ }
133
+ function failure(err, signal, callerSignal) {
134
+ if (callerSignal?.aborted) {
135
+ return { error: 'weather_lookup cancelled' };
136
+ }
137
+ if (signal.aborted) {
138
+ return { error: `weather_lookup timed out after ${LOOKUP_TIMEOUT_MS} ms`, code: 'timeout' };
139
+ }
140
+ return { error: unavailable(errorMessage(err)), code: 'transport' };
141
+ }
117
142
  function unavailable(reason) {
118
143
  return `The weather service could not be reached: ${reason}.`;
119
144
  }
145
+ function reply(content) {
146
+ return { content, answer: true };
147
+ }
120
148
  function notFound(location) {
121
149
  return `No such place: "${location}". ${WEATHER_HOST} does not know it.`;
122
150
  }
151
+ function which(city, places) {
152
+ return `Which ${city}: ${places.map(describePlace).join('; ')}?`;
153
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@qvac/harness",
3
- "version": "0.7.2",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "bare": ">=1.34.0"
@@ -606,6 +606,10 @@
606
606
  "types": "./dist-lib/lib/tools/weather-lookup.d.ts",
607
607
  "default": "./dist-lib/lib/tools/weather-lookup.js"
608
608
  },
609
+ "./lib/tools/weather-lookup/geocode.ts": {
610
+ "types": "./dist-lib/lib/tools/weather-lookup/geocode.d.ts",
611
+ "default": "./dist-lib/lib/tools/weather-lookup/geocode.js"
612
+ },
609
613
  "./lib/tools/weather-lookup/place.ts": {
610
614
  "types": "./dist-lib/lib/tools/weather-lookup/place.d.ts",
611
615
  "default": "./dist-lib/lib/tools/weather-lookup/place.js"