@nxisai/agent-harness 0.6.0 → 0.8.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 +32 -7
- package/dist/handoff/tools.d.ts.map +1 -1
- package/dist/handoff/tools.js +11 -4
- package/dist/handoff/tools.js.map +1 -1
- package/dist/harness/harness.d.ts +16 -0
- package/dist/harness/harness.d.ts.map +1 -1
- package/dist/harness/harness.js +70 -10
- 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 +31 -1
- package/dist/harness/renderer.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.d.ts +39 -0
- package/dist/harness/tools/action-tools.d.ts.map +1 -1
- package/dist/harness/tools/action-tools.js +82 -4
- package/dist/harness/tools/action-tools.js.map +1 -1
- package/dist/harness/tools/site-actions.d.ts +27 -0
- package/dist/harness/tools/site-actions.d.ts.map +1 -1
- package/dist/harness/tools/site-actions.js +47 -0
- package/dist/harness/tools/site-actions.js.map +1 -1
- package/dist/harness/tools/site-pages.js +1 -1
- package/dist/harness/tools/source.d.ts.map +1 -1
- package/dist/harness/tools/source.js +5 -2
- package/dist/harness/tools/source.js.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/ui/action-card.d.ts +141 -0
- package/dist/ui/action-card.d.ts.map +1 -0
- package/dist/ui/action-card.js +384 -0
- package/dist/ui/action-card.js.map +1 -0
- package/dist/ui/agent-fullscreen.d.ts.map +1 -1
- package/dist/ui/agent-fullscreen.js +615 -88
- 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/dist/ui/tailwind.generated.d.ts +1 -1
- package/dist/ui/tailwind.generated.d.ts.map +1 -1
- package/dist/ui/tailwind.generated.js +1 -1
- package/dist/ui/tailwind.generated.js.map +1 -1
- package/package.json +1 -1
- package/src/handoff/tools.ts +11 -7
- package/src/harness/harness.ts +82 -17
- package/src/harness/recommend.ts +194 -0
- package/src/harness/records.ts +237 -0
- package/src/harness/renderer.ts +34 -1
- package/src/harness/tools/action-tools.ts +92 -4
- package/src/harness/tools/site-actions.ts +58 -0
- package/src/harness/tools/site-pages.ts +1 -1
- package/src/harness/tools/source.ts +5 -2
- package/src/index.ts +4 -1
- package/src/ui/action-card.ts +496 -0
- package/src/ui/agent-fullscreen.ts +655 -78
- package/src/ui/result-pick.ts +233 -0
- package/src/ui/tailwind.generated.ts +1 -1
package/src/harness/harness.ts
CHANGED
|
@@ -39,6 +39,8 @@ import { decide, policyFor, type Decision } from './policy.js';
|
|
|
39
39
|
import { buildPlan } from './plan.js';
|
|
40
40
|
import { resolveSlots, summarySlots, type Resolution } from './slots.js';
|
|
41
41
|
import { beginJourney, journeySteps, recordStep, type AgentStep } from './steps.js';
|
|
42
|
+
import { extractRecords } from './records.js';
|
|
43
|
+
import { recommend, type ResultPick } from './recommend.js';
|
|
42
44
|
import { HANDOFF_SESSION_RESULT, TALK_TO_A_PERSON_TOOL, isHandoffTool } from '../handoff/tools.js';
|
|
43
45
|
import type { HarnessRenderer, StreamingBubble } from './renderer.js';
|
|
44
46
|
|
|
@@ -111,6 +113,15 @@ export interface HarnessOptions {
|
|
|
111
113
|
* Defaults to true. Pass false to ask one question at a time.
|
|
112
114
|
*/
|
|
113
115
|
groupRelated?: boolean;
|
|
116
|
+
/**
|
|
117
|
+
* Whether a search that finds several records ends with a recommendation:
|
|
118
|
+
* the model picks one and gives reasons, and only reasons the record backs
|
|
119
|
+
* are kept (see `recommend`). Defaults to true. Costs one more completion
|
|
120
|
+
* per search.
|
|
121
|
+
*/
|
|
122
|
+
recommend?: boolean;
|
|
123
|
+
/** The clock "open now" is judged by. For tests. */
|
|
124
|
+
now?: () => Date;
|
|
114
125
|
/** Called whenever the conversation changes, for hosts that persist it. */
|
|
115
126
|
onMessages?(messages: ChatMessage[]): void;
|
|
116
127
|
}
|
|
@@ -323,6 +334,8 @@ export class AgentHarness {
|
|
|
323
334
|
/** The previous request's system prompt and tool names, for the cache hint. */
|
|
324
335
|
private lastPrompt = '';
|
|
325
336
|
private lastTools = '';
|
|
337
|
+
/** What the visitor asked this turn, for a recommendation to answer. */
|
|
338
|
+
private goal = '';
|
|
326
339
|
|
|
327
340
|
constructor(opts: HarnessOptions) {
|
|
328
341
|
this.provider = opts.provider ?? new LocalGemmaProvider();
|
|
@@ -434,6 +447,7 @@ export class AgentHarness {
|
|
|
434
447
|
// rather than the previous turn's.
|
|
435
448
|
this.abort?.abort();
|
|
436
449
|
this.abort = new AbortController();
|
|
450
|
+
this.goal = goal;
|
|
437
451
|
this.renderer.stoppable?.(true);
|
|
438
452
|
|
|
439
453
|
// Opened before the model is touched: loading weights is the longest
|
|
@@ -766,12 +780,14 @@ export class AgentHarness {
|
|
|
766
780
|
detail: resolution.missing.map((s) => humanize(s.name).toLowerCase()).join(', '),
|
|
767
781
|
});
|
|
768
782
|
const plan = buildPlan(resolution.missing, this.opts.groupRelated !== false);
|
|
769
|
-
this.renderer.
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
783
|
+
if (!this.renderer.inlinePrompts) {
|
|
784
|
+
this.renderer.message(
|
|
785
|
+
'assistant',
|
|
786
|
+
plan.stepped
|
|
787
|
+
? `I need ${plan.steps.length} more details for ${humanize(tool.name)}.`
|
|
788
|
+
: `One more thing for ${humanize(tool.name)}.`,
|
|
789
|
+
);
|
|
790
|
+
}
|
|
775
791
|
this.renderer.ask({
|
|
776
792
|
tool,
|
|
777
793
|
plan,
|
|
@@ -832,6 +848,35 @@ export class AgentHarness {
|
|
|
832
848
|
this.renderer.step?.(step, journeySteps());
|
|
833
849
|
}
|
|
834
850
|
|
|
851
|
+
/**
|
|
852
|
+
* The record to recommend from a search's results, when there are several
|
|
853
|
+
* to choose between and the model can say why from what they state.
|
|
854
|
+
*/
|
|
855
|
+
private async pickFrom(res: ModelContextToolResponse): Promise<{ pick: ResultPick; name: string } | null> {
|
|
856
|
+
if (this.opts.recommend === false || !this.goal) return null;
|
|
857
|
+
// Records of things, not rows of a type listing: something with a name and a kind.
|
|
858
|
+
const records = extractRecords(res).filter((r) => typeof r.name === 'string' && r['@type'] != null);
|
|
859
|
+
if (records.length < 2) return null;
|
|
860
|
+
const bubble = this.renderer.streaming();
|
|
861
|
+
bubble.thinking('Comparing them…');
|
|
862
|
+
try {
|
|
863
|
+
const pick = await recommend({
|
|
864
|
+
provider: this.provider,
|
|
865
|
+
goal: this.goal,
|
|
866
|
+
records,
|
|
867
|
+
now: this.opts.now?.(),
|
|
868
|
+
signal: this.abort?.signal,
|
|
869
|
+
transcribe: withTranscribedCall,
|
|
870
|
+
});
|
|
871
|
+
return pick ? { pick, name: String(records[pick.index].name) } : null;
|
|
872
|
+
} catch {
|
|
873
|
+
// Stopped, or the model failed: the results stand on their own.
|
|
874
|
+
return null;
|
|
875
|
+
} finally {
|
|
876
|
+
bubble.finalize('');
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
835
880
|
/** Discard a live prompt and the values typed into it. */
|
|
836
881
|
private cancel(): void {
|
|
837
882
|
// A journey that stops here should say so, not sit waiting for a press
|
|
@@ -874,17 +919,23 @@ export class AgentHarness {
|
|
|
874
919
|
// courier persists the journey as it leaves, and this step has to be in
|
|
875
920
|
// it or the page it lands on shows no reason for being there.
|
|
876
921
|
const handing = isHandoffTool(tool.name);
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
: leaving
|
|
922
|
+
const inFlight: AgentStep = handing
|
|
923
|
+
? { id: 'fill', label: 'Handing the conversation over', status: 'active' }
|
|
924
|
+
: leaving
|
|
881
925
|
? { id: 'navigate', label: `Opening ${leaving.label}`, status: 'active' }
|
|
882
926
|
: {
|
|
883
927
|
id: 'fill',
|
|
884
928
|
label: readOnly ? 'Looking that up' : `Filling ${humanize(tool.name)}`,
|
|
885
929
|
status: 'active',
|
|
886
|
-
}
|
|
887
|
-
);
|
|
930
|
+
};
|
|
931
|
+
this.emitStep(inFlight);
|
|
932
|
+
// A failure ends the step that was running, so the card never shows it still under way.
|
|
933
|
+
const failed = (detail: string) => {
|
|
934
|
+
this.emitStep({ ...inFlight, label: 'It did not go through', status: 'failed', detail });
|
|
935
|
+
// The model reads why on its next turn, and can act on it rather than repeat the call.
|
|
936
|
+
messages.push({ role: 'user', content: `[${tool.name} did not go through: ${detail.slice(0, 400)}]` });
|
|
937
|
+
this.opts.onMessages?.(messages);
|
|
938
|
+
};
|
|
888
939
|
try {
|
|
889
940
|
const res: ModelContextToolResponse = await this.toolSource.execTool(tool, tool.name, values);
|
|
890
941
|
const text = extractResult(res);
|
|
@@ -900,7 +951,7 @@ export class AgentHarness {
|
|
|
900
951
|
});
|
|
901
952
|
|
|
902
953
|
if (res?.isError) {
|
|
903
|
-
|
|
954
|
+
failed(text || 'Failed');
|
|
904
955
|
this.renderer.toolError(tool, text || 'Failed');
|
|
905
956
|
return;
|
|
906
957
|
}
|
|
@@ -915,6 +966,11 @@ export class AgentHarness {
|
|
|
915
966
|
// Said from what we know, not from what came back: a registry may
|
|
916
967
|
// return the whole response object as JSON, and that is not a
|
|
917
968
|
// sentence to put in front of anyone.
|
|
969
|
+
if (this.renderer.left) {
|
|
970
|
+
const carried = res?.structuredContent?.[HANDOFF_RESULT] as { label?: string } | undefined;
|
|
971
|
+
this.renderer.left(tool, (visiting ?? leaving)?.label ?? carried?.label ?? null);
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
918
974
|
this.renderer.message(
|
|
919
975
|
'assistant',
|
|
920
976
|
visiting
|
|
@@ -931,7 +987,10 @@ export class AgentHarness {
|
|
|
931
987
|
const started = res?.structuredContent?.[HANDOFF_SESSION_RESULT] as { label?: string } | undefined;
|
|
932
988
|
if (handing && started) {
|
|
933
989
|
const who = started.label ?? 'another agent';
|
|
934
|
-
this
|
|
990
|
+
// Handing over is where this journey ends; the conversation carries on outside it. Without a
|
|
991
|
+
// `submit` step the card would read as running for as long as the handoff lasts.
|
|
992
|
+
this.emitStep({ id: 'fill', label: 'Handed the conversation over', status: 'done' });
|
|
993
|
+
this.emitStep({ id: 'submit', label: `Handed to ${who}`, status: 'done' });
|
|
935
994
|
messages.push({
|
|
936
995
|
role: 'user',
|
|
937
996
|
content: `[${tool.name}: the conversation was handed to ${who}. Their replies go to the visitor directly.]`,
|
|
@@ -958,8 +1017,14 @@ export class AgentHarness {
|
|
|
958
1017
|
});
|
|
959
1018
|
|
|
960
1019
|
if (readOnly) {
|
|
961
|
-
this.
|
|
962
|
-
|
|
1020
|
+
const picked = await this.pickFrom(res);
|
|
1021
|
+
this.renderer.results(tool, res, picked ? { pick: picked.pick } : undefined);
|
|
1022
|
+
messages.push({
|
|
1023
|
+
role: 'user',
|
|
1024
|
+
content:
|
|
1025
|
+
`[${tool.name} returned: ${text.slice(0, 400)}]` +
|
|
1026
|
+
(picked ? ` [You recommended ${picked.name}: ${picked.pick.reasons.join(' ')}]` : ''),
|
|
1027
|
+
});
|
|
963
1028
|
} else {
|
|
964
1029
|
this.renderer.detail(tool, values, text);
|
|
965
1030
|
messages.push({
|
|
@@ -970,7 +1035,7 @@ export class AgentHarness {
|
|
|
970
1035
|
this.opts.onMessages?.(messages);
|
|
971
1036
|
} catch (err) {
|
|
972
1037
|
const message = String((err as Error)?.message || err);
|
|
973
|
-
|
|
1038
|
+
failed(message);
|
|
974
1039
|
working.finalize('');
|
|
975
1040
|
this.renderer.status('');
|
|
976
1041
|
// A call that threw is the main reason to open a trace.
|
|
@@ -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. */
|
|
@@ -195,6 +206,28 @@ export interface HarnessRenderer {
|
|
|
195
206
|
/** Tear down any live ask or confirm UI. */
|
|
196
207
|
dismissPrompt?(): void;
|
|
197
208
|
|
|
209
|
+
/**
|
|
210
|
+
* Whether this renderer's ask and confirm prompts say for themselves what
|
|
211
|
+
* they need and where they go.
|
|
212
|
+
*
|
|
213
|
+
* When true, the harness adds no "One more thing for …" line of its own
|
|
214
|
+
* before an ask: the prompt already reads as the assistant speaking.
|
|
215
|
+
*/
|
|
216
|
+
inlinePrompts?: boolean;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* A confirmed call is leaving for the page that can submit it.
|
|
220
|
+
*
|
|
221
|
+
* Called instead of the harness's own "Opening … to submit this…" message,
|
|
222
|
+
* for a renderer that shows the departure on the prompt the visitor just
|
|
223
|
+
* confirmed. The page it opens finishes the call.
|
|
224
|
+
*
|
|
225
|
+
* @param tool - The tool whose call is travelling.
|
|
226
|
+
* @param destination - The page it is going to, named for the visitor, or
|
|
227
|
+
* null when the harness cannot tell which.
|
|
228
|
+
*/
|
|
229
|
+
left?(tool: ModelContextToolInfo, destination: string | null): void;
|
|
230
|
+
|
|
198
231
|
/**
|
|
199
232
|
* Report whether the turn can still be stopped.
|
|
200
233
|
*
|