Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
- A `code_execution` script whose interpreter is still starting is now stopped by `PyRunner.abort()` and `dispose()`, as a running one is, and so is one aborted on the tick `exec` is called. The exec settles at once, and the script never runs. Before, an abort in that window did nothing, though `busy` was already true: when a script's turn ended or was reset to run again in that window, the script ran to its end or its time limit, calling tools as it went, and each `code_execution` call of the agent's later turns was refused as another script already running until then. A dispose there, as a background script's cancel does, waited out the interpreter's 15 s start timeout and then reported that the runtime had failed to start. The interpreter starts at an agent's first script, after it has been idle, and after a script is aborted, killed at its time limit or crashes.
- A `code_execution` interpreter that stops reading its input, as one does after a script closes its standard input (`os.close(0)`), is now ended at the first message that can't reach it, and the script waiting on it settles at once, saying the runtime stopped reading its input. That message's failed write was thrown in the host as an uncaught exception (`write EPIPE`), which ended the host's process. After a script's `os.close(0)`, the next script's message is sometimes written without an error and never runs, and that script settles this way only at its time limit. An interpreter that died before its exit was handled still settles with its own exit code.
- A `code_execution` script stopped at its time limit now reads the note "script stopped: it reached its … time limit" on a line of its own when the text before it doesn't end with a newline, as the runner's own texts don't, such as "python runtime exited unexpectedly (…)". The note used to run on at the end of that text's line.
- A `code_execution` script stopped at its time limit now reads the note "script stopped: it reached its … time limit" on a line of its own when the text before it doesn't end with a newline, as the runner's own texts don't, such as an interpreter's exit. The note used to run on at the end of that text's line.
- An abort or a dispose on the tick `exec` is called, when the interpreter couldn't be spawned, no longer signals another process. The runner killed the failed spawn's child, which has no pid, in the tick before Node's `error` for it, and Node then signals whatever pid the child's handle held: in a fresh Node 20 process that was 0, so `SIGKILL` went to the host's own process group.
5 changes: 5 additions & 0 deletions changelog.d/fix-presentation-script-errors.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
- `set_tool_visibility` and `set_tool_description` refusals now lead with "Nothing was changed", lose the "Error: " prefix, and say what helps. Arguments of the wrong shape are named together, each by its key, with the call that works. A tool name that isn't available is quoted, cut past 60 characters, and gets the closest names, quoted whole, and the catalogue's index to copy from. A description over 32,768 characters, or hiding one of the three tools that can't be hidden, is refused as the agent's own input, where before it read as a problem with the file; a description's length is now counted in code points rather than UTF-16 units, so a character outside the Basic Multilingual Plane, such as 😀, counts once rather than twice. A stale `.lock`, a presentation file that is a symbolic link, an edit that would take the file past 256 KiB (with the bytes over), and a file changed during the edit each say what happened and whether calling again helps. An edit whose lock can't be removed after it's done now succeeds where it failed, and says that its lock was left and that edits will be refused until it's removed; a refusal that leaves its lock says so too. A lock that something else removed first is not reported as left.
- A presentation or description-defaults file the host can't use is reported with its problems all at once (up to ten, then how many more), each by its entry, both by the editing tools and in the catalogue's diagnostics. A name a problem quotes is cut past 60 characters and a value past 40, counted as code points, and the key the catalogue reports as not currently available is cut as a name is. The diagnostics also say what applies meanwhile and who can fix the file, each on one line of the catalogue. A presentation file that is a symbolic link to nothing is reported as such, where it read as no file. `parsePresentation`'s error names the document's problems the same way, where before it named the first, in other words.
- A `code_execution` script stopped at its time limit says its tool calls keep their effects, and offers `time_limit_ms` only when a longer limit is allowed. At the deployment's ceiling, which is the default unless `maxScriptTimeoutMs` raises it, it says that is the longest a script runs and to split the work, or to run it in the background when this agent could start a background script as the script starts (it runs them, and a slot is free) and one may run longer (a background script: that starting it again runs it from the top). Before, it said only that the script had stopped. A script whose interpreter exits under it, can't start, or exits as it starts says what ran and whether running it again can help; a missing `python3` says `code_execution` can't run there until the host installs one, a non-executable one until the host makes it executable, and a failure creating or writing the runtime's files says so, with its path. A second script while one runs says they run one at a time.
- Inside a script, a tool function called with keyword arguments, such as `await workspace__read(path="a.txt")`, is refused with the dict to write, `await workspace__read({'path': <str>})`, where before Python reported an unexpected keyword argument. A `str` key in that dict is shown whole up to 60 characters, and a longer one, any other key and every value by its type; a non-dict argument is named the same way.
- **Hosts driving `PyRunner`:** `exec()`'s options take `ceilingMs`, the longest limit the deployment allows that kind of script; without it, a stopped script isn't offered a longer limit. A foreground exec's `backgroundCeilingMs` is the longest a background script may run, which a script stopped at its ceiling is offered when it's longer; leave it out where the caller can't start one.
94 changes: 94 additions & 0 deletions src/closest-names.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
/**
* The names someone most likely meant, when the one they gave matches none:
* for a refusal's "did you mean", such as a tool name an agent got wrong.
*
* Case is ignored. A candidate that the name matches as a whole `--` segment
* ("ls" for "workspace--ls"), then one that starts with it or that it starts
* with, then one that contains it or that it contains, ranks ahead of one
* that is only a few edits away, however many edits those matches take. The
* prefix and substring matches need three characters on the shorter side,
* since a letter or two is in nearly every name. A candidate that matches in
* none of those ways counts only within the edits noiseLimit allows, and past
* that it's noise. So a name that is part of no candidate, holds none and is
* close to none gets nothing back, and a name in a crowded namespace doesn't
* get its neighbours (`workspace--write` for `workspace--read`).
*/
export function closestNames(name: string, candidates: readonly string[], max = 2): string[] {
const wanted = name.toLowerCase();
const ranked: { candidate: string; tier: number; distance: number }[] = [];
for (const candidate of new Set(candidates)) {
const other = candidate.toLowerCase();
const shorter = Math.min(wanted.length, other.length);
const tier = other === wanted ? 0
: wanted && other.split('--').includes(wanted) ? 1
: shorter >= 3 && (other.startsWith(wanted) || wanted.startsWith(other)) ? 2
: shorter >= 3 && (other.includes(wanted) || wanted.includes(other)) ? 3
: 4;
// In tiers 0 to 3 one name holds the other, so the length difference is
// already their distance, and a long name skips the edit table.
const limit = tier === 4 ? noiseLimit(wanted, other) : Infinity;
const distance = tier === 4 ? editDistance(wanted, other, limit) : Math.abs(wanted.length - other.length);
if (distance > limit) continue;
ranked.push({ candidate, tier, distance });
}
// A stable sort: candidates that tie keep the order they were given in.
ranked.sort((a, b) => a.tier - b.tier || a.distance - b.distance);
return ranked.slice(0, Math.max(0, max)).map((entry) => entry.candidate);
}

/**
* Whether `name` looks like a slip for `candidate`, ignoring case: the same;
* a few edits apart, where a transposition counts as one edit; or, at three
* characters or more, a part of it, which is a shortened attempt at it
* (`mode` for `deliveryMode`, `interval` for `refillIntervalMs`). A part
* counts only that way round: a name that holds the candidate, and is more
* edits from it than a slip allows, is another name, usually an extra that
* explains it (`channel_note` beside `channel`, where `channels` is a slip).
* Beyond these, two names are different names rather than one mistyped.
*/
export function isNearMiss(name: string, candidate: string): boolean {
const a = name.toLowerCase(), b = candidate.toLowerCase();
if (a.length >= 3 && b.includes(a)) return true;
const limit = noiseLimit(a, b);
return editDistance(a, b, limit) <= limit;
}

/**
* The most edits that still read as a slip: a quarter of what follows the
* prefix the two names share, and at least one. A shared prefix is mostly a
* namespace (`workspace--`, `mcpl--discord--`) and says nothing about the
* rest, so `workspace--read` and `workspace--write` are four edits apart in
* a four-letter tool name, not in a fifteen-letter one.
*/
function noiseLimit(a: string, b: string): number {
let shared = 0;
while (shared < a.length && shared < b.length && a[shared] === b[shared]) shared++;
return Math.max(1, Math.floor((Math.max(a.length, b.length) - shared) / 4));
}

/**
* Edits between two strings: insertions, deletions, substitutions and
* swaps of neighbours (optimal string alignment). Past `limit` the exact
* count doesn't matter, so it answers `limit + 1` as soon as it is sure.
*/
function editDistance(a: string, b: string, limit: number): number {
if (Math.abs(a.length - b.length) > limit) return limit + 1;
let before: number[] = [];
let previous = Array.from({ length: b.length + 1 }, (_, j) => j);
for (let i = 1; i <= a.length; i++) {
const current = [i];
let best = i;
for (let j = 1; j <= b.length; j++) {
const cost = a[i - 1] === b[j - 1] ? 0 : 1;
let value = Math.min(previous[j] + 1, current[j - 1] + 1, previous[j - 1] + cost);
if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) value = Math.min(value, before[j - 2] + 1);
current.push(value);
best = Math.min(best, value);
}
// No row's smallest entry is below the one before it, so none later can come back under the limit.
if (best > limit) return limit + 1;
before = previous;
previous = current;
}
return previous[b.length];
}
Loading
Loading