pi-browser-harness 0.13.0 → 0.14.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/CHANGELOG.md +20 -0
- package/README.md +46 -0
- package/package.json +5 -3
- package/skills/pi-browser-harness/SKILL.md +32 -0
- package/src/cdp/commands.ts +5 -0
- package/src/cdp/events.ts +1 -0
- package/src/cdp/session.ts +40 -1
- package/src/client.ts +51 -11
- package/src/domains/ax-tree.ts +19 -0
- package/src/domains/js.ts +2 -2
- package/src/domains/page/recipe.page.ts +82 -0
- package/src/domains/page/serp.page.ts +4 -1
- package/src/domains/ref-resolve.ts +1 -1
- package/src/domains/search/google-serp.ts +77 -7
- package/src/domains/search/redirect-resolver.ts +41 -0
- package/src/domains/search/web-search.ts +65 -7
- package/src/domains/snapshot.ts +11 -6
- package/src/index.ts +12 -1
- package/src/prompt.ts +2 -1
- package/src/recipes/command.ts +96 -0
- package/src/recipes/compile.ts +198 -0
- package/src/recipes/find.ts +49 -0
- package/src/recipes/journal.ts +125 -0
- package/src/recipes/recipe.ts +215 -0
- package/src/recipes/recordable.ts +359 -0
- package/src/recipes/replay.ts +354 -0
- package/src/recipes/run-tool.ts +132 -0
- package/src/recipes/save-tool.ts +247 -0
- package/src/recipes/store.ts +103 -0
- package/src/recipes/trust.ts +172 -0
- package/src/registry.ts +4 -0
- package/src/util/js-source.ts +28 -0
- package/src/util/tool.ts +25 -3
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import type { BrowserClient } from "../client";
|
|
2
|
+
import type { RefSignature } from "../cdp/types";
|
|
3
|
+
import { cdpCall, evalJs } from "../domains/cdp-call";
|
|
4
|
+
import { runOnElement } from "../domains/element-call";
|
|
5
|
+
import { elementFunctionSource, pageFunctionSource } from "../domains/page/inject";
|
|
6
|
+
import { focusedIsPassword, interactiveAt, isPasswordInput, liveMessages, pageFingerprint } from "../domains/page/recipe.page";
|
|
7
|
+
import { refreshRefs } from "../domains/ref-resolve";
|
|
8
|
+
import { safeJs } from "../util/js-template";
|
|
9
|
+
import type { CallObserver, JsonObject } from "../util/tool";
|
|
10
|
+
import { isRecord } from "../util/guards";
|
|
11
|
+
import { type Journal, type JournalFieldEntry, type Locator, type ObservedChange, activeJournal } from "./journal";
|
|
12
|
+
|
|
13
|
+
// How a replayable tool names its element: by `ref`/`selector`/x-y, by a `fields` list, or not at all.
|
|
14
|
+
export const REPLAYABLE: ReadonlyMap<string, "ref" | "fields" | "none"> = new Map([
|
|
15
|
+
["browser_click", "ref"],
|
|
16
|
+
["browser_fill", "ref"],
|
|
17
|
+
["browser_select_option", "ref"],
|
|
18
|
+
["browser_set_checked", "ref"],
|
|
19
|
+
["browser_focus", "ref"],
|
|
20
|
+
["browser_dispatch_key", "ref"],
|
|
21
|
+
["browser_upload_file", "ref"],
|
|
22
|
+
["browser_fill_form", "fields"],
|
|
23
|
+
["browser_type", "none"],
|
|
24
|
+
["browser_press_key", "none"],
|
|
25
|
+
["browser_navigate", "none"],
|
|
26
|
+
["browser_go_back", "none"],
|
|
27
|
+
["browser_go_forward", "none"],
|
|
28
|
+
["browser_reload", "none"],
|
|
29
|
+
["browser_scroll", "none"],
|
|
30
|
+
["browser_drag_and_drop", "none"],
|
|
31
|
+
["browser_handle_dialog", "none"],
|
|
32
|
+
["browser_wait_for", "none"],
|
|
33
|
+
["browser_wait_for_load", "none"],
|
|
34
|
+
["browser_new_tab", "none"],
|
|
35
|
+
["browser_download", "none"],
|
|
36
|
+
// Replayable only when it closes the tab the agent is on; see closesCurrentTab.
|
|
37
|
+
["browser_close_tab", "none"],
|
|
38
|
+
// Reads (see READS): they change nothing, and a replay hands back what they read.
|
|
39
|
+
["browser_http_get", "none"],
|
|
40
|
+
["browser_read_page", "none"],
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// Calls that only read data for the agent. A recipe keeps them so a replay returns the data the task was for, not
|
|
44
|
+
// just the page it ended on. The args each may keep: request headers can carry credentials, and a tab id only means
|
|
45
|
+
// something in this browser session, so a read that used either is left out of the recipe.
|
|
46
|
+
export const READS: ReadonlyMap<string, ReadonlySet<string>> = new Map([
|
|
47
|
+
["browser_http_get", new Set(["url", "timeout"])],
|
|
48
|
+
["browser_read_page", new Set(["url"])],
|
|
49
|
+
]);
|
|
50
|
+
|
|
51
|
+
const readArgs = (name: string, args: Readonly<Record<string, unknown>>): JsonObject | undefined => {
|
|
52
|
+
const keep = READS.get(name);
|
|
53
|
+
if (keep === undefined) return undefined;
|
|
54
|
+
const out: Record<string, string | number> = {};
|
|
55
|
+
for (const [k, v] of Object.entries(args)) {
|
|
56
|
+
if (v === undefined) continue;
|
|
57
|
+
if (!keep.has(k) || (typeof v !== "string" && typeof v !== "number")) return undefined;
|
|
58
|
+
out[k] = v;
|
|
59
|
+
}
|
|
60
|
+
return typeof out["url"] === "string" && out["url"] !== "" ? out : undefined;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
// A read changes nothing on the page, so it is journaled without measuring the page around it.
|
|
64
|
+
const observeRead = async (journal: Journal, name: string, args: Readonly<Record<string, unknown>>, client: BrowserClient): ReturnType<CallObserver> => {
|
|
65
|
+
const kept = readArgs(name, args);
|
|
66
|
+
const url = await urlOf(client);
|
|
67
|
+
return async (outcome) => {
|
|
68
|
+
try {
|
|
69
|
+
await journal.append({
|
|
70
|
+
source: "agent",
|
|
71
|
+
tool: name,
|
|
72
|
+
replayable: kept !== undefined,
|
|
73
|
+
args: kept ?? {},
|
|
74
|
+
password: false,
|
|
75
|
+
url,
|
|
76
|
+
ok: outcome.ok,
|
|
77
|
+
read: true,
|
|
78
|
+
...(outcome.error !== undefined ? { error: outcome.error } : {}),
|
|
79
|
+
});
|
|
80
|
+
} catch {
|
|
81
|
+
// Journal write failures are dropped; the tool already ran.
|
|
82
|
+
}
|
|
83
|
+
return undefined;
|
|
84
|
+
};
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// Page-changing calls a recipe cannot replay safely: arbitrary code, or a handle that only means something now.
|
|
88
|
+
export const NON_REPLAYABLE: ReadonlySet<string> = new Set([
|
|
89
|
+
"browser_execute_js",
|
|
90
|
+
"browser_run_script",
|
|
91
|
+
"browser_switch_tab",
|
|
92
|
+
"browser_open_urls",
|
|
93
|
+
"browser_set_headers",
|
|
94
|
+
"browser_clear_headers",
|
|
95
|
+
"browser_viewport_resize",
|
|
96
|
+
"browser_print_to_pdf",
|
|
97
|
+
]);
|
|
98
|
+
|
|
99
|
+
const IGNORED: ReadonlySet<string> = new Set(["browser_setup", "browser_web_search", "browser_save_recipe", "browser_run_recipe"]);
|
|
100
|
+
|
|
101
|
+
// Args a ref-mode tool turns into its locator, so the step need not keep them.
|
|
102
|
+
const HANDLE_KEYS: ReadonlySet<string> = new Set(["ref", "selector", "x", "y"]);
|
|
103
|
+
const MAX_APPEARED = 5;
|
|
104
|
+
|
|
105
|
+
// Steps a task needs before it is worth saving. The hint rides on the result that reaches this count, so it is shown
|
|
106
|
+
// once per run however long the task goes on; a run with a failed replay is left alone, since its next step is repair.
|
|
107
|
+
const SAVE_HINT_STEPS = 3;
|
|
108
|
+
const SAVE_HINT =
|
|
109
|
+
"This browser task is now several replayable steps long. When the whole task is done, end your final message with one offer to save it as a recipe with browser_save_recipe (a proposed name and params). Offer once; do not repeat it.";
|
|
110
|
+
|
|
111
|
+
const saveHint = async (journal: Journal): Promise<string | undefined> => {
|
|
112
|
+
const entries = await journal.entries();
|
|
113
|
+
const run = entries[entries.length - 1]?.run;
|
|
114
|
+
const current = entries.filter((e) => e.run === run);
|
|
115
|
+
if (current.some((e) => e.tool === "browser_run_recipe")) return undefined;
|
|
116
|
+
const steps = current.filter((e) => e.source === "agent" && e.ok && e.replayable).length;
|
|
117
|
+
return steps === SAVE_HINT_STEPS ? SAVE_HINT : undefined;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
const FOCUSED_IS_PASSWORD_EXPR = "(" + pageFunctionSource(focusedIsPassword) + ")()";
|
|
121
|
+
const INTERACTIVE_AT_SRC = "(" + pageFunctionSource(interactiveAt) + ")";
|
|
122
|
+
const IS_PASSWORD_FN = elementFunctionSource(isPasswordInput);
|
|
123
|
+
const LIVE_MESSAGES_EXPR = "(" + pageFunctionSource(liveMessages) + ")()";
|
|
124
|
+
const FINGERPRINT_EXPR = "(" + pageFunctionSource(pageFingerprint) + ")()";
|
|
125
|
+
|
|
126
|
+
// The page's fingerprint, or undefined when it could not be read (a dialog is open, the page is navigating).
|
|
127
|
+
const readFingerprint = async (client: BrowserClient): Promise<string | undefined> => {
|
|
128
|
+
const r = await evalJs(client, FINGERPRINT_EXPR);
|
|
129
|
+
return r.success && typeof r.data === "string" ? r.data : undefined;
|
|
130
|
+
};
|
|
131
|
+
|
|
132
|
+
export type LiveMessage = { readonly role: string; readonly name: string };
|
|
133
|
+
|
|
134
|
+
// Roles liveMessages reports. They have no ref, so replay matches them against the page's messages instead.
|
|
135
|
+
export const MESSAGE_ROLES: ReadonlySet<string> = new Set(["status", "alert", "alertdialog"]);
|
|
136
|
+
|
|
137
|
+
// The page's visible status and alert messages, or undefined when the page could not be read.
|
|
138
|
+
export const readLiveMessages = async (client: BrowserClient): Promise<ReadonlyArray<LiveMessage> | undefined> => {
|
|
139
|
+
const r = await evalJs(client, LIVE_MESSAGES_EXPR);
|
|
140
|
+
if (!r.success || !Array.isArray(r.data)) return undefined;
|
|
141
|
+
return r.data.flatMap((m) => (isRecord(m) && typeof m["role"] === "string" && typeof m["name"] === "string" ? [{ role: m["role"], name: m["name"] }] : []));
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
const sameKey = (a: RefSignature, b: RefSignature): boolean => a.role === b.role && a.name === b.name;
|
|
145
|
+
|
|
146
|
+
// Role+name plus the element's position among same-named elements, in the ref map's tree order.
|
|
147
|
+
export const locatorOfRef = (client: BrowserClient, ref: string): Locator | undefined => {
|
|
148
|
+
const sigs = client.session().refSignatures();
|
|
149
|
+
const sig = sigs.get(ref);
|
|
150
|
+
if (sig === undefined) return undefined;
|
|
151
|
+
let nth = 0;
|
|
152
|
+
for (const [r, s] of sigs) {
|
|
153
|
+
if (r === ref) break;
|
|
154
|
+
if (sameKey(s, sig)) nth += 1;
|
|
155
|
+
}
|
|
156
|
+
return { role: sig.role, name: sig.name, nth };
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const locatorAtPoint = async (client: BrowserClient, x: number, y: number): Promise<Locator> => {
|
|
160
|
+
const point: Locator = { x, y };
|
|
161
|
+
const hit = await cdpCall(client, "Runtime.evaluate", {
|
|
162
|
+
expression: INTERACTIVE_AT_SRC + safeJs`(${x}, ${y})`,
|
|
163
|
+
returnByValue: false,
|
|
164
|
+
});
|
|
165
|
+
const objectId = hit.success ? hit.data.result.objectId : undefined;
|
|
166
|
+
if (objectId === undefined) return point;
|
|
167
|
+
const described = await cdpCall(client, "DOM.describeNode", { objectId });
|
|
168
|
+
if (!described.success) return point;
|
|
169
|
+
const refreshed = await refreshRefs(client);
|
|
170
|
+
if (!refreshed.success) return point;
|
|
171
|
+
for (const [ref, backendId] of client.session().refMappings()) {
|
|
172
|
+
if (backendId === described.data.node.backendNodeId) return locatorOfRef(client, ref) ?? point;
|
|
173
|
+
}
|
|
174
|
+
return point;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
const isPasswordTarget = async (client: BrowserClient, ref: string | undefined, selector: string | undefined): Promise<boolean> => {
|
|
178
|
+
const r = await runOnElement(client, { ref, selector, functionDeclaration: IS_PASSWORD_FN, args: [] });
|
|
179
|
+
return r.success && r.data.found && r.data.value === true;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
const urlOf = async (client: BrowserClient): Promise<string> => {
|
|
183
|
+
const info = await client.pageInfo();
|
|
184
|
+
return info.success && info.data.kind === "page" ? info.data.url : "";
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
export type BeforeCall = {
|
|
188
|
+
// The page's elements read just before the call; undefined when that read failed, and then none are recorded.
|
|
189
|
+
readonly refs: ReadonlyMap<string, RefSignature> | undefined;
|
|
190
|
+
readonly downloads: number;
|
|
191
|
+
// Undefined when the messages could not be read before the call; then none are recorded.
|
|
192
|
+
readonly messages: ReadonlyArray<LiveMessage> | undefined;
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
const keyOf = (m: LiveMessage): string => `${m.role}\u0000${m.name}`;
|
|
196
|
+
|
|
197
|
+
const newMessages = async (client: BrowserClient, before: ReadonlyArray<LiveMessage> | undefined): Promise<ReadonlyArray<LiveMessage>> => {
|
|
198
|
+
if (before === undefined) return [];
|
|
199
|
+
const after = await readLiveMessages(client);
|
|
200
|
+
if (after === undefined) return [];
|
|
201
|
+
// Seeded with what was there before; each new message is added as it is taken, so nested live regions count once.
|
|
202
|
+
const seen = new Set(before.map(keyOf));
|
|
203
|
+
return after.filter((m) => !seen.has(keyOf(m)) && Boolean(seen.add(keyOf(m))));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// What the page did after the call: where it is now, which named elements and messages appeared, whether a dialog
|
|
207
|
+
// opened, and whether a download began.
|
|
208
|
+
export const observeChange = async (client: BrowserClient, before: BeforeCall): Promise<ObservedChange> => {
|
|
209
|
+
const info = await client.pageInfo();
|
|
210
|
+
const dialog = info.success && info.data.kind === "dialog" ? info.data.dialog.type : undefined;
|
|
211
|
+
const url = info.success && info.data.kind === "page" ? info.data.url : undefined;
|
|
212
|
+
const refreshed = await refreshRefs(client);
|
|
213
|
+
const had = new Set([...(before.refs ?? new Map<string, RefSignature>()).values()].map(keyOf));
|
|
214
|
+
const elements =
|
|
215
|
+
refreshed.success && before.refs !== undefined
|
|
216
|
+
? [...refreshed.data.cur.values()].filter((sig) => sig.name !== "" && !had.has(keyOf(sig))).map((sig) => ({ role: sig.role, name: sig.name }))
|
|
217
|
+
: [];
|
|
218
|
+
// Messages go first: a toast is the clearest sign an action worked, and the cap must not crowd it out.
|
|
219
|
+
const appeared = [...(await newMessages(client, before.messages)), ...elements].slice(0, MAX_APPEARED);
|
|
220
|
+
const download = client.session().downloadCount() > before.downloads;
|
|
221
|
+
return {
|
|
222
|
+
appeared,
|
|
223
|
+
...(url !== undefined ? { url } : {}),
|
|
224
|
+
...(dialog !== undefined ? { dialog } : {}),
|
|
225
|
+
...(download ? { download: true } : {}),
|
|
226
|
+
};
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
const stringArg = (args: Readonly<Record<string, unknown>>, key: string): string | undefined => {
|
|
230
|
+
const v = args[key];
|
|
231
|
+
return typeof v === "string" ? v : undefined;
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const numberArg = (args: Readonly<Record<string, unknown>>, key: string): number | undefined => {
|
|
235
|
+
const v = args[key];
|
|
236
|
+
return typeof v === "number" ? v : undefined;
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
// A ref is a handle into this session only, so it is never kept; selector and x/y are the step itself for a tool
|
|
240
|
+
// with no locator, such as browser_wait_for or browser_scroll.
|
|
241
|
+
// A close of the tab the agent is on replays as "close the current tab". A close of any other tab names a target id
|
|
242
|
+
// that only this browser session has, so it cannot be replayed.
|
|
243
|
+
const closesCurrentTab = (client: BrowserClient, args: Readonly<Record<string, unknown>>): boolean => {
|
|
244
|
+
const current = client.current()?.targetId;
|
|
245
|
+
const target = stringArg(args, "targetId");
|
|
246
|
+
return current !== undefined && target !== undefined && target !== "" && current.startsWith(target);
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
const plainArgs = (args: Readonly<Record<string, unknown>>, redact: boolean, located: boolean): JsonObject => {
|
|
250
|
+
const out: Record<string, string | number | boolean> = {};
|
|
251
|
+
for (const [k, v] of Object.entries(args)) {
|
|
252
|
+
if (k === "ref" || k === "fields" || (located && HANDLE_KEYS.has(k))) continue;
|
|
253
|
+
if (typeof v === "string" || typeof v === "number" || typeof v === "boolean") out[k] = v;
|
|
254
|
+
}
|
|
255
|
+
if (redact) {
|
|
256
|
+
if ("value" in out) out["value"] = "";
|
|
257
|
+
if ("text" in out) out["text"] = "";
|
|
258
|
+
}
|
|
259
|
+
return out;
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
const fieldsOf = async (client: BrowserClient, args: Readonly<Record<string, unknown>>): Promise<ReadonlyArray<JournalFieldEntry>> => {
|
|
263
|
+
const raw = args["fields"];
|
|
264
|
+
if (!Array.isArray(raw)) return [];
|
|
265
|
+
const out: JournalFieldEntry[] = [];
|
|
266
|
+
for (const f of raw) {
|
|
267
|
+
if (!isRecord(f)) continue;
|
|
268
|
+
const ref = stringArg(f, "ref");
|
|
269
|
+
const value = stringArg(f, "value") ?? "";
|
|
270
|
+
const password = ref !== undefined && (await isPasswordTarget(client, ref, undefined));
|
|
271
|
+
const locator = ref !== undefined ? locatorOfRef(client, ref) : undefined;
|
|
272
|
+
out.push({ value: password ? "" : value, password, ...(locator !== undefined ? { locator } : {}) });
|
|
273
|
+
}
|
|
274
|
+
return out;
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
// Watches one tool call for the journal. Returns undefined when the call is not journaled, otherwise the function
|
|
278
|
+
// that records the outcome and returns the save hint when this call is due one. Any error inside is dropped:
|
|
279
|
+
// observing must never change whether the call it watches succeeds.
|
|
280
|
+
export const observeCall: CallObserver = async ({ name, concurrency, args, client }) => {
|
|
281
|
+
const journal = activeJournal();
|
|
282
|
+
if (journal === undefined || IGNORED.has(name) || !isRecord(args)) return undefined;
|
|
283
|
+
if (READS.has(name)) return observeRead(journal, name, args, client).catch(() => undefined);
|
|
284
|
+
const mode = REPLAYABLE.get(name);
|
|
285
|
+
const known = mode !== undefined || NON_REPLAYABLE.has(name);
|
|
286
|
+
if (!known && concurrency === "parallel") return undefined;
|
|
287
|
+
try {
|
|
288
|
+
// A closed tab has no page to compare: reading it after the close waits out the CDP timeout (10s) on a session
|
|
289
|
+
// that is gone. Its step needs no page check, so neither side of it is read.
|
|
290
|
+
const closing = name === "browser_close_tab";
|
|
291
|
+
// Read the page now, not from the agent's last snapshot: that one may be cut short or from another tab, and
|
|
292
|
+
// everything missing from it would look like it appeared after this call. Refs the agent holds keep their names.
|
|
293
|
+
const baseline = closing ? undefined : await refreshRefs(client);
|
|
294
|
+
const ref = stringArg(args, "ref");
|
|
295
|
+
const selector = stringArg(args, "selector");
|
|
296
|
+
const x = numberArg(args, "x");
|
|
297
|
+
const y = numberArg(args, "y");
|
|
298
|
+
let locator: Locator | undefined;
|
|
299
|
+
if (mode === "ref") {
|
|
300
|
+
if (ref !== undefined) locator = locatorOfRef(client, ref);
|
|
301
|
+
else if (selector !== undefined) locator = { css: selector };
|
|
302
|
+
else if (x !== undefined && y !== undefined) locator = await locatorAtPoint(client, x, y);
|
|
303
|
+
}
|
|
304
|
+
const fields = mode === "fields" ? await fieldsOf(client, args) : undefined;
|
|
305
|
+
let password = fields?.some((f) => f.password) ?? false;
|
|
306
|
+
if (name === "browser_fill") password = await isPasswordTarget(client, ref, selector);
|
|
307
|
+
if (name === "browser_type") {
|
|
308
|
+
const r = await evalJs(client, FOCUSED_IS_PASSWORD_EXPR);
|
|
309
|
+
password = r.success && r.data === true;
|
|
310
|
+
}
|
|
311
|
+
const replayable = mode !== undefined && (!closing || closesCurrentTab(client, args));
|
|
312
|
+
const url = await urlOf(client);
|
|
313
|
+
// A script is measured, not judged by what appeared: setting a field's value makes nothing appear.
|
|
314
|
+
const script = name === "browser_execute_js";
|
|
315
|
+
const printBefore = script ? await readFingerprint(client) : undefined;
|
|
316
|
+
const writesBefore = client.session().writeRequestCount();
|
|
317
|
+
const before: BeforeCall = {
|
|
318
|
+
refs: baseline?.success === true ? baseline.data.cur : undefined,
|
|
319
|
+
downloads: client.session().downloadCount(),
|
|
320
|
+
messages: closing ? undefined : await readLiveMessages(client),
|
|
321
|
+
};
|
|
322
|
+
return async (outcome) => {
|
|
323
|
+
try {
|
|
324
|
+
const printAfter = script && outcome.ok ? await readFingerprint(client) : undefined;
|
|
325
|
+
const change = outcome.ok && !closing ? await observeChange(client, before) : undefined;
|
|
326
|
+
const wrote = client.session().writeRequestCount() > writesBefore;
|
|
327
|
+
const readOnly =
|
|
328
|
+
printBefore !== undefined &&
|
|
329
|
+
printAfter === printBefore &&
|
|
330
|
+
!wrote &&
|
|
331
|
+
change !== undefined &&
|
|
332
|
+
(change.url === undefined || change.url === url) &&
|
|
333
|
+
change.dialog === undefined &&
|
|
334
|
+
change.download !== true;
|
|
335
|
+
await journal.append({
|
|
336
|
+
source: "agent",
|
|
337
|
+
tool: name,
|
|
338
|
+
replayable,
|
|
339
|
+
args: closing && replayable ? {} : plainArgs(args, password, mode === "ref"),
|
|
340
|
+
password,
|
|
341
|
+
url,
|
|
342
|
+
ok: outcome.ok,
|
|
343
|
+
...(locator !== undefined ? { locator } : {}),
|
|
344
|
+
...(fields !== undefined ? { fields } : {}),
|
|
345
|
+
...(outcome.error !== undefined ? { error: outcome.error } : {}),
|
|
346
|
+
...(change !== undefined ? { change } : {}),
|
|
347
|
+
...(readOnly ? { readOnly: true } : {}),
|
|
348
|
+
...(wrote ? { wrote: true } : {}),
|
|
349
|
+
});
|
|
350
|
+
return outcome.ok && mode !== undefined ? await saveHint(journal) : undefined;
|
|
351
|
+
} catch {
|
|
352
|
+
// Journal write failures are dropped; the tool already ran.
|
|
353
|
+
return undefined;
|
|
354
|
+
}
|
|
355
|
+
};
|
|
356
|
+
} catch {
|
|
357
|
+
return undefined;
|
|
358
|
+
}
|
|
359
|
+
};
|