@nxisai/agent-harness 0.7.0 → 0.9.0
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/LICENSE +201 -0
- package/README.md +17 -5
- package/dist/a2ui/cards.d.ts +3 -0
- package/dist/a2ui/cards.d.ts.map +1 -1
- package/dist/a2ui/cards.js +11 -2
- package/dist/a2ui/cards.js.map +1 -1
- package/dist/harness/cta.d.ts +27 -0
- package/dist/harness/cta.d.ts.map +1 -0
- package/dist/harness/cta.js +43 -0
- package/dist/harness/cta.js.map +1 -0
- package/dist/harness/harness.d.ts +33 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +84 -2
- package/dist/harness/harness.js.map +1 -1
- package/dist/harness/recommend.d.ts +46 -0
- package/dist/harness/recommend.d.ts.map +1 -0
- package/dist/harness/recommend.js +144 -0
- package/dist/harness/recommend.js.map +1 -0
- package/dist/harness/records.d.ts +70 -0
- package/dist/harness/records.d.ts.map +1 -0
- package/dist/harness/records.js +209 -0
- package/dist/harness/records.js.map +1 -0
- package/dist/harness/renderer.d.ts +11 -1
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/router/coordinator.d.ts +6 -0
- package/dist/harness/router/coordinator.d.ts.map +1 -1
- package/dist/harness/router/coordinator.js +14 -0
- package/dist/harness/router/coordinator.js.map +1 -1
- package/dist/harness/tools/action-tools.d.ts +25 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +39 -0
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/inbound.d.ts +88 -0
- package/dist/harness/tools/inbound.d.ts.map +1 -0
- package/dist/harness/tools/inbound.js +156 -0
- package/dist/harness/tools/inbound.js.map +1 -0
- package/dist/harness/tools/site-pages.js +1 -1
- package/dist/index.d.ts +7 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +154 -76
- package/dist/ui/agent-fullscreen.js.map +1 -1
- package/dist/ui/result-pick.d.ts +52 -0
- package/dist/ui/result-pick.d.ts.map +1 -0
- package/dist/ui/result-pick.js +196 -0
- package/dist/ui/result-pick.js.map +1 -0
- package/package.json +1 -1
- package/src/a2ui/cards.ts +14 -2
- package/src/harness/cta.ts +54 -0
- package/src/harness/harness.ts +93 -2
- package/src/harness/recommend.ts +194 -0
- package/src/harness/records.ts +237 -0
- package/src/harness/renderer.ts +12 -1
- package/src/harness/router/coordinator.ts +14 -0
- package/src/harness/tools/action-tools.ts +41 -0
- package/src/harness/tools/inbound.ts +189 -0
- package/src/harness/tools/site-pages.ts +1 -1
- package/src/index.ts +16 -0
- package/src/ui/agent-fullscreen.ts +162 -66
- package/src/ui/result-pick.ts +233 -0
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Recommending one result, with reasons the records support.
|
|
3
|
+
*
|
|
4
|
+
* After a search returns several records, the visitor's next question is
|
|
5
|
+
* nearly always "which one?". The model answers it in one more completion:
|
|
6
|
+
* it picks a record and gives a short lead and up to three reasons. Then the
|
|
7
|
+
* harness checks every reason against the facts of the record it picked. A
|
|
8
|
+
* number the record does not state, or a reason that shares no words with
|
|
9
|
+
* it, is dropped. A pick with no reason left is no pick at all, and the
|
|
10
|
+
* renderer shows the plain list instead.
|
|
11
|
+
*
|
|
12
|
+
* The model chooses and words it; the records decide what may be said.
|
|
13
|
+
*
|
|
14
|
+
* @module
|
|
15
|
+
*/
|
|
16
|
+
import type { ChatMessage } from '../engine.js';
|
|
17
|
+
import type { Completion, ModelProvider } from '../providers/types.js';
|
|
18
|
+
import type { ModelContextToolInfo } from '../types.js';
|
|
19
|
+
import { factLines, recordFacts, type RecordFacts } from './records.js';
|
|
20
|
+
|
|
21
|
+
/** The tool the model answers through. Never registered anywhere: offered for this one completion. */
|
|
22
|
+
export const RECOMMEND_TOOL = 'recommend';
|
|
23
|
+
|
|
24
|
+
/** A recommendation the renderer can show. */
|
|
25
|
+
export interface ResultPick {
|
|
26
|
+
/** The picked record's position in the results, from 0. */
|
|
27
|
+
index: number;
|
|
28
|
+
/** Its key: `@id`, else its name. */
|
|
29
|
+
key: string;
|
|
30
|
+
/** One sentence for the visitor, naming it. */
|
|
31
|
+
lead: string;
|
|
32
|
+
/** Up to three reasons, each backed by the record. */
|
|
33
|
+
reasons: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** What {@link recommend} needs. */
|
|
37
|
+
export interface RecommendRequest {
|
|
38
|
+
provider: ModelProvider;
|
|
39
|
+
/** What the visitor asked. */
|
|
40
|
+
goal: string;
|
|
41
|
+
/** The records, in the order the results show them. */
|
|
42
|
+
records: Record<string, unknown>[];
|
|
43
|
+
/** The time "open now" is judged at. Defaults to now. */
|
|
44
|
+
now?: Date;
|
|
45
|
+
signal?: AbortSignal;
|
|
46
|
+
/** Reads a call the model wrote out as text. */
|
|
47
|
+
transcribe?: (c: Completion) => Completion;
|
|
48
|
+
/** How many records the model compares. Defaults to eight. */
|
|
49
|
+
limit?: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const TOOL: ModelContextToolInfo = {
|
|
53
|
+
name: RECOMMEND_TOOL,
|
|
54
|
+
description: 'Recommend the one result that best fits what the visitor asked, with reasons taken from that result.',
|
|
55
|
+
inputSchema: {
|
|
56
|
+
type: 'object',
|
|
57
|
+
properties: {
|
|
58
|
+
pick: { type: 'integer', description: 'The number of the result you recommend.' },
|
|
59
|
+
lead: { type: 'string', description: 'One short sentence for the visitor, naming it and saying why.' },
|
|
60
|
+
reasons: {
|
|
61
|
+
type: 'array',
|
|
62
|
+
items: { type: 'string' },
|
|
63
|
+
description: 'Two or three short reasons, each restating a fact listed for that result.',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: ['pick', 'lead', 'reasons'],
|
|
67
|
+
},
|
|
68
|
+
} as ModelContextToolInfo;
|
|
69
|
+
|
|
70
|
+
const WEEKDAY = new Intl.DateTimeFormat('en-US', { weekday: 'long', hour: 'numeric', minute: '2-digit' });
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Ask the model which result fits, and keep only what the record supports.
|
|
74
|
+
*
|
|
75
|
+
* @returns The pick, or null when the model gave none, picked a result that
|
|
76
|
+
* does not exist, or gave no reason the record backs.
|
|
77
|
+
*/
|
|
78
|
+
export async function recommend(req: RecommendRequest): Promise<ResultPick | null> {
|
|
79
|
+
const now = req.now ?? new Date();
|
|
80
|
+
const records = req.records.slice(0, req.limit ?? 8);
|
|
81
|
+
if (records.length < 2) return null;
|
|
82
|
+
const facts = records.map((r) => recordFacts(r, now));
|
|
83
|
+
const listing = facts.map((f, i) => `${i + 1}. ${f.name}\n${factLines(f).map((l) => ` - ${l}`).join('\n')}`).join('\n');
|
|
84
|
+
|
|
85
|
+
const messages: ChatMessage[] = [
|
|
86
|
+
{
|
|
87
|
+
role: 'system',
|
|
88
|
+
content: [
|
|
89
|
+
'You help a website visitor choose between search results.',
|
|
90
|
+
`Call ${RECOMMEND_TOOL} once: pick is the number of the result that best fits what they asked; lead is one short sentence that names it and says why; reasons are two or three short reasons.`,
|
|
91
|
+
'Every reason must restate a fact listed for the result you pick. Do not add facts, prices, times or numbers that are not listed.',
|
|
92
|
+
'If they need help now, prefer one that is open now.',
|
|
93
|
+
].join(' '),
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
role: 'user',
|
|
97
|
+
content: `The visitor asked: "${req.goal}"\nIt is now ${WEEKDAY.format(now)}.\n\nResults:\n${listing}`,
|
|
98
|
+
},
|
|
99
|
+
];
|
|
100
|
+
|
|
101
|
+
let completion: Completion;
|
|
102
|
+
try {
|
|
103
|
+
completion = await req.provider.complete({ messages, tools: [TOOL], signal: req.signal, maxTokens: 300 });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
if (req.signal?.aborted) throw err;
|
|
106
|
+
console.warn('[nxis-agent] recommendation failed:', err);
|
|
107
|
+
return null;
|
|
108
|
+
}
|
|
109
|
+
if (req.transcribe) completion = req.transcribe(completion);
|
|
110
|
+
const args = completion.call?.name === RECOMMEND_TOOL ? completion.call.args : readJson(completion.text);
|
|
111
|
+
if (!args) return null;
|
|
112
|
+
|
|
113
|
+
const index = Number(args.pick) - 1;
|
|
114
|
+
const picked = facts[index];
|
|
115
|
+
if (!Number.isInteger(index) || !picked) return null;
|
|
116
|
+
const others = facts.filter((_, i) => i !== index).map((f) => f.name.toLowerCase());
|
|
117
|
+
const reasons = (Array.isArray(args.reasons) ? args.reasons : [])
|
|
118
|
+
.map((r) => String(r ?? '').trim())
|
|
119
|
+
.filter((r) => r && r.length <= 200 && grounded(r, picked, others))
|
|
120
|
+
.slice(0, 3);
|
|
121
|
+
if (!reasons.length) return null;
|
|
122
|
+
|
|
123
|
+
const said = String(args.lead ?? '').trim();
|
|
124
|
+
const lead = said && said.length <= 200 && mentions(said, picked.name) && grounded(said, picked, others) ? said : `I'd go with ${picked.name}.`;
|
|
125
|
+
return { index, key: picked.key, lead, reasons };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** A call written as plain JSON: `{"pick": 1, …}`. */
|
|
129
|
+
function readJson(text: string): Record<string, unknown> | null {
|
|
130
|
+
const m = text.match(/\{[\s\S]*\}/);
|
|
131
|
+
if (!m) return null;
|
|
132
|
+
try {
|
|
133
|
+
const v = JSON.parse(m[0]);
|
|
134
|
+
return v && typeof v === 'object' ? (v as Record<string, unknown>) : null;
|
|
135
|
+
} catch {
|
|
136
|
+
return null;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const STOP = new Set(
|
|
141
|
+
'about after also and are around been best both but can come does done each even from good great have here into just like more most much need only open other over same some than that their them then there these they this those very well were what when where which while will with would your'.split(
|
|
142
|
+
' ',
|
|
143
|
+
),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
/** The words worth matching: four letters or more, not filler, roughly stemmed. */
|
|
147
|
+
function words(text: string): string[] {
|
|
148
|
+
return (text.toLowerCase().match(/[a-z][a-z'-]{3,}/g) ?? []).filter((w) => !STOP.has(w)).map(stem);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const stem = (w: string): string => w.replace(/'s$/, '').replace(/(ing|ers|ed|es|s)$/, '').slice(0, 7);
|
|
152
|
+
|
|
153
|
+
/** The record's facts as one searchable text. */
|
|
154
|
+
function factText(f: RecordFacts): string {
|
|
155
|
+
return [f.name, ...factLines(f)].join(' ').toLowerCase();
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Whether a sentence says only what the record says.
|
|
160
|
+
*
|
|
161
|
+
* Every number in it must appear in the record ("4.7", "412", "40", "24/7");
|
|
162
|
+
* it must share at least one meaningful word with the record; and it must
|
|
163
|
+
* not be about another result.
|
|
164
|
+
*/
|
|
165
|
+
export function grounded(sentence: string, f: RecordFacts, others: string[] = []): boolean {
|
|
166
|
+
const text = factText(f);
|
|
167
|
+
const lower = sentence.toLowerCase();
|
|
168
|
+
if (others.some((name) => name && lower.includes(name))) return false;
|
|
169
|
+
const numbers = lower.match(/\d+(?:[.:]\d+)?/g) ?? [];
|
|
170
|
+
const numberText = text.replace(/,/g, '');
|
|
171
|
+
if (!numbers.every((n) => numberText.includes(n))) return false;
|
|
172
|
+
// Hours are claims about now, checked against the hours rather than the words:
|
|
173
|
+
// every record says "open" somewhere, closed ones too.
|
|
174
|
+
const always = ALWAYS.test(lower);
|
|
175
|
+
const now = OPEN_NOW.test(lower);
|
|
176
|
+
if (always && !(f.status?.always || /24\/7|24 hours/.test(text))) return false;
|
|
177
|
+
if (now && !f.status?.open) return false;
|
|
178
|
+
if (always || now) return true;
|
|
179
|
+
const known = new Set(words(text));
|
|
180
|
+
return words(sentence).some((w) => known.has(w));
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** A claim that it never closes. */
|
|
184
|
+
const ALWAYS = /24\/7|24 hours|around the clock|day (?:or|and) night|any ?time/;
|
|
185
|
+
/** A claim that it is open at this moment. */
|
|
186
|
+
const OPEN_NOW = /\bopen (?:right )?now\b|\bright now\b|\bopen today\b|\bstill open\b/;
|
|
187
|
+
|
|
188
|
+
/** Whether a sentence names the record: its full name, or its first distinctive word. */
|
|
189
|
+
function mentions(sentence: string, name: string): boolean {
|
|
190
|
+
const lower = sentence.toLowerCase();
|
|
191
|
+
if (lower.includes(name.toLowerCase())) return true;
|
|
192
|
+
const first = words(name).find((w) => w.length >= 4);
|
|
193
|
+
return !!first && words(sentence).includes(first);
|
|
194
|
+
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The records a read tool returned, and what each one says as plain facts.
|
|
3
|
+
*
|
|
4
|
+
* A typed-retrieval result reaches the harness in many wrappings: structured
|
|
5
|
+
* content, JSON text, or the whole response serialized again by an extension
|
|
6
|
+
* in the loop. {@link extractRecords} digs the record list out of any of them.
|
|
7
|
+
* {@link recordFacts} then reads one schema.org record into the facts a
|
|
8
|
+
* person compares businesses by: whether it is open now, its rating, what it
|
|
9
|
+
* does, what reviewers said. Those facts are what a recommendation may cite,
|
|
10
|
+
* and nothing else.
|
|
11
|
+
*
|
|
12
|
+
* @module
|
|
13
|
+
*/
|
|
14
|
+
import type { ModelContextToolResponse } from '../types.js';
|
|
15
|
+
|
|
16
|
+
type Obj = Record<string, unknown>;
|
|
17
|
+
|
|
18
|
+
const isObj = (v: unknown): v is Obj => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The records in a read tool's response, wherever they are.
|
|
22
|
+
*
|
|
23
|
+
* @param res - The response, or its text.
|
|
24
|
+
* @returns The records, as the server sent them; empty when there are none.
|
|
25
|
+
*/
|
|
26
|
+
export function extractRecords(res: ModelContextToolResponse | string | null | undefined): Obj[] {
|
|
27
|
+
const sources: unknown[] = [];
|
|
28
|
+
// Every candidate payload, unwrapping response envelopes: structured
|
|
29
|
+
// content and each text part, parsed as JSON, possibly encoded twice.
|
|
30
|
+
const visit = (raw: unknown, depth = 0): void => {
|
|
31
|
+
if (raw == null || depth > 5) return;
|
|
32
|
+
if (typeof raw === 'string') {
|
|
33
|
+
const t = raw.trim();
|
|
34
|
+
if (!t) return;
|
|
35
|
+
try {
|
|
36
|
+
visit(JSON.parse(t), depth + 1);
|
|
37
|
+
} catch {
|
|
38
|
+
// Plain text: nothing to extract.
|
|
39
|
+
}
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (typeof raw !== 'object') return;
|
|
43
|
+
sources.push(raw);
|
|
44
|
+
const v = raw as Obj;
|
|
45
|
+
if (v.structuredContent != null) visit(v.structuredContent, depth + 1);
|
|
46
|
+
if (Array.isArray(v.content)) {
|
|
47
|
+
for (const c of v.content) if (isObj(c)) visit(c.text, depth + 1);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
visit(res);
|
|
51
|
+
for (const data of sources) {
|
|
52
|
+
const items = itemsFrom(data);
|
|
53
|
+
if (items.length) return items;
|
|
54
|
+
}
|
|
55
|
+
return [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The record array within one parsed payload. */
|
|
59
|
+
function itemsFrom(data: unknown): Obj[] {
|
|
60
|
+
const objs = (arr: unknown[]) => arr.filter(isObj);
|
|
61
|
+
if (Array.isArray(data)) return objs(data);
|
|
62
|
+
if (!isObj(data)) return [];
|
|
63
|
+
for (const key of ['results', 'types', 'items', 'entities', 'data']) {
|
|
64
|
+
if (Array.isArray(data[key])) return objs(data[key] as unknown[]);
|
|
65
|
+
}
|
|
66
|
+
if (isObj(data.result)) return [data.result];
|
|
67
|
+
if (data['@id'] != null || data['@type'] != null || data.name != null) return [data];
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Opening hours ───────────────────────────────────────────────────────────
|
|
72
|
+
|
|
73
|
+
const DAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
74
|
+
const SHORT: Record<string, number> = { su: 0, mo: 1, tu: 2, we: 3, th: 4, fr: 5, sa: 6 };
|
|
75
|
+
|
|
76
|
+
/** Minutes after midnight: "07:30" → 450. */
|
|
77
|
+
function minutes(hhmm: unknown): number | null {
|
|
78
|
+
const m = String(hhmm ?? '').match(/^(\d{1,2}):(\d{2})/);
|
|
79
|
+
return m ? Number(m[1]) * 60 + Number(m[2]) : null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** "7am", "5:30pm", "noon". */
|
|
83
|
+
function clock(mins: number): string {
|
|
84
|
+
const h = Math.floor(mins / 60) % 24;
|
|
85
|
+
const m = mins % 60;
|
|
86
|
+
if (h === 12 && m === 0) return 'noon';
|
|
87
|
+
const suffix = h < 12 ? 'am' : 'pm';
|
|
88
|
+
const h12 = h % 12 || 12;
|
|
89
|
+
return m ? `${h12}:${String(m).padStart(2, '0')}${suffix}` : `${h12}${suffix}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Each day's opening spans, [opens, closes] in minutes; index 0 is Sunday. */
|
|
93
|
+
function weekOf(entity: Obj): [number, number][][] | null {
|
|
94
|
+
const week: [number, number][][] = DAYS.map(() => []);
|
|
95
|
+
let any = false;
|
|
96
|
+
const spec = entity.openingHoursSpecification;
|
|
97
|
+
for (const s of Array.isArray(spec) ? spec : spec ? [spec] : []) {
|
|
98
|
+
if (!isObj(s)) continue;
|
|
99
|
+
const opens = minutes(s.opens);
|
|
100
|
+
const closes = minutes(s.closes);
|
|
101
|
+
if (opens === null || closes === null) continue;
|
|
102
|
+
const days = Array.isArray(s.dayOfWeek) ? s.dayOfWeek : [s.dayOfWeek];
|
|
103
|
+
for (const d of days) {
|
|
104
|
+
const i = DAYS.findIndex((name) => String(d ?? '').replace(/^.*[/#]/, '').toLowerCase() === name.toLowerCase());
|
|
105
|
+
if (i >= 0) {
|
|
106
|
+
week[i].push([opens, closes]);
|
|
107
|
+
any = true;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// The compact form: "Mo-Fr 07:00-17:00", "Sa 08:00-13:00", "Mo-Su 00:00-23:59".
|
|
112
|
+
const compact = entity.openingHours;
|
|
113
|
+
for (const line of Array.isArray(compact) ? compact : compact ? String(compact).split(/[;,](?=\s*[A-Z][a-z])/) : []) {
|
|
114
|
+
const m = String(line).trim().match(/^([A-Za-z]{2})(?:-([A-Za-z]{2}))?\s+(\d{1,2}:\d{2})-(\d{1,2}:\d{2})$/);
|
|
115
|
+
if (!m) continue;
|
|
116
|
+
const from = SHORT[m[1].toLowerCase()];
|
|
117
|
+
const to = m[2] ? SHORT[m[2].toLowerCase()] : from;
|
|
118
|
+
if (from === undefined || to === undefined) continue;
|
|
119
|
+
for (let d = from; ; d = (d + 1) % 7) {
|
|
120
|
+
week[d].push([minutes(m[3])!, minutes(m[4])!]);
|
|
121
|
+
any = true;
|
|
122
|
+
if (d === to) break;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return any ? week : null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Whether a business is open at a time, and how a visitor should read it. */
|
|
129
|
+
export interface OpenStatus {
|
|
130
|
+
open: boolean;
|
|
131
|
+
/** "Open now · 24/7", "Open now · until 5pm", "Opens 7am", "Opens Mon 7am". */
|
|
132
|
+
label: string;
|
|
133
|
+
/** Open every hour of every day. */
|
|
134
|
+
always: boolean;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether a record's business is open at `now`, read from its opening hours.
|
|
139
|
+
*
|
|
140
|
+
* @returns Null when the record states no hours this can read.
|
|
141
|
+
*/
|
|
142
|
+
export function openStatus(entity: Obj, now: Date = new Date()): OpenStatus | null {
|
|
143
|
+
const week = weekOf(entity);
|
|
144
|
+
if (!week) return null;
|
|
145
|
+
const always = week.every((spans) => spans.some(([o, c]) => o === 0 && c >= 23 * 60 + 59));
|
|
146
|
+
if (always) return { open: true, label: 'Open now · 24/7', always: true };
|
|
147
|
+
const day = now.getDay();
|
|
148
|
+
const at = now.getHours() * 60 + now.getMinutes();
|
|
149
|
+
const current = week[day].find(([o, c]) => at >= o && at < c);
|
|
150
|
+
if (current) return { open: true, label: `Open now · until ${clock(current[1])}`, always: false };
|
|
151
|
+
// The next opening, today later or on a following day.
|
|
152
|
+
for (let ahead = 0; ahead < 7; ahead++) {
|
|
153
|
+
const d = (day + ahead) % 7;
|
|
154
|
+
const next = week[d].filter(([o]) => ahead > 0 || o > at).sort((a, b) => a[0] - b[0])[0];
|
|
155
|
+
if (!next) continue;
|
|
156
|
+
// Today or tomorrow reads as a time alone; further off, with the day.
|
|
157
|
+
return { open: false, label: ahead <= 1 ? `Opens ${clock(next[0])}` : `Opens ${DAYS[d].slice(0, 3)} ${clock(next[0])}`, always: false };
|
|
158
|
+
}
|
|
159
|
+
return { open: false, label: 'Closed', always: false };
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Facts ───────────────────────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
/** One record, as the facts a visitor compares by. */
|
|
165
|
+
export interface RecordFacts {
|
|
166
|
+
/** Its `@id`, else its name: what a pick refers to. */
|
|
167
|
+
key: string;
|
|
168
|
+
name: string;
|
|
169
|
+
type?: string;
|
|
170
|
+
status?: OpenStatus;
|
|
171
|
+
rating?: number;
|
|
172
|
+
reviewCount?: number;
|
|
173
|
+
phone?: string;
|
|
174
|
+
/** Services it offers, by name, with their one-line descriptions. */
|
|
175
|
+
services: { name: string; description?: string }[];
|
|
176
|
+
/** What it says it specializes in. */
|
|
177
|
+
knows: string[];
|
|
178
|
+
description?: string;
|
|
179
|
+
/** What reviewers wrote, with who wrote it. */
|
|
180
|
+
reviews: { text: string; author?: string }[];
|
|
181
|
+
/** The year it was founded. */
|
|
182
|
+
since?: number;
|
|
183
|
+
/** ZIP codes or places it serves. */
|
|
184
|
+
area: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const str = (v: unknown): string => (v == null ? '' : String(Array.isArray(v) ? v[0] : v)).trim();
|
|
188
|
+
const list = (v: unknown): unknown[] => (Array.isArray(v) ? v : v == null ? [] : [v]);
|
|
189
|
+
|
|
190
|
+
/** A schema.org record as plain facts. */
|
|
191
|
+
export function recordFacts(entity: Obj, now: Date = new Date()): RecordFacts {
|
|
192
|
+
const name = str(entity.name ?? entity.title ?? entity.headline) || 'This result';
|
|
193
|
+
const rating = isObj(entity.aggregateRating) ? Number(entity.aggregateRating.ratingValue) : NaN;
|
|
194
|
+
const count = isObj(entity.aggregateRating) ? Number(entity.aggregateRating.reviewCount ?? entity.aggregateRating.ratingCount) : NaN;
|
|
195
|
+
const services = list(entity.makesOffer)
|
|
196
|
+
.map((o) => (isObj(o) && isObj(o.itemOffered) ? o.itemOffered : isObj(o) ? o : null))
|
|
197
|
+
.filter(isObj)
|
|
198
|
+
.map((s) => ({ name: str(s.name), description: str(s.description) || undefined }))
|
|
199
|
+
.filter((s) => s.name);
|
|
200
|
+
const reviews = list(entity.review)
|
|
201
|
+
.filter(isObj)
|
|
202
|
+
.map((r) => ({ text: str(r.reviewBody ?? r.description), author: isObj(r.author) ? str(r.author.name) || undefined : str(r.author) || undefined }))
|
|
203
|
+
.filter((r) => r.text);
|
|
204
|
+
const founded = str(entity.foundingDate).match(/^(\d{4})/);
|
|
205
|
+
const status = openStatus(entity, now) ?? undefined;
|
|
206
|
+
return {
|
|
207
|
+
key: str(entity['@id']) || name,
|
|
208
|
+
name,
|
|
209
|
+
type: str(entity['@type']).replace(/^.*[/#]/, '') || undefined,
|
|
210
|
+
...(status ? { status } : {}),
|
|
211
|
+
...(Number.isFinite(rating) ? { rating } : {}),
|
|
212
|
+
...(Number.isFinite(count) ? { reviewCount: count } : {}),
|
|
213
|
+
...(str(entity.telephone) ? { phone: str(entity.telephone) } : {}),
|
|
214
|
+
services,
|
|
215
|
+
knows: list(entity.knowsAbout).map(str).filter(Boolean),
|
|
216
|
+
...(str(entity.description) ? { description: str(entity.description) } : {}),
|
|
217
|
+
reviews,
|
|
218
|
+
...(founded ? { since: Number(founded[1]) } : {}),
|
|
219
|
+
area: list(entity.areaServed).map((a) => (isObj(a) ? str(a.name ?? a.postalCode) : str(a))).filter(Boolean),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The facts as lines a model reads, and a recommendation is checked against. */
|
|
224
|
+
export function factLines(f: RecordFacts): string[] {
|
|
225
|
+
const lines: string[] = [];
|
|
226
|
+
if (f.type) lines.push(f.type);
|
|
227
|
+
if (f.status) lines.push(f.status.label);
|
|
228
|
+
if (f.rating !== undefined) lines.push(`Rated ${f.rating}${f.reviewCount !== undefined ? ` from ${f.reviewCount} reviews` : ''}`);
|
|
229
|
+
if (f.since) lines.push(`In business since ${f.since}`);
|
|
230
|
+
if (f.services.length) lines.push(`Services: ${f.services.map((s) => (s.description ? `${s.name} (${s.description})` : s.name)).join('; ')}`);
|
|
231
|
+
if (f.knows.length) lines.push(`Specializes in: ${f.knows.join(', ')}`);
|
|
232
|
+
if (f.description) lines.push(f.description);
|
|
233
|
+
for (const r of f.reviews.slice(0, 2)) lines.push(`Review: "${r.text}"${r.author ? ` (${r.author})` : ''}`);
|
|
234
|
+
if (f.area.length) lines.push(`Serves: ${f.area.join(', ')}`);
|
|
235
|
+
if (f.phone) lines.push(`Phone: ${f.phone}`);
|
|
236
|
+
return lines;
|
|
237
|
+
}
|
package/src/harness/renderer.ts
CHANGED
|
@@ -13,6 +13,7 @@ import type { ModelContextToolInfo, ModelContextToolResponse } from '../types.js
|
|
|
13
13
|
import type { ClarificationPlan } from './plan.js';
|
|
14
14
|
import type { Resolution, Slot } from './slots.js';
|
|
15
15
|
import type { AgentStep } from './steps.js';
|
|
16
|
+
import type { ResultPick } from './recommend.js';
|
|
16
17
|
|
|
17
18
|
/** One tool call and its response, for renderers that show their work. */
|
|
18
19
|
export interface TraceEntry {
|
|
@@ -81,6 +82,16 @@ export interface StreamingBubble {
|
|
|
81
82
|
finalize(text: string): void;
|
|
82
83
|
}
|
|
83
84
|
|
|
85
|
+
/** What the harness adds to a search's results. */
|
|
86
|
+
export interface ResultsExtra {
|
|
87
|
+
/**
|
|
88
|
+
* The record the model recommends, with reasons the record backs. Absent
|
|
89
|
+
* when there was one result, the model gave no backed reason, or
|
|
90
|
+
* recommendations are off: show the results as a list.
|
|
91
|
+
*/
|
|
92
|
+
pick?: ResultPick;
|
|
93
|
+
}
|
|
94
|
+
|
|
84
95
|
/** A write tool with every value resolved, awaiting a human press. */
|
|
85
96
|
export interface ConfirmRequest {
|
|
86
97
|
/** The tool about to be called. */
|
|
@@ -162,7 +173,7 @@ export interface HarnessRenderer {
|
|
|
162
173
|
confirm(req: ConfirmRequest): void;
|
|
163
174
|
|
|
164
175
|
/** A read tool returned records. */
|
|
165
|
-
results(tool: ModelContextToolInfo, res: ModelContextToolResponse): void;
|
|
176
|
+
results(tool: ModelContextToolInfo, res: ModelContextToolResponse, extra?: ResultsExtra): void;
|
|
166
177
|
/** A write tool succeeded. */
|
|
167
178
|
detail(tool: ModelContextToolInfo, values: Record<string, unknown>, text: string): void;
|
|
168
179
|
/** A tool failed, or returned something that cannot be rendered. */
|
|
@@ -188,6 +188,20 @@ export class AgentCoordinator {
|
|
|
188
188
|
return this.working.turn(goal, messages);
|
|
189
189
|
}
|
|
190
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Run a turn whose tool call is already decided, on the agent that holds the tool.
|
|
193
|
+
*
|
|
194
|
+
* @see AgentHarness.begin
|
|
195
|
+
*/
|
|
196
|
+
async begin(goal: string, name: string, args: Record<string, unknown>, messages: ChatMessage[]): Promise<TurnResult> {
|
|
197
|
+
const owner = this.ownerOf(name, '');
|
|
198
|
+
if (!owner) return { decision: 'unavailable' };
|
|
199
|
+
this.opts.onRoute?.({ ...this.route(goal, messages), agent: owner, via: 'handback', fellBack: false });
|
|
200
|
+
this.sticky.set(messages, owner);
|
|
201
|
+
this.working = this.opts.agents[owner];
|
|
202
|
+
return this.working.begin(goal, name, args, messages);
|
|
203
|
+
}
|
|
204
|
+
|
|
191
205
|
/** The agent holding a tool, ignoring the one that handed the turn back. */
|
|
192
206
|
private ownerOf(tool: string, except: string): string | null {
|
|
193
207
|
for (const [name, agent] of Object.entries(this.opts.agents)) {
|
|
@@ -520,6 +520,47 @@ export function formOptions(tool: ModelContextToolInfo, values: Record<string, u
|
|
|
520
520
|
return out;
|
|
521
521
|
}
|
|
522
522
|
|
|
523
|
+
/**
|
|
524
|
+
* The site forms that can be sent to a business, by its name: the site tools
|
|
525
|
+
* on this page whose choices include it.
|
|
526
|
+
*
|
|
527
|
+
* @returns Each form's tool name and what it is called ("quote form").
|
|
528
|
+
*/
|
|
529
|
+
export function formsFor(name: string): { tool: string; noun: string }[] {
|
|
530
|
+
const wanted = name.trim().toLowerCase();
|
|
531
|
+
if (!wanted) return [];
|
|
532
|
+
const out: { tool: string; noun: string }[] = [];
|
|
533
|
+
for (const [toolName, t] of built) {
|
|
534
|
+
if (t.choices?.some((c) => c.toLowerCase() === wanted)) {
|
|
535
|
+
out.push({ tool: toolName, noun: withoutSiteSuffix(toolName).replace(/[_-]+/g, ' ').trim().toLowerCase() });
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* The site form that runs an action, by the action's `@id`: the site-wide tool
|
|
543
|
+
* built on this page that covers it, and which business it is for.
|
|
544
|
+
*
|
|
545
|
+
* @returns The tool's name, what the form is called ("quote form"), the
|
|
546
|
+
* choice that picks this action when the tool covers several, and the
|
|
547
|
+
* arguments that make a call to it this action; null when no site tool built
|
|
548
|
+
* here covers it.
|
|
549
|
+
*/
|
|
550
|
+
export function siteFormFor(
|
|
551
|
+
actionId: string,
|
|
552
|
+
): { tool: string; noun: string; choice?: string; args: Record<string, unknown> } | null {
|
|
553
|
+
for (const [toolName, t] of built) {
|
|
554
|
+
const index = t.actions.findIndex((a) => a['@id'] === actionId);
|
|
555
|
+
if (index < 0) continue;
|
|
556
|
+
const noun = withoutSiteSuffix(toolName).replace(/[_-]+/g, ' ').trim().toLowerCase();
|
|
557
|
+
const choice = t.choices?.[index];
|
|
558
|
+
const args = choice && t.discriminator ? { [t.discriminator]: choice } : {};
|
|
559
|
+
return choice ? { tool: toolName, noun, choice, args } : { tool: toolName, noun, args };
|
|
560
|
+
}
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
|
|
523
564
|
/** An option that only asks to choose: "Select…", "Choose a service…". */
|
|
524
565
|
export const isPlaceholder = (value: string): boolean => /^\s*(select|choose|pick)\b/i.test(value) || /…\s*$|\.\.\.\s*$/.test(value);
|
|
525
566
|
|