@proagentstore/cli 0.4.54 → 0.4.55
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.
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every outcome `/browser/complete` can be called with, and what it means locally.
|
|
3
|
+
*
|
|
4
|
+
* Only `cancelled` changed in #636. The rest are the behaviour that was already there, written
|
|
5
|
+
* down: `blocked` stays its own status because the agent stopped and needs the USER (email
|
|
6
|
+
* verification, something it cannot answer truthfully) — needs-attention, not a failure, and
|
|
7
|
+
* the console shows it as an active ticket rather than burying it.
|
|
8
|
+
*
|
|
9
|
+
* `captcha`/`stuck`/`needs_input` reach here only through the workflow's handoff TIMEOUT,
|
|
10
|
+
* which calls this endpoint with `failed` and a "… not resolved in time" detail — so their
|
|
11
|
+
* rows are a backstop for a direct call. They are `failed` because `TaskStatus` has no
|
|
12
|
+
* `escalated`, which is what the cloud's run record calls them; the honest local equivalent
|
|
13
|
+
* would be `needs_human`, but a task in that state is not finished, and every call to this
|
|
14
|
+
* method IS the finish. Left as-is deliberately rather than changed under an unrelated fix.
|
|
15
|
+
*/
|
|
16
|
+
export const APPLY_OUTCOME_DISPOSITION = {
|
|
17
|
+
submitted: { status: "completed", event: "task.completed", error: false },
|
|
18
|
+
ready: { status: "completed", event: "task.completed", error: false },
|
|
19
|
+
expired: { status: "completed", event: "task.completed", error: false },
|
|
20
|
+
blocked: { status: "blocked", event: "task.failed", error: true },
|
|
21
|
+
captcha: { status: "failed", event: "task.failed", error: true },
|
|
22
|
+
stuck: { status: "failed", event: "task.failed", error: true },
|
|
23
|
+
needs_input: { status: "failed", event: "task.failed", error: true },
|
|
24
|
+
failed: { status: "failed", event: "task.failed", error: true },
|
|
25
|
+
max_steps: { status: "failed", event: "task.failed", error: true },
|
|
26
|
+
cancelled: { status: "cancelled", event: "task.cancelled", error: false },
|
|
27
|
+
};
|
|
28
|
+
/**
|
|
29
|
+
* The disposition for an outcome off the wire.
|
|
30
|
+
*
|
|
31
|
+
* `outcome` is a string from an HTTP body: a newer cloud can send one this bundled runner has
|
|
32
|
+
* never heard of, since the runner ships inside a CLI the user upgrades on their own schedule.
|
|
33
|
+
* An unknown outcome falls back to `failed` — the same `?? "failed"` the cloud's
|
|
34
|
+
* `browserRunStopReason` uses — because "something ended and I cannot say it went well" is the
|
|
35
|
+
* only honest reading of a word this build does not know.
|
|
36
|
+
*/
|
|
37
|
+
export function dispositionForOutcome(outcome) {
|
|
38
|
+
return APPLY_OUTCOME_DISPOSITION[outcome] ?? APPLY_OUTCOME_DISPOSITION.failed;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Write a finished run's outcome onto the task, and name the event to append.
|
|
42
|
+
*
|
|
43
|
+
* Pure apart from the mutation of the task it is handed, so the whole disposition — status,
|
|
44
|
+
* error-or-not, which event — is decided in one tested place instead of inside a method that
|
|
45
|
+
* also detaches CDP sessions and closes pages.
|
|
46
|
+
*/
|
|
47
|
+
export function settleTaskOutcome(task, outcome, detail) {
|
|
48
|
+
const disposition = dispositionForOutcome(outcome);
|
|
49
|
+
task.status = disposition.status;
|
|
50
|
+
task.output = { outcome, detail };
|
|
51
|
+
if (disposition.error)
|
|
52
|
+
task.error = detail || outcome;
|
|
53
|
+
task.updatedAt = new Date().toISOString();
|
|
54
|
+
task.completedAt = task.updatedAt;
|
|
55
|
+
return disposition;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The statuses a task cannot be moved out of — what `cancelTask` refuses to overwrite.
|
|
59
|
+
*
|
|
60
|
+
* `blocked` is deliberately NOT here: a blocked task is waiting on the owner, and Stop is the
|
|
61
|
+
* owner answering. `cancelled` IS here, and was the member the old `completed || failed` pair
|
|
62
|
+
* missed (#636) — a second Stop rewrote the timestamps and appended a duplicate
|
|
63
|
+
* `task.cancelled`. `completed` and `failed` stay untouchable in the other direction: a run
|
|
64
|
+
* that genuinely finished must not be relabelled as cancelled, because it was not.
|
|
65
|
+
*/
|
|
66
|
+
export const TERMINAL_TASK_STATUSES = ["completed", "failed", "cancelled"];
|
|
67
|
+
/** Has this task already finished, whichever way it finished? */
|
|
68
|
+
export function isTerminalTaskStatus(status) {
|
|
69
|
+
return TERMINAL_TASK_STATUSES.includes(status);
|
|
70
|
+
}
|
|
@@ -2,6 +2,7 @@ import { spawn } from "node:child_process";
|
|
|
2
2
|
import { classifyCommand, commandFromToolInput, fillTargetFromResult, toolCallOk, toolResultMark } from "./engine-acts.js";
|
|
3
3
|
import { parseEngineUsage } from "./engine-usage.js";
|
|
4
4
|
import { turnReportFromExit, turnReportFromResult } from "./engine-turn.js";
|
|
5
|
+
import { renderToolResult, shortInput, stripAnsi } from "./transcript-lines.js";
|
|
5
6
|
/**
|
|
6
7
|
* Merge the platform's resolved engine env over the machine's, where an EMPTY value means
|
|
7
8
|
* REMOVE rather than "set to empty".
|
|
@@ -115,6 +116,15 @@ export class HeadlessSession {
|
|
|
115
116
|
* damages the record exactly as much as missing a real one.
|
|
116
117
|
*/
|
|
117
118
|
awaitingResult = new Map();
|
|
119
|
+
/**
|
|
120
|
+
* `tool_use_id` → tool name, for the ONE decision the result event cannot make on its own: how
|
|
121
|
+
* much of it reaches the pane (#700, `toolResultBudget` in `transcript-lines.ts`).
|
|
122
|
+
*
|
|
123
|
+
* Separate from {@link awaitingResult}, which holds only the small minority of calls that
|
|
124
|
+
* classify as consequential acts. Entries are deleted when their result arrives and the whole
|
|
125
|
+
* map is cleared at the turn boundary, where a call that never got one is known never to.
|
|
126
|
+
*/
|
|
127
|
+
toolNames = new Map();
|
|
118
128
|
/** Turn counter — only used to build a fallback id when the CLI's event has no `uuid`. */
|
|
119
129
|
usageSeq = 0;
|
|
120
130
|
/**
|
|
@@ -597,7 +607,13 @@ export class HeadlessSession {
|
|
|
597
607
|
this.push(`[${stamp()}] ${block.text.trim()}`); // timestamped agent reply
|
|
598
608
|
}
|
|
599
609
|
else if (block.type === "tool_use") {
|
|
600
|
-
|
|
610
|
+
const name = String(block.name ?? "tool");
|
|
611
|
+
this.push(`⚙ ${name} ${shortInput(block.input)}`); // ⚙
|
|
612
|
+
// The result arrives in a LATER event carrying only `tool_use_id`, and how much
|
|
613
|
+
// of it reaches the pane depends on which tool it was (#700) — so the name is
|
|
614
|
+
// remembered here and read back in `settleAct`'s sibling branch below.
|
|
615
|
+
if (typeof block.id === "string" && block.id)
|
|
616
|
+
this.toolNames.set(block.id, name);
|
|
601
617
|
this.noteAct(block);
|
|
602
618
|
}
|
|
603
619
|
}
|
|
@@ -605,7 +621,12 @@ export class HeadlessSession {
|
|
|
605
621
|
case "user": // tool results come back as a synthetic user message
|
|
606
622
|
for (const block of ev.message?.content ?? []) {
|
|
607
623
|
if (block.type === "tool_result") {
|
|
608
|
-
|
|
624
|
+
const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
625
|
+
// `""` when the call was not seen (a pane that began mid-turn, a runner restart):
|
|
626
|
+
// an unknown tool takes the conservative budget rather than the generous one.
|
|
627
|
+
const tool = this.toolNames.get(id) ?? "";
|
|
628
|
+
this.toolNames.delete(id);
|
|
629
|
+
this.push(renderToolResult(toolResultMark(block), block.content, tool)); // ↳✓ / ↳✗ (#597)
|
|
609
630
|
this.settleAct(block);
|
|
610
631
|
}
|
|
611
632
|
}
|
|
@@ -633,13 +654,16 @@ export class HeadlessSession {
|
|
|
633
654
|
// never saw whether it worked" is a materially different claim from silence, and
|
|
634
655
|
// silence is what a supervisor would read as "it did nothing".
|
|
635
656
|
this.flushAwaitingActs();
|
|
657
|
+
this.toolNames.clear(); // no further result is coming for anything still named here
|
|
636
658
|
this.run = "idle"; // the turn is OVER — a fact, not a guess
|
|
637
659
|
break;
|
|
638
660
|
}
|
|
639
661
|
default:
|
|
640
662
|
break;
|
|
641
663
|
}
|
|
642
|
-
// Keep the in-memory transcript bounded.
|
|
664
|
+
// Keep the in-memory transcript bounded. Counts ENTRIES, and an entry may now be a
|
|
665
|
+
// multi-line block (a result, or a long assistant reply) rather than one line — the
|
|
666
|
+
// character bound that matters is `MAX_PANE` in runtime.ts, applied on the way out.
|
|
643
667
|
if (this.transcript.length > 4000)
|
|
644
668
|
this.transcript = this.transcript.slice(-3000);
|
|
645
669
|
}
|
|
@@ -665,8 +689,8 @@ export class HeadlessSession {
|
|
|
665
689
|
*
|
|
666
690
|
* The result carries more than the outcome: `gh pr create --fill` states its PR number nowhere
|
|
667
691
|
* but its own stdout, so an unnumbered `pr.open`/`pr.merge` takes it from here (#417). It is read
|
|
668
|
-
* from the RAW `block.content`, never from `
|
|
669
|
-
*
|
|
692
|
+
* from the RAW `block.content`, never from `renderToolResult()`'s display lines — those are cut to
|
|
693
|
+
* the pane's budget (`transcript-lines.ts`) and would drop the URL off a verbose result.
|
|
670
694
|
*
|
|
671
695
|
* This path (and `noteAct`) is reachable ONLY from the structured stream-json handling above
|
|
672
696
|
* (`assistant` → `tool_use`, `user` → `tool_result`). A Codex/Grok session is a raw spawn with no
|
|
@@ -820,36 +844,6 @@ export function buildClaudeArgs(userArgs, resumeId) {
|
|
|
820
844
|
args.push("--resume", resumeId);
|
|
821
845
|
return args;
|
|
822
846
|
}
|
|
823
|
-
/** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
|
|
824
|
-
function stripAnsi(s) {
|
|
825
|
-
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape/control codes from terminal output.
|
|
826
|
-
return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[()][AB0-2]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
|
|
827
|
-
}
|
|
828
|
-
/** Compact a tool_use input object to a single readable line. */
|
|
829
|
-
function shortInput(input) {
|
|
830
|
-
if (input == null)
|
|
831
|
-
return "";
|
|
832
|
-
try {
|
|
833
|
-
const s = typeof input === "string" ? input : JSON.stringify(input);
|
|
834
|
-
return s.length > 160 ? `${s.slice(0, 160)}…` : s;
|
|
835
|
-
}
|
|
836
|
-
catch {
|
|
837
|
-
return "";
|
|
838
|
-
}
|
|
839
|
-
}
|
|
840
|
-
/** Render a tool_result's content (string, or array of {type:text,text}) to text. */
|
|
841
|
-
function toolResult(content) {
|
|
842
|
-
let text = "";
|
|
843
|
-
if (typeof content === "string")
|
|
844
|
-
text = content;
|
|
845
|
-
else if (Array.isArray(content)) {
|
|
846
|
-
text = content
|
|
847
|
-
.map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
|
|
848
|
-
.join(" ");
|
|
849
|
-
}
|
|
850
|
-
text = text.replace(/\s+/g, " ").trim();
|
|
851
|
-
return text.length > 240 ? `${text.slice(0, 240)}…` : text;
|
|
852
|
-
}
|
|
853
847
|
function loadFile(path) {
|
|
854
848
|
if (!path || !existsSync(path))
|
|
855
849
|
return {};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How an engine event becomes a line in the pane — the runner's half of #700.
|
|
3
|
+
*
|
|
4
|
+
* ── The defect this file exists to remove
|
|
5
|
+
*
|
|
6
|
+
* `headless.ts` rendered every `tool_result` through a four-line helper that did two things before
|
|
7
|
+
* the pane existed: it collapsed `\s+` to a single space, and it cut the text to 240 characters.
|
|
8
|
+
* Both losses were total and neither was disclosed beyond a trailing ellipsis.
|
|
9
|
+
*
|
|
10
|
+
* The consequence is not "results are short". It is that **the shape of a result no longer depends
|
|
11
|
+
* on the command that produced it.** `cat`, `sed -n '1,50p'`, `cat -n | head -60` and "print every
|
|
12
|
+
* character" all arrive at the same size, on one line, with the indentation gone. A Pilot that
|
|
13
|
+
* cannot see a file therefore has no move: every rephrasing it can reach is the same request with
|
|
14
|
+
* the same bound. One live run spent twelve of its sixteen decisions searching that empty space and
|
|
15
|
+
* concluded — reasonably, on the evidence in front of it — that "the CLI is summarizing file
|
|
16
|
+
* contents". It was not; the evidence had been destroyed upstream.
|
|
17
|
+
*
|
|
18
|
+
* Measured across 20 sessions of one instance: **150 of 251 stored tool-result lines (59.8%) hit
|
|
19
|
+
* the 240-character cap** — 102 `Bash`, 43 `Read` — with a maximum line length of 246. The cap was
|
|
20
|
+
* the common case, and it fired hardest on `Read`, the tool whose entire job is returning file
|
|
21
|
+
* contents.
|
|
22
|
+
*
|
|
23
|
+
* ── What replaces it, and what it costs
|
|
24
|
+
*
|
|
25
|
+
* Three changes, and the reason each is separate:
|
|
26
|
+
*
|
|
27
|
+
* 1. **Line structure survives.** Newlines are kept and continuation lines carry
|
|
28
|
+
* {@link RESULT_CONT_PREFIX}. This is the half that matters most and it costs almost nothing:
|
|
29
|
+
* `cat -n` output is unreadable as one space-joined line at ANY length, so the collapse made
|
|
30
|
+
* the cap worse than its size alone implies.
|
|
31
|
+
* 2. **The cap is per tool** ({@link toolResultBudget}), not one number. A `Read` result is the
|
|
32
|
+
* case that needs room; an `Edit`'s "has been updated successfully" is a status string that was
|
|
33
|
+
* never near 240 anyway. Raising one number for everything would buy file text by spending the
|
|
34
|
+
* pane on results that carry none.
|
|
35
|
+
* 3. **A cut states its own size** (`…[cut: 1,500 of 18,432 chars]`), so the reader learns there
|
|
36
|
+
* is more AND how much — which is what makes "ask for a slice that fits" a strategy rather than
|
|
37
|
+
* a guess. Same disclosure discipline as `repo_read_file`'s window header and `repo_git`'s
|
|
38
|
+
* TRUNCATED note on the cloud side.
|
|
39
|
+
*
|
|
40
|
+
* **The cost is real and it is the pane.** A `terminal` row stores the last 8,000 characters
|
|
41
|
+
* (`TERMINAL_SNAPSHOT_CHARS`) and one Pilot decision reads the last 6,000 (`PILOT_PANE_CHARS`), so
|
|
42
|
+
* every character bought for file text is bought by evicting history — including the `⚙`/`↳`
|
|
43
|
+
* framing `engine-tool-calls.ts` parses back out and the engine-error lines #580 exists to keep
|
|
44
|
+
* visible. {@link RESULT_CAP_CONTENT_CHARS} is therefore derived from that window rather than
|
|
45
|
+
* chosen from it — see {@link RESULT_CAP_CONTENT_CHARS} and {@link RESULT_RENDERED_MAX}, which
|
|
46
|
+
* state the content budget and what it actually costs the pane once framing is paid for.
|
|
47
|
+
*
|
|
48
|
+
* ── Old runners, and why nothing gates on a version
|
|
49
|
+
*
|
|
50
|
+
* A machine that has not upgraded keeps the 240-char single-line behaviour, so every cloud reader
|
|
51
|
+
* must go on tolerating short, structureless results — `engine-tool-calls.ts` already models that
|
|
52
|
+
* (`outputCut`) and continues to. Continuation lines are read by their PREFIX, not by a version
|
|
53
|
+
* probe: a pane is judged by what it contains, the way the `↳✓` outcome marker is.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* The prefix every line of a result after the first carries.
|
|
57
|
+
*
|
|
58
|
+
* It exists for two reasons and the second is the load-bearing one:
|
|
59
|
+
*
|
|
60
|
+
* - the pane stays readable as a transcript rather than as an unindented dump, and
|
|
61
|
+
* - a result can no longer FORGE the framing. `parseEngineToolCalls` decides what a line is from
|
|
62
|
+
* its first characters, so a file containing a line that begins `⚙ ` or `↳` would, once
|
|
63
|
+
* newlines survived, parse as a tool call the engine never made. Every continuation line begins
|
|
64
|
+
* with this prefix instead, so a result's own text can never start a line that the parser reads
|
|
65
|
+
* as anything else.
|
|
66
|
+
*
|
|
67
|
+
* `│` (U+2502), not `|`: a markdown table in an engine's reply uses the ASCII pipe, and the point
|
|
68
|
+
* of the character is that transcript prose does not use it.
|
|
69
|
+
*/
|
|
70
|
+
export const RESULT_CONT_PREFIX = " │ ";
|
|
71
|
+
/**
|
|
72
|
+
* How much of a CONTENT tool's result — its own characters, framing excluded — reaches the pane.
|
|
73
|
+
*
|
|
74
|
+
* Derived, not picked: the Pilot reads the last 6,000 characters of the pane
|
|
75
|
+
* (`PILOT_PANE_CHARS`, `workers/api/src/lib/coding-repetition.ts`), and no single result may take
|
|
76
|
+
* more than a QUARTER of that in content, which is ~35 lines of ordinary source — what a bounded
|
|
77
|
+
* slice request actually asks for. What that costs the pane in full is {@link RESULT_RENDERED_MAX},
|
|
78
|
+
* and it is the larger number, because framing is not free.
|
|
79
|
+
*
|
|
80
|
+
* The two constants are separate declarations in separate packages for the reason `RESULT_ARROW` is
|
|
81
|
+
* (a Worker must not import the runner's Node package); the derivation is stated here so that
|
|
82
|
+
* moving one without the other is at least a visible mistake.
|
|
83
|
+
*
|
|
84
|
+
* `used` and `total` in the cut notice are BOTH content characters, deliberately: the number is
|
|
85
|
+
* only actionable if the model can compare it against the file it is asking for.
|
|
86
|
+
*/
|
|
87
|
+
export const RESULT_CAP_CONTENT_CHARS = 1500;
|
|
88
|
+
/**
|
|
89
|
+
* A ceiling on LINES as well as characters, because the prefix is charged per line.
|
|
90
|
+
*
|
|
91
|
+
* A result of 1,500 one-character lines would pay {@link RESULT_CONT_PREFIX} 1,500 times — 6,000
|
|
92
|
+
* characters of framing for 1,500 of content, i.e. the whole of the Pilot's window spent on
|
|
93
|
+
* indentation. 60 lines bounds that overhead at 240 characters.
|
|
94
|
+
*/
|
|
95
|
+
export const RESULT_CAP_CONTENT_LINES = 60;
|
|
96
|
+
/** Every other tool keeps the historical 240 characters — see {@link CONTENT_TOOLS}. */
|
|
97
|
+
export const RESULT_CAP_DEFAULT_CHARS = 240;
|
|
98
|
+
/** …and at most six lines of it, so a status string cannot spend six prefixes to say nothing. */
|
|
99
|
+
export const RESULT_CAP_DEFAULT_LINES = 6;
|
|
100
|
+
/** The longest cut notice the renderer can emit — `…[cut: 999,999,999 of 999,999,999 chars]`. */
|
|
101
|
+
const CUT_NOTICE_MAX = 45;
|
|
102
|
+
/**
|
|
103
|
+
* What the WHOLE result block can cost the pane, framing and disclosure included.
|
|
104
|
+
*
|
|
105
|
+
* Stated because the content budget alone is not the honest figure and pretending otherwise is how
|
|
106
|
+
* a cap gets quietly exceeded: the arrow prefix, one `RESULT_CONT_PREFIX` and one newline per
|
|
107
|
+
* continuation line, and the cut notice are all pane characters too. At the current numbers that is
|
|
108
|
+
* ~1,840 — under a THIRD of the Pilot's 6,000-character window, so three consecutive content
|
|
109
|
+
* results still coexist with the narrative and the `⚙`/`↳` framing rather than evicting them.
|
|
110
|
+
*
|
|
111
|
+
* If someone widens {@link RESULT_CAP_CONTENT_CHARS}, this is the number that says what it costs.
|
|
112
|
+
*/
|
|
113
|
+
export const RESULT_RENDERED_MAX = " ↳✓ ".length + RESULT_CAP_CONTENT_CHARS + (RESULT_CAP_CONTENT_LINES - 1) * (RESULT_CONT_PREFIX.length + 1) + CUT_NOTICE_MAX;
|
|
114
|
+
/**
|
|
115
|
+
* The tools whose RESULT is the answer, rather than a receipt for an action.
|
|
116
|
+
*
|
|
117
|
+
* `Read`/`Bash` are the two the measurement names (43 and 102 of the 150 truncated lines).
|
|
118
|
+
* `Grep`/`Glob`/`BashOutput`/`NotebookRead` are here because their results are line-structured
|
|
119
|
+
* listings — the same content, arriving through a different tool name — and a Pilot that asked for
|
|
120
|
+
* a grep instead of a cat should not be punished for it.
|
|
121
|
+
*
|
|
122
|
+
* `Edit`, `Write`, `MultiEdit` and `TodoWrite` are deliberately absent: their results are status
|
|
123
|
+
* strings ("has been updated successfully…", 176-214 characters in the sampled sessions) that the
|
|
124
|
+
* old cap barely touched, so widening them would spend the pane and buy nothing.
|
|
125
|
+
*/
|
|
126
|
+
export const CONTENT_TOOLS = new Set(["Read", "Bash", "BashOutput", "Grep", "Glob", "NotebookRead"]);
|
|
127
|
+
/** The budget for one tool's result. An unknown or unnamed tool gets the conservative one. */
|
|
128
|
+
export function toolResultBudget(tool) {
|
|
129
|
+
return CONTENT_TOOLS.has(tool)
|
|
130
|
+
? { chars: RESULT_CAP_CONTENT_CHARS, lines: RESULT_CAP_CONTENT_LINES }
|
|
131
|
+
: { chars: RESULT_CAP_DEFAULT_CHARS, lines: RESULT_CAP_DEFAULT_LINES };
|
|
132
|
+
}
|
|
133
|
+
/** Strip ANSI/VT escape sequences so a raw CLI's coloured output reads as plain text. */
|
|
134
|
+
export function stripAnsi(s) {
|
|
135
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: stripping terminal escape/control codes from terminal output.
|
|
136
|
+
return s.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "").replace(/\x1b[()][AB0-2]/g, "").replace(/[\x00-\x08\x0b\x0c\x0e-\x1f]/g, "");
|
|
137
|
+
}
|
|
138
|
+
/** Compact a tool_use input object to a single readable line. */
|
|
139
|
+
export function shortInput(input) {
|
|
140
|
+
if (input == null)
|
|
141
|
+
return "";
|
|
142
|
+
try {
|
|
143
|
+
const s = typeof input === "string" ? input : JSON.stringify(input);
|
|
144
|
+
return s.length > 160 ? `${s.slice(0, 160)}…` : s;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
return "";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
/** Render a tool_result's content (string, or array of `{type:text,text}`) to raw text. */
|
|
151
|
+
export function toolResultText(content) {
|
|
152
|
+
if (typeof content === "string")
|
|
153
|
+
return content;
|
|
154
|
+
if (!Array.isArray(content))
|
|
155
|
+
return "";
|
|
156
|
+
// Joined with a NEWLINE, not a space: separate content blocks are separate pieces of output,
|
|
157
|
+
// and the whole point of this module is that the difference is preserved.
|
|
158
|
+
return content
|
|
159
|
+
.map((b) => (b && typeof b === "object" && "text" in b ? String(b.text) : ""))
|
|
160
|
+
.filter((t) => t !== "")
|
|
161
|
+
.join("\n");
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Normalise a result's text to display lines: no CR, no escape codes, no trailing whitespace, and
|
|
165
|
+
* no runs of blank lines. Every one of those is pane budget spent on nothing.
|
|
166
|
+
*/
|
|
167
|
+
function tidyLines(text) {
|
|
168
|
+
const out = [];
|
|
169
|
+
for (const raw of stripAnsi(text.replace(/\r\n?/g, "\n")).split("\n")) {
|
|
170
|
+
const line = raw.trimEnd();
|
|
171
|
+
// Drop a leading blank, and collapse a run of blanks to one.
|
|
172
|
+
if (!line && (out.length === 0 || out[out.length - 1] === ""))
|
|
173
|
+
continue;
|
|
174
|
+
out.push(line);
|
|
175
|
+
}
|
|
176
|
+
while (out.length && out[out.length - 1] === "")
|
|
177
|
+
out.pop();
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
/**
|
|
181
|
+
* The full transcript entry for one `tool_result` — the arrow line plus any continuation lines.
|
|
182
|
+
*
|
|
183
|
+
* The head is cut rather than the tail, unchanged from the old helper: a file, a listing and a
|
|
184
|
+
* grep all answer from their beginning, and a test run's tail is recoverable from the engine's own
|
|
185
|
+
* reply text, which is never truncated. Changing that is a separate decision and is not made here.
|
|
186
|
+
*/
|
|
187
|
+
export function renderToolResult(mark, content, tool) {
|
|
188
|
+
const lines = tidyLines(toolResultText(content));
|
|
189
|
+
const budget = toolResultBudget(tool);
|
|
190
|
+
const total = lines.join("\n").length;
|
|
191
|
+
const kept = [];
|
|
192
|
+
let used = 0;
|
|
193
|
+
let cut = false;
|
|
194
|
+
for (const line of lines) {
|
|
195
|
+
if (kept.length >= budget.lines) {
|
|
196
|
+
cut = true;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
const room = budget.chars - used;
|
|
200
|
+
if (room <= 0) {
|
|
201
|
+
cut = true;
|
|
202
|
+
break;
|
|
203
|
+
}
|
|
204
|
+
if (line.length > room) {
|
|
205
|
+
kept.push(line.slice(0, room));
|
|
206
|
+
used += room;
|
|
207
|
+
cut = true;
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
kept.push(line);
|
|
211
|
+
used += line.length;
|
|
212
|
+
}
|
|
213
|
+
if (cut && kept.length) {
|
|
214
|
+
// The figures, not just the ellipsis: "there was more" tells the reader to give up, "1,500
|
|
215
|
+
// of 18,432" tells it how narrow a slice would arrive whole. The ellipsis stays because it
|
|
216
|
+
// is what every runner ever written has used to mark a cut, and readers key on it.
|
|
217
|
+
kept[kept.length - 1] = `${kept[kept.length - 1]}…[cut: ${used.toLocaleString("en-US")} of ${total.toLocaleString("en-US")} chars]`;
|
|
218
|
+
}
|
|
219
|
+
const head = ` ↳${mark} ${kept[0] ?? ""}`.trimEnd();
|
|
220
|
+
if (kept.length < 2)
|
|
221
|
+
return head;
|
|
222
|
+
return [head, ...kept.slice(1).map((l) => `${RESULT_CONT_PREFIX}${l}`)].join("\n");
|
|
223
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a handoff-status poll can answer WITHOUT looking at the live page (#641).
|
|
3
|
+
*
|
|
4
|
+
* A handoff is the runner asking a human for something the brain cannot do: solve a
|
|
5
|
+
* challenge, perform one widget step it failed at, or supply a value it must not invent.
|
|
6
|
+
* The remote Workflow polls `browserHandoffStatus` until it is told the human acted, and
|
|
7
|
+
* `solved: true` is a licence to resume the application — so the only safe answers are the
|
|
8
|
+
* ones the runner can actually stand behind.
|
|
9
|
+
*
|
|
10
|
+
* ── The rule
|
|
11
|
+
*
|
|
12
|
+
* A handoff whose PAGE IS GONE is LOST, not resolved. It is the same category as a handoff
|
|
13
|
+
* whose SESSION is gone (the runner restarted, the takeover expired), which the caller
|
|
14
|
+
* already answers `solved: false` under a comment stating the rule outright: *a status poll
|
|
15
|
+
* must never relaunch the browser or claim a lost handoff is done.* Three lines below that
|
|
16
|
+
* comment sat `if (page.isClosed()) return { solved: true }`, ahead of all three per-reason
|
|
17
|
+
* branches, so it overrode every one of them:
|
|
18
|
+
*
|
|
19
|
+
* - `challenge` — the brain resumed with the CAPTCHA unsolved, and the claim was acted
|
|
20
|
+
* on rather than re-checked: the workflow records `solvedChallengeUrl`, and the apply
|
|
21
|
+
* loop then suppresses captcha re-detection on that page for the rest of the round. A
|
|
22
|
+
* false "solved" muted the detector that would have caught it.
|
|
23
|
+
* - `stuck` — resumed without the human doing the single step the handoff exists for;
|
|
24
|
+
* `humanDone` was never consulted.
|
|
25
|
+
* - `needs_input` — resumed with `value: undefined`, so the workflow skipped the save and
|
|
26
|
+
* the brain re-asked on the next round for a value it had been given.
|
|
27
|
+
*
|
|
28
|
+
* There is nothing to resume ONTO in any of those cases: the page the human was working in
|
|
29
|
+
* no longer exists, and the brain's next action would land on a freshly created blank tab.
|
|
30
|
+
* Staying unsolved lets the wait time out into "not resolved in time", which the cloud maps
|
|
31
|
+
* to `escalated` → the board's "Needs you" column — visible, and retryable by the owner.
|
|
32
|
+
*
|
|
33
|
+
* ── The one exception, and why it is not one
|
|
34
|
+
*
|
|
35
|
+
* `needs_input` is answered BEFORE page liveness is considered, because its answer never
|
|
36
|
+
* involved the page: the value arrives out of band through `browserSubmitInput`, which
|
|
37
|
+
* writes it onto the session. A value the owner typed is a real answer whether or not the
|
|
38
|
+
* tab survived, and discarding it would re-ask for something already in hand.
|
|
39
|
+
*/
|
|
40
|
+
/**
|
|
41
|
+
* The answer to a handoff-status poll, or `null` when only the live DOM can give one — a
|
|
42
|
+
* `challenge` on a page that is still open, whose token clears in the page itself.
|
|
43
|
+
*/
|
|
44
|
+
export function resolveHandoffStatus(facts) {
|
|
45
|
+
// Out of band, and therefore unaffected by a page that has gone.
|
|
46
|
+
if (facts.reason === "needs_input")
|
|
47
|
+
return { solved: !!facts.inputValue, challenge: null, value: facts.inputValue };
|
|
48
|
+
// Lost, not done. See the note above — this line is the fix for #641.
|
|
49
|
+
if (facts.pageClosed)
|
|
50
|
+
return { solved: false, challenge: null };
|
|
51
|
+
// A stuck handoff resumes only when the human explicitly clicks Resume — there's
|
|
52
|
+
// nothing to auto-detect.
|
|
53
|
+
if (facts.reason === "stuck")
|
|
54
|
+
return { solved: !!facts.humanDone, challenge: null };
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
@@ -5,9 +5,11 @@ import { basename, dirname, join, resolve } from "node:path";
|
|
|
5
5
|
import { pathToFileURL } from "node:url";
|
|
6
6
|
import { captureScreenshotDataUrl, challengeSolved, detectHumanChallenge } from "./challenge.js";
|
|
7
7
|
import { resolveRealChromeProfileDir, seedProfileCopy } from "./browser-profile.js";
|
|
8
|
+
import { isTerminalTaskStatus, settleTaskOutcome } from "./apply-outcome.js";
|
|
8
9
|
import { McpRuntime } from "./mcp-runtime.js";
|
|
9
10
|
import { commitLabelRe, ELEMENT_PROBE_FN, FOCUS_PROBE_FN, refuseClick, refuseKey } from "./commit-guard.js";
|
|
10
11
|
import { HumanHandoffError, RunnerInputError } from "./errors.js";
|
|
12
|
+
import { resolveHandoffStatus } from "./handoff-status.js";
|
|
11
13
|
import { RunnerStore } from "./store.js";
|
|
12
14
|
import { CodingRuntime } from "./coding/runtime.js";
|
|
13
15
|
import { WORKFLOW_DRIVEN_TASKS } from "./task-types.js";
|
|
@@ -170,7 +172,11 @@ export class LocalRunner {
|
|
|
170
172
|
}
|
|
171
173
|
cancelTask(id) {
|
|
172
174
|
const task = this.requireTask(id);
|
|
173
|
-
|
|
175
|
+
// Already finished, whichever way — the old `completed || failed` pair missed `cancelled`, so
|
|
176
|
+
// a second Stop rewrote the timestamps and appended a duplicate event (#636). A run that
|
|
177
|
+
// genuinely completed or failed is NOT relabelled: it ended before the Stop arrived. Off THIS
|
|
178
|
+
// return the cloud mirrored `task.cancelled` carrying `{status:"failed"}` — fixed in the cloud.
|
|
179
|
+
if (isTerminalTaskStatus(task.status))
|
|
174
180
|
return task;
|
|
175
181
|
task.status = "cancelled";
|
|
176
182
|
task.updatedAt = new Date().toISOString();
|
|
@@ -1144,15 +1150,12 @@ export class LocalRunner {
|
|
|
1144
1150
|
if (!session)
|
|
1145
1151
|
return { solved: false, challenge: null };
|
|
1146
1152
|
const page = session.page;
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
//
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
// there's nothing to auto-detect.
|
|
1154
|
-
if (session.reason === "stuck")
|
|
1155
|
-
return { solved: !!session.humanDone, challenge: null };
|
|
1153
|
+
// Every answer that needs no live DOM — including a GONE page, which used to report
|
|
1154
|
+
// solved:true and resume past an unsolved challenge (#641) — comes from
|
|
1155
|
+
// handoff-status.ts; null means only an open challenge's own DOM can answer.
|
|
1156
|
+
const offline = resolveHandoffStatus({ reason: session.reason, pageClosed: page.isClosed(), inputValue: session.inputValue, humanDone: session.humanDone });
|
|
1157
|
+
if (offline)
|
|
1158
|
+
return offline;
|
|
1156
1159
|
// A challenge resumes when the token/widget clears OR the human clicks Done —
|
|
1157
1160
|
// custom captchas (e.g. PageUp's "not a robot") have no detectable token, so
|
|
1158
1161
|
// the human's explicit Done is the authority; never strand them.
|
|
@@ -1197,23 +1200,17 @@ export class LocalRunner {
|
|
|
1197
1200
|
}
|
|
1198
1201
|
return { ok: true };
|
|
1199
1202
|
}
|
|
1200
|
-
/** Finalize an agent-driven application
|
|
1203
|
+
/** Finalize an agent-driven application — the outcome→status table lives in apply-outcome.ts. */
|
|
1201
1204
|
async browserComplete(taskId, outcome, detail) {
|
|
1202
1205
|
await this.endTakeover(taskId).catch(() => undefined);
|
|
1203
1206
|
const task = this.store.getTask(taskId);
|
|
1204
1207
|
if (task) {
|
|
1205
|
-
|
|
1206
|
-
//
|
|
1207
|
-
//
|
|
1208
|
-
|
|
1209
|
-
task.status = outcome === "blocked" ? "blocked" : success ? "completed" : "failed";
|
|
1210
|
-
task.output = { outcome, detail };
|
|
1211
|
-
if (!success)
|
|
1212
|
-
task.error = detail || outcome;
|
|
1213
|
-
task.updatedAt = new Date().toISOString();
|
|
1214
|
-
task.completedAt = task.updatedAt;
|
|
1208
|
+
// A TOTAL table, not a chain of string comparisons: `cancelled` matched none of
|
|
1209
|
+
// them and fell through to `failed`, so a run the owner stopped was filed as an
|
|
1210
|
+
// error with a Retry button on it (#636). See apply-outcome.ts.
|
|
1211
|
+
const { event } = settleTaskOutcome(task, outcome, detail);
|
|
1215
1212
|
this.store.putTask(task);
|
|
1216
|
-
this.addTaskEvent(task,
|
|
1213
|
+
this.addTaskEvent(task, event, detail || `Application ${outcome}`, { outcome, detail });
|
|
1217
1214
|
}
|
|
1218
1215
|
await this.closeStalePages().catch(() => undefined);
|
|
1219
1216
|
return { ok: true };
|