tapline-host 1.3.0 → 1.4.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/dist/actionlog.d.ts +28 -1
- package/dist/actionlog.js +2 -2
- package/dist/actionlog.js.map +1 -1
- package/dist/audit.d.ts +10 -0
- package/dist/audit.js +19 -1
- package/dist/audit.js.map +1 -1
- package/dist/cli.js +19 -4
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +10 -0
- package/dist/config.js +42 -1
- package/dist/config.js.map +1 -1
- package/dist/doctor.d.ts +11 -1
- package/dist/doctor.js +74 -2
- package/dist/doctor.js.map +1 -1
- package/dist/format.d.ts +2 -0
- package/dist/format.js +8 -0
- package/dist/format.js.map +1 -1
- package/dist/init.js +83 -1
- package/dist/init.js.map +1 -1
- package/dist/mcp.js +108 -59
- package/dist/mcp.js.map +1 -1
- package/dist/protocol.d.ts +2 -1
- package/dist/protocol.js +7 -1
- package/dist/protocol.js.map +1 -1
- package/dist/report.d.ts +55 -0
- package/dist/report.js +298 -0
- package/dist/report.js.map +1 -0
- package/dist/run.js +1 -1
- package/dist/run.js.map +1 -1
- package/dist/tool-text-data.d.ts +2 -1
- package/dist/tool-text-data.js +2 -1
- package/dist/tool-text-data.js.map +1 -1
- package/dist/tools.d.ts +69 -0
- package/dist/tools.js +324 -24
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/skills/tapline/SKILL.md +33 -1
package/dist/tools.js
CHANGED
|
@@ -6,11 +6,13 @@ import { z } from "zod";
|
|
|
6
6
|
import { annotateScreenshot } from "./annotate.js";
|
|
7
7
|
import { agentKind, captionFor, personKind, reasonOf } from "./captions.js";
|
|
8
8
|
import { composeFilmstrip, diffImages, imageSize } from "./images.js";
|
|
9
|
-
import { alertNotes, describeElement, filterLogs, formatChanges, formatElement, formatLogAnswer, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
|
|
9
|
+
import { alertNotes, networkLine, describeElement, filterLogs, formatChanges, formatElement, formatLogAnswer, formatLogs, formatOffScreenAfterList, formatTree } from "./format.js";
|
|
10
10
|
import { formatInspect, inspectResultSchema } from "./inspect.js";
|
|
11
11
|
import { AUDIT_TYPES, auditResultSchema, auditTitle, formatAudit } from "./audit.js";
|
|
12
12
|
import { NO_CRASH_TEXT, crashNote, disconnectNote, formatCrash } from "./crash.js";
|
|
13
13
|
import { ActionLog, loggedTarget } from "./actionlog.js";
|
|
14
|
+
import { reportFor } from "./report.js";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
14
16
|
import { Confirmations } from "./confirm.js";
|
|
15
17
|
import { DEFAULT_POLICY, elementKey, listsFor, matchWord } from "./policy.js";
|
|
16
18
|
import { readScript, refStep, scriptSteps } from "./script.js";
|
|
@@ -250,6 +252,9 @@ export const toolShapes = {
|
|
|
250
252
|
tap: targetField.optional().describe("Tap this first, then measure what it sets off"),
|
|
251
253
|
},
|
|
252
254
|
wait: { seconds: z.number().min(0.1).max(30).describe("How long to wait, 0.1 to 30 seconds"), screenshot: screenshotField },
|
|
255
|
+
session_report: {
|
|
256
|
+
since: z.number().min(0).optional().describe("Epoch milliseconds: report only calls from then on (default: the start of this session)"),
|
|
257
|
+
},
|
|
253
258
|
run: {
|
|
254
259
|
steps: z
|
|
255
260
|
.array(stepSchema)
|
|
@@ -265,6 +270,20 @@ export const toolShapes = {
|
|
|
265
270
|
screenshot: screenshotField,
|
|
266
271
|
},
|
|
267
272
|
};
|
|
273
|
+
/**
|
|
274
|
+
* The answer for a call that carries arguments the tool doesn't have (null when all are known). Zod strips unknown
|
|
275
|
+
* keys silently, so `audit {labels: true}` would run and ignore `labels`; this says so instead. Every way in to a
|
|
276
|
+
* tool (the MCP server, and through it the bench) goes through here; script steps are checked by `run` itself.
|
|
277
|
+
*/
|
|
278
|
+
export function unknownArgumentError(name, args) {
|
|
279
|
+
const known = Object.keys(toolShapes[name]);
|
|
280
|
+
const unknown = Object.keys(args).filter((key) => !known.includes(key));
|
|
281
|
+
if (unknown.length === 0)
|
|
282
|
+
return null;
|
|
283
|
+
// AGENT-TOOLS 1.11 wording, shared with Tapwright: every unknown name in one sentence.
|
|
284
|
+
const takes = known.length === 0 ? "it takes no arguments" : `it takes ${known.join(", ")}`;
|
|
285
|
+
return fail(`Error bad_params: ${name} doesn't take ${unknown.join(", ")}; ${takes}.`);
|
|
286
|
+
}
|
|
268
287
|
// ---- compare_screen ---------------------------------------------------------
|
|
269
288
|
const DEFAULT_MATCH_THRESHOLD = 0.5;
|
|
270
289
|
const MAX_REGION_LINES = 8;
|
|
@@ -618,7 +637,17 @@ const dryRunResultSchema = z.object({
|
|
|
618
637
|
dryRun: z.literal(true),
|
|
619
638
|
point: z.object({ x: z.number(), y: z.number() }).optional(),
|
|
620
639
|
value: z.unknown().optional(),
|
|
640
|
+
/** The element is off screen and the action would scroll it into view first (§16.2); `point` is where it expects to touch after. */
|
|
641
|
+
scrolls: z.boolean().optional(),
|
|
621
642
|
});
|
|
643
|
+
/** Whether an app's answer says the action scrolled the element into view first (AGENT-TOOLS 1.12 §16.2). */
|
|
644
|
+
function scrolledFirst(result) {
|
|
645
|
+
return typeof result === "object" && result !== null && "scrolled" in result && result.scrolled === true;
|
|
646
|
+
}
|
|
647
|
+
/** `Scrolled to it, then tapped …` when the action scrolled first: the sentence goes on after a lead-in. */
|
|
648
|
+
function afterScroll(result, sentence) {
|
|
649
|
+
return scrolledFirst(result) ? `Scrolled to it, then ${sentence.charAt(0).toLowerCase()}${sentence.slice(1)}` : sentence;
|
|
650
|
+
}
|
|
622
651
|
/** For an app without dry runs: every element on its list the target could mean. */
|
|
623
652
|
function candidatesFor(target, elements) {
|
|
624
653
|
if (typeof target !== "object" || target === null)
|
|
@@ -657,6 +686,34 @@ const CRASH_NOTED_TOOLS = [
|
|
|
657
686
|
"record", "replay", "tap", "longPress", "typeText", "scroll", "toggle", "appCommand", "waitFor", "swipe", "back",
|
|
658
687
|
"openUrl", "logs", "reload", "performance", "wait",
|
|
659
688
|
];
|
|
689
|
+
/** Every tool the agent calls, by handler name (the call log numbers these). */
|
|
690
|
+
const CALL_TOOLS = {
|
|
691
|
+
apps: "apps", openApp: "open_app", pair: "pair", look: "look", run: "run", inspect: "inspect", audit: "audit",
|
|
692
|
+
checkLayout: "check_layout", viewer: "viewer", compareScreen: "compare_screen", filmstrip: "filmstrip", record: "record",
|
|
693
|
+
replay: "replay", tap: "tap", longPress: "long_press", typeText: "type_text", scroll: "scroll", toggle: "toggle",
|
|
694
|
+
appCommand: "app_command", waitFor: "wait_for", swipe: "swipe", back: "back", openUrl: "open_url", logs: "logs",
|
|
695
|
+
crash: "crash", reload: "reload", performance: "performance", wait: "wait", sessionReport: "session_report",
|
|
696
|
+
};
|
|
697
|
+
/** Calls logged with `"kind": "read"` when they logged no action of their own. */
|
|
698
|
+
const READ_TOOLS = new Set([
|
|
699
|
+
"apps", "open_app", "pair", "look", "inspect", "audit", "check_layout", "viewer", "compare_screen", "filmstrip", "record",
|
|
700
|
+
"replay", "wait_for", "logs", "crash", "reload", "performance", "wait", "session_report",
|
|
701
|
+
]);
|
|
702
|
+
/** A look's picture arguments for the call log: booleans and numbers only; undefined when it gave none. */
|
|
703
|
+
function pictureArgs(input) {
|
|
704
|
+
const out = {};
|
|
705
|
+
if (typeof input["screenshot"] === "boolean")
|
|
706
|
+
out.screenshot = input["screenshot"];
|
|
707
|
+
if (typeof input["width"] === "number" && Number.isFinite(input["width"]))
|
|
708
|
+
out.width = input["width"];
|
|
709
|
+
if (typeof input["labels"] === "boolean")
|
|
710
|
+
out.labels = input["labels"];
|
|
711
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
712
|
+
}
|
|
713
|
+
/** Tools whose answer carries the changes or the full list unless the call said `look: false`. */
|
|
714
|
+
const LISTING_TOOLS = new Set(["tap", "long_press", "type_text", "scroll", "toggle", "swipe", "back", "open_url", "app_command", "run"]);
|
|
715
|
+
/** Tools that act on one element: after a run of scrolls they log whether the element was already in the list. */
|
|
716
|
+
const ELEMENT_ACTIONS = new Set(["tap", "long_press", "type_text", "toggle"]);
|
|
660
717
|
/** The tool handlers behind the MCP server. They only talk to a TaplineHost, so they are easy to test. */
|
|
661
718
|
export class TaplineTools {
|
|
662
719
|
host;
|
|
@@ -670,9 +727,28 @@ export class TaplineTools {
|
|
|
670
727
|
batch;
|
|
671
728
|
/** Extra fields for the next log line (a dry run, a confirmed touch). */
|
|
672
729
|
logExtra;
|
|
730
|
+
/** The call log (AGENT-TOOLS 1.11 §16.1): this run's id, when it began, and how many calls the agent has made. */
|
|
731
|
+
sessionTag = randomBytes(4).toString("hex");
|
|
732
|
+
startedAt = Date.now();
|
|
733
|
+
callCount = 0;
|
|
734
|
+
/** The call in flight (and everything it awaits): its log entries wait here for the size of its answer. */
|
|
735
|
+
callScope = new AsyncLocalStorage();
|
|
736
|
+
clientIdFn;
|
|
737
|
+
/** The tool of the previous call, for scroll runs. */
|
|
738
|
+
prevCallTool;
|
|
739
|
+
/**
|
|
740
|
+
* The latest full element list the agent was given. `stale`: only changes since. `unseen`: an action since then
|
|
741
|
+
* answered with no list and no changes (`look: false`), so the screen may be another one: neither "in the
|
|
742
|
+
* list" nor "not in it" can be said until a list is seen again.
|
|
743
|
+
*/
|
|
744
|
+
listBase;
|
|
745
|
+
/** The list as it was before the current run of scrolls began. */
|
|
746
|
+
scrollBase;
|
|
673
747
|
confirmations;
|
|
674
748
|
policy;
|
|
675
749
|
reloadSettleMs;
|
|
750
|
+
stillGapMs;
|
|
751
|
+
stillCapMs;
|
|
676
752
|
noScreenshot = new Set();
|
|
677
753
|
shotFiles = [];
|
|
678
754
|
/** Sessions this agent has had an element list for (so later lists can be just the changes). */
|
|
@@ -719,6 +795,8 @@ export class TaplineTools {
|
|
|
719
795
|
this.actionLog = options.logDir !== undefined ? new ActionLog(options.logDir) : null;
|
|
720
796
|
this.actionLog?.open();
|
|
721
797
|
this.reloadSettleMs = options.reloadSettleMs ?? 1500;
|
|
798
|
+
this.stillGapMs = options.stillGapMs ?? 150;
|
|
799
|
+
this.stillCapMs = options.stillCapMs ?? 2000;
|
|
722
800
|
// A finger on the phone itself: the app saw a real touch. It can't be queued like viewer input
|
|
723
801
|
// (it has already happened), so it only leaves the note and makes `{ref}` targets stale.
|
|
724
802
|
host.on("touch", (session) => {
|
|
@@ -734,6 +812,120 @@ export class TaplineTools {
|
|
|
734
812
|
const method = original;
|
|
735
813
|
Reflect.set(this, name, (...args) => this.noting(() => method.apply(this, args)));
|
|
736
814
|
}
|
|
815
|
+
// Outermost: every call of the agent is numbered and logged (reads too) once its answer is known.
|
|
816
|
+
for (const [name, tool] of Object.entries(CALL_TOOLS)) {
|
|
817
|
+
const original = Reflect.get(this, name);
|
|
818
|
+
if (typeof original !== "function")
|
|
819
|
+
continue;
|
|
820
|
+
const method = original;
|
|
821
|
+
Reflect.set(this, name, (...args) => this.loggedCall(tool, args[0], () => method.apply(this, args)));
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
/** The agent's client id for the call log; the MCP server passes the client's name once it is known. */
|
|
825
|
+
setClientId(read) {
|
|
826
|
+
this.clientIdFn = read;
|
|
827
|
+
}
|
|
828
|
+
/** A call refused before it reached its tool (an unknown argument): numbered and logged like any other, so the report counts it. */
|
|
829
|
+
rejectedCall(tool, args, answer) {
|
|
830
|
+
const result = this.loggedCall(tool, args, () => answer);
|
|
831
|
+
return result instanceof Promise ? answer : result;
|
|
832
|
+
}
|
|
833
|
+
/** Numbers a call of the agent and logs it when its answer is known (§16.1). Calls within a call (`run`'s steps) are part of it. */
|
|
834
|
+
loggedCall(tool, args, run) {
|
|
835
|
+
if (this.actionLog === null || this.callScope.getStore() !== undefined)
|
|
836
|
+
return run();
|
|
837
|
+
const afterScrolls = this.prevCallTool === "scroll";
|
|
838
|
+
if (tool === "scroll" && !afterScrolls)
|
|
839
|
+
this.scrollBase = this.listBase;
|
|
840
|
+
this.prevCallTool = tool;
|
|
841
|
+
const state = { call: ++this.callCount, tool, afterScrolls, entries: [] };
|
|
842
|
+
const began = new Date();
|
|
843
|
+
let result;
|
|
844
|
+
try {
|
|
845
|
+
result = this.callScope.run(state, run);
|
|
846
|
+
}
|
|
847
|
+
catch (error) {
|
|
848
|
+
this.flushCall(state, args, began, undefined);
|
|
849
|
+
throw error;
|
|
850
|
+
}
|
|
851
|
+
if (result instanceof Promise) {
|
|
852
|
+
return result.then((answer) => {
|
|
853
|
+
this.flushCall(state, args, began, answer);
|
|
854
|
+
return answer;
|
|
855
|
+
}, (error) => {
|
|
856
|
+
this.flushCall(state, args, began, undefined);
|
|
857
|
+
throw error;
|
|
858
|
+
});
|
|
859
|
+
}
|
|
860
|
+
this.flushCall(state, args, began, result);
|
|
861
|
+
return result;
|
|
862
|
+
}
|
|
863
|
+
flushCall(state, args, began, answer) {
|
|
864
|
+
if (this.actionLog === null)
|
|
865
|
+
return;
|
|
866
|
+
const said = (answer?.content ?? []).map((block) => (block.type === "text" ? block.text : ""));
|
|
867
|
+
const size = said.reduce((sum, part) => sum + part.length, 0);
|
|
868
|
+
const failed = answer === undefined || answer.isError === true;
|
|
869
|
+
const input = typeof args === "object" && args !== null ? args : {};
|
|
870
|
+
const listed = LISTING_TOOLS.has(state.tool) && !failed && input["look"] !== false && input["dry_run"] !== true && !(state.tool === "app_command" && input["name"] === undefined);
|
|
871
|
+
const client = (this.clientIdFn ?? this.options.clientId)?.()?.replace(/[^\w .@/-]/g, "").slice(0, 60);
|
|
872
|
+
const common = {
|
|
873
|
+
session: this.sessionTag,
|
|
874
|
+
call: state.call,
|
|
875
|
+
...(client !== undefined && client !== "" ? { client } : {}),
|
|
876
|
+
size,
|
|
877
|
+
};
|
|
878
|
+
const code = failed ? /^Error (\w+):/.exec(said.join("\n"))?.[1] : undefined;
|
|
879
|
+
if (state.entries.length > 0) {
|
|
880
|
+
// Actions that went through, in a call that failed after them (an expectation that didn't hold): the call's
|
|
881
|
+
// own outcome goes with them, so the report doesn't count it as a success (Codex review of 1.4.0).
|
|
882
|
+
const callFailed = failed && state.entries.every(({ entry }) => entry.ok) ? { call_failed: code ?? "internal" } : {};
|
|
883
|
+
for (const { entry, at } of state.entries)
|
|
884
|
+
this.actionLog.write({ ...entry, ...common, ...callFailed, ...(listed ? { listed: true } : {}) }, at);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
// A call that logged no action: a read, or an action refused before it began (then without `kind`).
|
|
888
|
+
const session = this.host.current();
|
|
889
|
+
const lookArgs = state.tool === "look" ? pictureArgs(input) : undefined;
|
|
890
|
+
this.actionLog.write({
|
|
891
|
+
by: "agent",
|
|
892
|
+
tool: state.tool,
|
|
893
|
+
...(READ_TOOLS.has(state.tool) ? { kind: "read" } : {}),
|
|
894
|
+
// the target only (as given); typed text, pictures and log text never
|
|
895
|
+
...(loggedTarget(args).target !== undefined ? { target: loggedTarget(args).target } : {}),
|
|
896
|
+
ok: !failed,
|
|
897
|
+
...(code !== undefined ? { code } : {}),
|
|
898
|
+
ms: Date.now() - began.getTime(),
|
|
899
|
+
...(session !== undefined ? { app: session.app.id ?? session.app.name } : {}),
|
|
900
|
+
...common,
|
|
901
|
+
...(listed ? { listed: true } : {}),
|
|
902
|
+
...(lookArgs !== undefined ? { look_args: lookArgs } : {}),
|
|
903
|
+
}, began);
|
|
904
|
+
}
|
|
905
|
+
/**
|
|
906
|
+
* Whether a touch's target was in the list the agent had before its run of scrolls: true (it was), false (it
|
|
907
|
+
* wasn't), undefined (can't be known: no list, a point, or a list that only changed since).
|
|
908
|
+
*/
|
|
909
|
+
knownBeforeScrolls(params) {
|
|
910
|
+
const base = this.scrollBase;
|
|
911
|
+
const target = typeof params === "object" && params !== null ? params.target : undefined;
|
|
912
|
+
if (base === undefined || typeof target !== "object" || target === null)
|
|
913
|
+
return undefined;
|
|
914
|
+
const t = target;
|
|
915
|
+
if (!["ref", "testID", "label", "text"].some((key) => typeof t[key] === "string"))
|
|
916
|
+
return undefined;
|
|
917
|
+
if (base.unseen)
|
|
918
|
+
return undefined;
|
|
919
|
+
if (candidatesFor(target, base.elements).length > 0)
|
|
920
|
+
return true;
|
|
921
|
+
return base.stale ? undefined : false;
|
|
922
|
+
}
|
|
923
|
+
/** `session_report` (AGENT-TOOLS 1.11 §16.1): where this session's calls went, and the waste in them. */
|
|
924
|
+
sessionReport(args = {}) {
|
|
925
|
+
const dir = this.options.logDir;
|
|
926
|
+
if (dir === undefined)
|
|
927
|
+
return fail("Error unsupported: the action log is off (--no-log), so there is nothing to report.");
|
|
928
|
+
return text(reportFor(dir, args.since ?? this.startedAt, this.sessionTag));
|
|
737
929
|
}
|
|
738
930
|
/**
|
|
739
931
|
* Runs a tool call and ends its answer with a note when the app stopped (AGENT-TOOLS 15.2): during this
|
|
@@ -749,10 +941,32 @@ export class TaplineTools {
|
|
|
749
941
|
return this.noteScope.run(true, () => {
|
|
750
942
|
const result = run();
|
|
751
943
|
if (result instanceof Promise)
|
|
752
|
-
return result.then((answer) => this.withCrashNotes(answer, mark, startedWith));
|
|
753
|
-
return this.withCrashNotes(result, mark, startedWith);
|
|
944
|
+
return result.then((answer) => this.withPersonNote(this.withCrashNotes(answer, mark, startedWith)));
|
|
945
|
+
return this.withPersonNote(this.withCrashNotes(result, mark, startedWith));
|
|
754
946
|
});
|
|
755
947
|
}
|
|
948
|
+
/** The first call has been answered, so the "needs a person" note has had its turn. */
|
|
949
|
+
personNoteDone = false;
|
|
950
|
+
/**
|
|
951
|
+
* The first answer of the session says what only a person can do, once, when something is pending: the app isn't
|
|
952
|
+
* connected (open it on the phone, same Wi-Fi), and the phone has never paired. Nothing pending: never.
|
|
953
|
+
*/
|
|
954
|
+
withPersonNote(answer) {
|
|
955
|
+
if (this.personNoteDone || this.options.pairedBefore === undefined)
|
|
956
|
+
return answer;
|
|
957
|
+
if (this.host.current() !== undefined) {
|
|
958
|
+
this.personNoteDone = true;
|
|
959
|
+
return answer;
|
|
960
|
+
}
|
|
961
|
+
if (answer.content[0]?.type !== "text")
|
|
962
|
+
return answer; // nowhere to put it yet: the next answer says it
|
|
963
|
+
const items = [];
|
|
964
|
+
if (!this.options.pairedBefore())
|
|
965
|
+
items.push("the phone isn't paired yet (call `pair`, then the user scans the QR code or taps Connect)");
|
|
966
|
+
items.push("the app isn't connected (the user opens it on the phone, on the same Wi-Fi as this computer)");
|
|
967
|
+
this.personNoteDone = true;
|
|
968
|
+
return appendNote(answer, `Note: needs a person: ${items.join("; ")}.`);
|
|
969
|
+
}
|
|
756
970
|
/** What was told about this app's crashes so far: the crash events up to this number. */
|
|
757
971
|
notedSeqOf(app) {
|
|
758
972
|
return this.notedCrashSeqs.get(identityOf(app)) ?? this.crashBaseline;
|
|
@@ -837,6 +1051,7 @@ export class TaplineTools {
|
|
|
837
1051
|
}
|
|
838
1052
|
// -- pair
|
|
839
1053
|
async pair(args) {
|
|
1054
|
+
this.personNoteDone = true; // its answer says what the person does; no "call pair" note on it
|
|
840
1055
|
const { url, name } = this.options.connect;
|
|
841
1056
|
// Zero-QR pairing (Tapline 1.1): a development build whose JavaScript comes from this computer finds the
|
|
842
1057
|
// offer itself and asks its person to connect. Open for 10 minutes, one use, local network only.
|
|
@@ -1004,7 +1219,9 @@ export class TaplineTools {
|
|
|
1004
1219
|
return fail(`Error bad_params: step ${i + 1}: "${step.tool}" can't be a step (steps act; use it on its own call). Steps can be: ${RUN_TOOLS.join(", ")}.`);
|
|
1005
1220
|
}
|
|
1006
1221
|
const shape = toolShapes[step.tool];
|
|
1007
|
-
const { tool: _tool, expect: _expect, route: _route, ...
|
|
1222
|
+
const { tool: _tool, expect: _expect, route: _route, ...given } = step;
|
|
1223
|
+
// §9.1: a step's own look and screenshot are ignored (the batch answers once), whether its tool has them or not.
|
|
1224
|
+
const params = Object.fromEntries(Object.entries(given).filter(([key]) => !(key === "look" || key === "screenshot") || key in shape));
|
|
1008
1225
|
if (step.tool === "compare_screen" && params.update !== undefined) {
|
|
1009
1226
|
return fail(`Error bad_params: step ${i + 1}: update can't be used in run (a step never saves a reference)`);
|
|
1010
1227
|
}
|
|
@@ -1037,6 +1254,12 @@ export class TaplineTools {
|
|
|
1037
1254
|
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): launch reloads the app and tap taps in it; give one of them.`);
|
|
1038
1255
|
}
|
|
1039
1256
|
}
|
|
1257
|
+
// AGENT-TOOLS 1.11 §4: the shared wording, with the step first.
|
|
1258
|
+
const known = Object.keys(shape);
|
|
1259
|
+
const stray = Object.keys(params).filter((key) => !known.includes(key));
|
|
1260
|
+
if (stray.length > 0) {
|
|
1261
|
+
return fail(`Error bad_params: step ${i + 1}: ${step.tool} doesn't take ${stray.join(", ")}; ${known.length === 0 ? "it takes no arguments" : `it takes ${known.join(", ")}`}.`);
|
|
1262
|
+
}
|
|
1040
1263
|
const parsed = z.object(shape).strict().safeParse(params);
|
|
1041
1264
|
if (!parsed.success) {
|
|
1042
1265
|
return fail(`Error bad_params: step ${i + 1} (${describeRunStep(step)}): ${parsed.error.issues.map((issue) => `${issue.path.join(".") || "params"}: ${issue.message}`).join("; ")}`);
|
|
@@ -1145,6 +1368,7 @@ export class TaplineTools {
|
|
|
1145
1368
|
let response;
|
|
1146
1369
|
const live = this.host.current();
|
|
1147
1370
|
if (args.look === false || live === undefined) {
|
|
1371
|
+
this.markListUnseen();
|
|
1148
1372
|
response = text([first, ...tail].join("\n"));
|
|
1149
1373
|
}
|
|
1150
1374
|
else {
|
|
@@ -1409,6 +1633,11 @@ export class TaplineTools {
|
|
|
1409
1633
|
else
|
|
1410
1634
|
this.alertOpen.delete(sid);
|
|
1411
1635
|
const changesText = askChanges ? formatChanges(tree, { alertGone }) : null;
|
|
1636
|
+
// For the call log: the list the agent now has (a change block leaves it as it was, but no longer certain).
|
|
1637
|
+
if (changesText === null)
|
|
1638
|
+
this.listBase = { elements: tree.elements, stale: false, unseen: false };
|
|
1639
|
+
else if (this.listBase !== undefined)
|
|
1640
|
+
this.listBase = { elements: this.listBase.elements, stale: true, unseen: false };
|
|
1412
1641
|
// A full list gives every ref a fresh meaning, so refs are trustworthy again.
|
|
1413
1642
|
if (changesText === null)
|
|
1414
1643
|
this.personActed = false;
|
|
@@ -1478,6 +1707,11 @@ export class TaplineTools {
|
|
|
1478
1707
|
this.personNotePending = false;
|
|
1479
1708
|
return ["Note: a person used the app since your last look."];
|
|
1480
1709
|
}
|
|
1710
|
+
/** An action answered with no list and no changes: the call log can't say what is in the list any more. */
|
|
1711
|
+
markListUnseen() {
|
|
1712
|
+
if (this.listBase !== undefined)
|
|
1713
|
+
this.listBase = { ...this.listBase, unseen: true };
|
|
1714
|
+
}
|
|
1481
1715
|
/** Runs a request, then (unless disabled) appends the fresh element list and how long the app took to settle. */
|
|
1482
1716
|
async act(method, params, describe, withLook, screenshot, extraNotes,
|
|
1483
1717
|
/** True when the result means nothing was done: the first line is then the whole answer. */
|
|
@@ -1525,6 +1759,7 @@ export class TaplineTools {
|
|
|
1525
1759
|
? `Settled in ${(settledMs / 1000).toFixed(2)} s.`
|
|
1526
1760
|
: undefined;
|
|
1527
1761
|
if (withLook === false) {
|
|
1762
|
+
this.markListUnseen();
|
|
1528
1763
|
this.unlistedActions.set(session.id, (this.unlistedActions.get(session.id) ?? 0) + 1);
|
|
1529
1764
|
response = text([line, ...this.takePersonNote()].join("\n"));
|
|
1530
1765
|
}
|
|
@@ -1643,7 +1878,8 @@ export class TaplineTools {
|
|
|
1643
1878
|
? elementSchema.safeParse(result.element)
|
|
1644
1879
|
: null;
|
|
1645
1880
|
const session = this.host.current();
|
|
1646
|
-
this.
|
|
1881
|
+
const state = by === "agent" ? this.callScope.getStore() : undefined;
|
|
1882
|
+
const entry = {
|
|
1647
1883
|
by,
|
|
1648
1884
|
tool,
|
|
1649
1885
|
...loggedTarget(params),
|
|
@@ -1656,9 +1892,22 @@ export class TaplineTools {
|
|
|
1656
1892
|
...(session !== undefined ? { app: session.app.id ?? session.app.name } : {}),
|
|
1657
1893
|
...(by === "agent" && this.batch !== undefined ? { batch: this.batch } : {}),
|
|
1658
1894
|
...(by === "agent" ? (this.logExtra ?? {}) : {}),
|
|
1659
|
-
}
|
|
1895
|
+
};
|
|
1660
1896
|
if (by === "agent")
|
|
1661
1897
|
this.logExtra = undefined;
|
|
1898
|
+
if (state === undefined) {
|
|
1899
|
+
this.actionLog.write(entry);
|
|
1900
|
+
return;
|
|
1901
|
+
}
|
|
1902
|
+
if (state.tool !== tool)
|
|
1903
|
+
entry.of = state.tool;
|
|
1904
|
+
// After scrolls: was the target already in the list before the first one? (a scroll hunt is exact when it was)
|
|
1905
|
+
if (state.afterScrolls && state.tool === tool && ELEMENT_ACTIONS.has(tool)) {
|
|
1906
|
+
const known = this.knownBeforeScrolls(params);
|
|
1907
|
+
if (known !== undefined)
|
|
1908
|
+
entry.in_list = known;
|
|
1909
|
+
}
|
|
1910
|
+
state.entries.push({ entry, at: new Date() });
|
|
1662
1911
|
}
|
|
1663
1912
|
showAction(by, method, result, params, code, reason) {
|
|
1664
1913
|
const onAction = this.options.onAction;
|
|
@@ -1914,7 +2163,7 @@ export class TaplineTools {
|
|
|
1914
2163
|
async checkAgainstReference(session, name, options) {
|
|
1915
2164
|
let shot;
|
|
1916
2165
|
try {
|
|
1917
|
-
shot = await this.
|
|
2166
|
+
shot = await this.stillShot(session.id, options.width, 0.9);
|
|
1918
2167
|
}
|
|
1919
2168
|
catch (error) {
|
|
1920
2169
|
return { kind: "error", line: describeError(error) };
|
|
@@ -2062,10 +2311,35 @@ export class TaplineTools {
|
|
|
2062
2311
|
// the failure still reads without the picture
|
|
2063
2312
|
}
|
|
2064
2313
|
}
|
|
2314
|
+
/**
|
|
2315
|
+
* A picture of the screen once it stopped moving: two in a row about 150 ms apart that agree (under 0.1% changed),
|
|
2316
|
+
* at most 2 s. The app's settle sees React commits, not a native transition, so a screen pushed by the action before
|
|
2317
|
+
* can still be sliding in (found on the phone, 2026-11-12). Past the cap, the last picture.
|
|
2318
|
+
*/
|
|
2319
|
+
async stillShot(sessionId, width, quality) {
|
|
2320
|
+
let previous = await this.takeShot(sessionId, width, quality);
|
|
2321
|
+
const started = Date.now();
|
|
2322
|
+
while (Date.now() - started < this.stillCapMs) {
|
|
2323
|
+
await sleep(this.stillGapMs);
|
|
2324
|
+
const next = await this.takeShot(sessionId, width, quality);
|
|
2325
|
+
if (next.data === previous.data)
|
|
2326
|
+
return next;
|
|
2327
|
+
try {
|
|
2328
|
+
const diff = diffImages(previous, next, { mask: [] });
|
|
2329
|
+
if (diff.changedPercent < 0.1)
|
|
2330
|
+
return next;
|
|
2331
|
+
}
|
|
2332
|
+
catch {
|
|
2333
|
+
return next; // sizes differ (a rotation): nothing to wait for
|
|
2334
|
+
}
|
|
2335
|
+
previous = next;
|
|
2336
|
+
}
|
|
2337
|
+
return previous;
|
|
2338
|
+
}
|
|
2065
2339
|
/** Takes the screen and saves it as the reference `name` in `dir`. */
|
|
2066
2340
|
async saveReferenceHere(session, name, width, dir) {
|
|
2067
2341
|
try {
|
|
2068
|
-
return this.writeReference(dir, name, await this.
|
|
2342
|
+
return this.writeReference(dir, name, await this.stillShot(session.id, width, 0.9));
|
|
2069
2343
|
}
|
|
2070
2344
|
catch (error) {
|
|
2071
2345
|
return fail(describeError(error));
|
|
@@ -2243,7 +2517,7 @@ export class TaplineTools {
|
|
|
2243
2517
|
const save = (shot) => this.writeReference(folder, name, shot);
|
|
2244
2518
|
if (args.update === true) {
|
|
2245
2519
|
try {
|
|
2246
|
-
return save(await this.
|
|
2520
|
+
return save(await this.stillShot(session.id, width, 0.9));
|
|
2247
2521
|
}
|
|
2248
2522
|
catch (error) {
|
|
2249
2523
|
return fail(describeError(error));
|
|
@@ -2566,7 +2840,7 @@ export class TaplineTools {
|
|
|
2566
2840
|
this.showAction("agent", method, { element: verdict.element }, params, "not_allowed", "blocked");
|
|
2567
2841
|
return fail(this.blockedLine(verdict.element, verdict.word));
|
|
2568
2842
|
}
|
|
2569
|
-
const would = this.wouldLine(tool, shown, resolved.point, resolved.value);
|
|
2843
|
+
const would = this.wouldLine(tool, shown, resolved.point, resolved.value, resolved.scrolls);
|
|
2570
2844
|
const needs = verdict.kind === "confirm" ? ` It needs a person's OK (policy word ${JSON.stringify(verdict.word)}).` : "";
|
|
2571
2845
|
this.showAction("agent", method, shown !== undefined ? { element: shown } : undefined, params);
|
|
2572
2846
|
return text([`${would}${needs}`, ...this.takePersonNote()].join("\n"));
|
|
@@ -2684,7 +2958,7 @@ export class TaplineTools {
|
|
|
2684
2958
|
return fail(resolved.text);
|
|
2685
2959
|
}
|
|
2686
2960
|
this.showAction("agent", method, resolved.elements[0] !== undefined ? { element: resolved.elements[0] } : undefined, params);
|
|
2687
|
-
return text([this.wouldLine(tool, resolved.elements[0], resolved.point, resolved.value), ...this.takePersonNote()].join("\n"));
|
|
2961
|
+
return text([this.wouldLine(tool, resolved.elements[0], resolved.point, resolved.value, resolved.scrolls), ...this.takePersonNote()].join("\n"));
|
|
2688
2962
|
}
|
|
2689
2963
|
/**
|
|
2690
2964
|
* What a touch would act on. Apps that know dry runs resolve it themselves, with every check; older
|
|
@@ -2713,6 +2987,7 @@ export class TaplineTools {
|
|
|
2713
2987
|
elements: parsed.data.element ? [parsed.data.element] : [],
|
|
2714
2988
|
...(parsed.data.point !== undefined ? { point: parsed.data.point } : {}),
|
|
2715
2989
|
...(parsed.data.value !== undefined ? { value: parsed.data.value } : {}),
|
|
2990
|
+
...(parsed.data.scrolls === true ? { scrolls: true } : {}),
|
|
2716
2991
|
};
|
|
2717
2992
|
}
|
|
2718
2993
|
// An app without dry runs: a swipe is checked by what lies under its start.
|
|
@@ -2743,24 +3018,27 @@ export class TaplineTools {
|
|
|
2743
3018
|
};
|
|
2744
3019
|
}
|
|
2745
3020
|
/** `Would tap e12 button "Delete trip" at 215,640.` */
|
|
2746
|
-
wouldLine(tool, element, point, value) {
|
|
2747
|
-
const name = element !== undefined ? this.shortName(element, true) : "the element";
|
|
3021
|
+
wouldLine(tool, element, point, value, scrolls) {
|
|
3022
|
+
const name = element !== undefined ? this.shortName(element, true, scrolls === true) : "the element";
|
|
3023
|
+
// Off screen (§16.2): `Would scroll to it, then tap e41 button "Save" id=save at 215,640.` The point is the one expected after the scroll.
|
|
3024
|
+
const lead = scrolls === true ? "Would scroll to it, then" : "Would";
|
|
2748
3025
|
const at = point !== undefined ? ` at ${point.x},${point.y}` : "";
|
|
2749
3026
|
switch (tool) {
|
|
2750
3027
|
case "long_press":
|
|
2751
|
-
return
|
|
3028
|
+
return `${lead} long-press ${name}${at}.`;
|
|
2752
3029
|
case "toggle":
|
|
2753
|
-
return
|
|
3030
|
+
return `${lead} toggle ${name}${typeof value === "boolean" ? ` to ${String(value)}` : ""}${at}.`;
|
|
2754
3031
|
case "type_text":
|
|
2755
|
-
return
|
|
3032
|
+
return `${lead} type into ${name}${scrolls === true ? at : ""}.`;
|
|
2756
3033
|
default:
|
|
2757
|
-
return
|
|
3034
|
+
return `${lead} tap ${name}${at}.`;
|
|
2758
3035
|
}
|
|
2759
3036
|
}
|
|
2760
3037
|
/** `e12 button "Delete trip"`: ref (when asked), role and name, nothing else. */
|
|
2761
|
-
shortName(element, withRef) {
|
|
3038
|
+
shortName(element, withRef, withId = false) {
|
|
2762
3039
|
return formatElement({
|
|
2763
3040
|
ref: withRef ? element.ref : "",
|
|
3041
|
+
...(withId ? { testID: element.testID ?? null } : {}),
|
|
2764
3042
|
role: element.role ?? null,
|
|
2765
3043
|
type: element.type ?? null,
|
|
2766
3044
|
label: element.label ?? null,
|
|
@@ -2782,12 +3060,12 @@ export class TaplineTools {
|
|
|
2782
3060
|
}
|
|
2783
3061
|
tap(args) {
|
|
2784
3062
|
const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}) };
|
|
2785
|
-
return this.guarded("tap", "press", params, args.dry_run === true, (preflight) => this.act("press", params, (r) => `Tapped ${this.elementName(r)}
|
|
3063
|
+
return this.guarded("tap", "press", params, args.dry_run === true, (preflight) => this.act("press", params, (r) => afterScroll(r, `Tapped ${this.elementName(r)}.`), args.look, args.screenshot, undefined, undefined, preflight));
|
|
2786
3064
|
}
|
|
2787
3065
|
/** `seconds` is accepted for the shared interface; Tapline calls the handler, so it doesn't hold. */
|
|
2788
3066
|
longPress(args) {
|
|
2789
3067
|
const params = { target: toWireTarget(withIndex(args.target, args.index)), ...(args.force === true ? { force: true } : {}) };
|
|
2790
|
-
return this.guarded("long_press", "longPress", params, args.dry_run === true, (preflight) => this.act("longPress", params, (r) => `Long-pressed ${this.elementName(r)}
|
|
3068
|
+
return this.guarded("long_press", "longPress", params, args.dry_run === true, (preflight) => this.act("longPress", params, (r) => afterScroll(r, `Long-pressed ${this.elementName(r)}.`), args.look, args.screenshot, undefined, undefined, preflight));
|
|
2791
3069
|
}
|
|
2792
3070
|
typeText(args) {
|
|
2793
3071
|
const params = {
|
|
@@ -2807,7 +3085,7 @@ export class TaplineTools {
|
|
|
2807
3085
|
// AGENT-TOOLS 1.8 §3.3: a secure field's value is never shown, not even as bullets in this line
|
|
2808
3086
|
const secure = typeof r === "object" && r !== null && "secure" in r && r.secure === true;
|
|
2809
3087
|
const shown = secure ? " The value is hidden (secure field)." : typeof value === "string" ? ` Value is now ${JSON.stringify(value)}.` : "";
|
|
2810
|
-
return `Typed into ${this.elementName(r, "element", shown !== "")}${args.submit ? " and submitted" : ""}.${shown}
|
|
3088
|
+
return afterScroll(r, `Typed into ${this.elementName(r, "element", shown !== "")}${args.submit ? " and submitted" : ""}.${shown}`);
|
|
2811
3089
|
}, args.look, args.screenshot);
|
|
2812
3090
|
}
|
|
2813
3091
|
scroll(args) {
|
|
@@ -2876,8 +3154,8 @@ export class TaplineTools {
|
|
|
2876
3154
|
return this.guarded("toggle", "toggle", params, args.dry_run === true, (preflight) => this.act("toggle", params, (r) => {
|
|
2877
3155
|
const value = typeof r === "object" && r !== null && "value" in r ? r.value : undefined;
|
|
2878
3156
|
if (unchanged(r))
|
|
2879
|
-
return `${this.elementName(r)} is already ${JSON.stringify(value)}.`;
|
|
2880
|
-
return `Toggled ${this.elementName(r)}${value !== undefined ? ` to ${JSON.stringify(value)}` : ""}
|
|
3157
|
+
return `${scrolledFirst(r) ? "Scrolled to it; " : ""}${this.elementName(r)} is already ${JSON.stringify(value)}.`;
|
|
3158
|
+
return afterScroll(r, `Toggled ${this.elementName(r)}${value !== undefined ? ` to ${JSON.stringify(value)}` : ""}.`);
|
|
2881
3159
|
}, args.look, args.screenshot, undefined, unchanged, preflight));
|
|
2882
3160
|
}
|
|
2883
3161
|
async appCommand(args) {
|
|
@@ -2901,8 +3179,28 @@ export class TaplineTools {
|
|
|
2901
3179
|
: `${session.app.name} offers no commands.`);
|
|
2902
3180
|
}
|
|
2903
3181
|
const name = args.name.trim();
|
|
3182
|
+
// AGENT-TOOLS 1.11 §16.4: a policy whose block list holds `network` refuses a condition; `normal` always goes.
|
|
3183
|
+
if (name === "network") {
|
|
3184
|
+
const mode = args.args?.mode;
|
|
3185
|
+
const app = this.host.current()?.app;
|
|
3186
|
+
const names = (words) => (words ?? []).some((word) => word.trim().toLowerCase() === "network");
|
|
3187
|
+
const own = app === undefined ? undefined : (this.policy.apps?.[app.id ?? ""] ?? this.policy.apps?.[app.name]);
|
|
3188
|
+
if (mode !== "normal" && names(listsFor(this.policy, app).block)) {
|
|
3189
|
+
const where = own?.block !== undefined && app !== undefined ? `for ${app.name}` : "here";
|
|
3190
|
+
return fail(`Error not_allowed: blocked: the safety policy blocks network ${where}.`);
|
|
3191
|
+
}
|
|
3192
|
+
}
|
|
2904
3193
|
return this.act("command", { name, args: args.args ?? {} }, (r) => {
|
|
2905
3194
|
const result = typeof r === "object" && r !== null && "result" in r ? r.result : null;
|
|
3195
|
+
// Built in `network`: the same line every header carries while a condition is on (AGENT-TOOLS §16.4),
|
|
3196
|
+
// then one sentence on what it reaches.
|
|
3197
|
+
if (name === "network" && typeof result === "object" && result !== null && "mode" in result && typeof result.mode === "string") {
|
|
3198
|
+
const covers = "covers" in result && typeof result.covers === "string" ? ` ${result.covers}` : "";
|
|
3199
|
+
return `${networkLine(result.mode)}${covers}`;
|
|
3200
|
+
}
|
|
3201
|
+
// Standard `language`, `region` and `text_size` (AGENT-TOOLS §16.3): the app's own sentence, e.g. `Text size xl (1.12×).`
|
|
3202
|
+
if (typeof result === "object" && result !== null && "say" in result && typeof result.say === "string")
|
|
3203
|
+
return result.say;
|
|
2906
3204
|
return result === null || result === undefined ? `Ran ${name}.` : `Ran ${name}: ${JSON.stringify(result)}`;
|
|
2907
3205
|
}, args.look, args.screenshot);
|
|
2908
3206
|
}
|
|
@@ -3022,8 +3320,10 @@ export class TaplineTools {
|
|
|
3022
3320
|
if (!worked)
|
|
3023
3321
|
return fail(describeError(error));
|
|
3024
3322
|
}
|
|
3025
|
-
if (args.look === false)
|
|
3323
|
+
if (args.look === false) {
|
|
3324
|
+
this.markListUnseen();
|
|
3026
3325
|
return text("Reload requested.");
|
|
3326
|
+
}
|
|
3027
3327
|
const deadline = Date.now() + this.reloadWaitMs;
|
|
3028
3328
|
for (;;) {
|
|
3029
3329
|
const now = this.host.current();
|