Repository navigation
Answer an unreadable token identifier instead of throwing on it - #123
Merged
portdeveloper merged 1 commit intoOct 9, 2026
Merged
Conversation
`isNativeToken` coerced with a bare `String()`, which is not total: it throws for a
value whose `toString` is not callable, and `{"toString": null}` is ordinary JSON a
model can emit. The call in `get_token_balance` runs before the `resolveToken`
try/catch, so the throw left `runAction` entirely — `handleAction` printed `error: …`
rather than a refusal, `isRefusal()` read false for what is a rejection, and a scripted
run exited 0 on a rejected call. Every alias of the field reaches that same line, so
`token`, `symbol` and `tokenAddress` all did it, and so did the native dispatcher.
The fix is in the predicate rather than at the call site, which is a choice worth
stating. `isNativeToken` is called from three places with model-supplied values, so a
predicate that can throw is a hazard at each of them; normalizing in
`get_token_balance` alone would leave the other two able to throw. A value `String()`
cannot read is certainly not the native symbol, so false is the answer — it only has to
be answered rather than thrown.
`safeEcho` already takes this shape of value for the same reason and returns
`<unprintable>`, so the refusal reads `Refused: invalid token "<unprintable>"` without a
second coercion of its own.
Readable identifiers keep their old answer exactly: the coercion, the trim and the
comparison are unchanged, and the missing-token message for an unrecognised symbol is
untouched.
596 tests, up from 589. The seven new ones cover the three aliases, the native
dispatcher, the refusal arriving before any wallet or RPC call, and the controls — an
unknown symbol still answering with the missing-token message rather than a refusal.
One of them exists because the mutation pass found it: removing the trim or the
toUpperCase from this predicate changed what counts as the native coin and reddened
nothing. That property predates this change, but the change rewrites the predicate that
holds it, so " MON ", "mon" and a tab-padded form are now pinned.
portdeveloper
approved these changes
Oct 9, 2026
portdeveloper
left a comment
Owner
There was a problem hiding this comment.
lgtm, thanks for covering the aliases and preserving native-token recognition
portdeveloper
pushed a commit
that referenced
this pull request
Oct 13, 2026
get_token_balance answered a token it could not resolve with the bare "Unknown token ..." text, so isRefusal() was false and a scripted run finished with exit 0 after a balance read that never happened. The send path already prefixes the same explanation with "Refused:". The balance read now does too, keeping the known-symbol and contract-address hint. Tests cover "NOT_A_TOKEN", "", 0 and false through runAction and native dispatch, and a real dist/cli.mjs run with a shimmed model that reads an unknown token and then replies normally still exits 1. The #123 control for the unknown symbol and the empty-catalog message now expect the refusal; MON, known-token, contract-address and #123's unreadable-value handling are unchanged. Closes #124
This was referenced Oct 14, 2026
Open
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #122.
isNativeTokencoerced with a bareString(), which is not total: it throws for a value whosetoStringis not callable, and{"toString": null}is ordinary JSON a model can emit. That call runs before theresolveTokentry/catch, so the throw leftrunActionentirely.Measured on
9871f73, with the controls in the last two rows:get_token_balancewithtoken: {toString: null}TypeError: Cannot convert object to primitive valueRefused: invalid token "<unprintable>": Cannot convert object to primitive valuesymbol: {toString: null}tokenAddress: {toString: null}dispatchToolCalltoken: "NOT_A_TOKEN"(control)Unknown token "NOT_A_TOKEN"…token: "MON"(control)The refusal keeps the converter's own words after the colon. That shape comes from the
resolveTokentry/catchca31f86added, not from this change — and it is where theRefused:prefix and the recognised status come from, sinceresolveTokenthrows on this value too. Worth saying plainly so the table is not read as a cleaner message than it is.isRefusal()now reads true for all four, where before it read false for a rejection — so a scripted caller keeps its failure status instead of exiting 0 on it. The refusal arrives before any wallet or RPC call: with no wallet configured, anything that reached one would sayWallet not initialized, and the new tests assert that it does not.Where the fix went, and why there
In the predicate, not at the call site.
isNativeTokenis called from three places with model-supplied values, so a predicate that can throw is a hazard at each of them; normalizing insideget_token_balancealone would leave the other two able to throw. A valueString()cannot read is certainly not the native symbol, sofalseis the answer — it only has to be answered rather than thrown.safeEchoalready takes this shape of value for the same reason and returns<unprintable>(addressBook.mjs), so the refusal message needs no coercion of its own.Readable identifiers keep their old answer exactly: the coercion, the trim and the comparison are unchanged, and the missing-token message for an unrecognised symbol is untouched, as the issue asks.
Coverage
596 tests, up from 589. Seven new: the three aliases, the native dispatcher, the refusal arriving before any wallet or RPC call, and the controls.
One of them exists because the mutation pass found it. Removing the
trimor thetoUpperCasefrom this predicate changes what counts as the native coin and reddened nothing onmain. That property predates this change, but the change rewrites the predicate that holds it and the issue asks to preserve MON handling — so"MON","mon"," MON ","Mon"and a tab-padded form are now pinned, along with"MONX","XMON"and""not being native.Mutations
Seven mutations, each reverted on its own with the full suite run each time, plus a no-op control:
trimdroppedtoUpperCasedropped""instead of returningfalse?? ""droppedThe two zeros are equivalent rather than uncovered, and both are provable rather than argued:
SYMBOL()isMON, so"".trim().toUpperCase()cannot match it and continuing with""gives the same answer as returningfalse; andString(null)is"null", which cannot match it either, so dropping?? ""changes no outcome.Two things measured and deliberately not touched
This closes the four execution paths the issue names. Two neighbours carry the same coercion and are left alone, because "do not broaden this into a rewrite of other actions" reads to me as leaving them. Reported rather than fixed, and I would take either as a separate issue.
The parse path still loses the action. After this change
normalizeParsedActionno longer throws, buthasRequiredArgsdoes, at its ownString(action[candidate]).trim().parseActioncatches it in the "try the next JSON object" handler and returns{action: "none"}:An operator who asks for a balance has the request silently dropped and gets a chat reply instead. I checked that this is unchanged by this PR: the parser's output is byte-identical before and after, including inputs with two JSON objects in one message and the text fallback.
The send path throws the same way.
prepareTokenSendcoerces the same field, and its call site is not wrapped:Reachable through native tools only:
nativeToolLoopbuildshandleAction({ action: name, ...args })straight from the tool call (:76), sohasRequiredArgsnever gates it, while the v0 path is stopped by the parse behaviour above. The throw is caught at the top of the loop, printed asError: …, andhadFailureis still set in a scripted run — so the session survives and the exit status stays correct. What is lost is the message quality, which is this issue's class on the write side.Gates
npm run buildclean; suite run three times at 596/596. Two files, two hunks, +103 −1; longest added line 93 characters.Environment
main@9871f73runActionanddispatchToolCallwith no wallet and no network