ai newsDo., 15. Okt.
EilmeldungMeta and Apple team up to make Muse AI models run better on Macs

Wenn ein LLM Trades auslöst: Technische Hürden bei KI-Agenten am Markt

KI-Agenten wie Claude Code oder Codex können nun über ein Kommando eigenständig Orders auf Polymarket-Wallets platzieren. Dafür mussten Probleme rund um Idempotency Keys, fsync und Prozessgruppen gelöst werden.

dev.toMittwoch 17:39 · 14.10.DE8 Min. Lesezeit
Bild: dev.to

We recently shipped a way for AI agents (Claude Code, Codex, anything that takes a prompt and reads stdin, or speaks MCP) to act on the live trades of Polymarket wallets and place orders of their own. The data side streams fills to the agent; the trading side is a command the agent calls:

pmwallets-copytrade order buy <tokenId> --usdc 5 --max-price 0.45 --key <eventId> Enter fullscreen mode Exit fullscreen mode The happy path needs little code. Most of what we wrote handles failures that could cost money; those are the cases I'll cover here.

The failures we planned for were ordinary ones:

Each of those, unhandled, turns into either a second order or an order without the limit you meant to set.

--usdc 5 reads as "spend $5". What we wanted was "never spend more than $5".

A BUY is fill-or-kill at a limit price, and the share count is computed at that limit, not at the current ask:

// usdc is a ceiling: sized at the limit, the order cannot cost more than it whatever it matches shares = roundBuyShares (( microToward ( req. usdc, ' down ' ) * UNIT ) / limit, limit ); Enter fullscreen mode Exit fullscreen mode If the ask is 0.40 and the agent sets --max-price 0.45, it gets 5 / 0.45 shares. If the book moves up to 0.45 between the check and the match, the cost is still at most $5. A loose --max-price buys fewer shares, but keeps the order within the budget.

export function microToward ( x: number, dir: ' up ' | ' down ' ): bigint { const m = /^ (\d + )(?:\.(\d + ))?(?: e ([ +- ]?\d + ))? $/. exec ( String ( x )); if (! m ) throw new Error ( `cannot read ${ x } as a decimal` ); const digits = BigInt ( ` ${ m [ 1 ]}${ m [ 2 ]?? '' } ` ); const shift = Number ( m [ 3 ]?? 0 ) - ( m [ 2 ]?. length?? 0 ) + 6; if ( shift >= 0 ) return digits * 10 n ** BigInt ( shift ); const div = 10 n ** BigInt ( - shift ); const q = digits / div; return dir === ' down ' || digits % div === 0 n? q: q + 1 n; } Enter fullscreen mode Exit fullscreen mode The same rule applies to the market's tick size: a BUY limit is rounded down onto the tick grid, a SELL floor up, and a floor above the highest price the market accepts is refused rather than lowered.

A claude -p run that saw a slow response may run the same command again. --key prevents that retry from sending a second order; the fill's eventId is a suitable choice.

The key is claimed on disk and synced before the order is sent:

claim ( key: string ): boolean { mkdirDurable ( this. dir ); let fd: number; try { fd = openSync ( this. file ( key ), ' wx ' ); // exclusive create: two callers can't both win } catch ( e ) { if (( e as NodeJS. ErrnoException ). code === ' EEXIST ' ) return false; throw e; } try { writeSync ( fd, JSON. stringify ({ status: ' sending ', key, claimedAt: new Date (). toISOString () })); fsyncSync ( fd ); } finally { closeSync ( fd ); } syncDir ( this. dir ); // the directory entry too return true; } Enter fullscreen mode Exit fullscreen mode These details each came from a specific failure case:

To record the result, we write a temporary file, fsync it, rename it and sync the directory. A crash between the claim and the send means that key won't order again, even though nothing was sent. We accepted that missed-trade risk to avoid sending the same order twice.

pmwallets watch --exec "<agent command>" holds a WebSocket to the fill stream and runs the agent once per batch of fills. The stream itself delivers each fill once, in order, and replays gaps. The question was what happens between the stream and the agent.

At-least-once is the usual choice for queues. Here, if the agent placed an order and then the process crashed, running the batch again could place it again. We hand each batch over at most once:

A fill whose eventId is in handed.txt is never queued again, even if the stream replays it because the cursor wasn't saved before a crash. handed.txt is trimmed occasionally, but only of ids at or before the stream cursor as it is on disk (synced first), since those are the only ones the stream can never replay.

A crash can leave fills in failed/ that nobody acted on. We log those for manual checking and accept that risk with this design.

An agent CLI is rarely one process. claude -p and codex exec start their own children, and a child left running after a timeout might still be about to call order buy.

The command runs in its own process group:

child = nodeSpawn ( this. o. exec!, { shell: true, env, stdio: [ ' pipe ', ' inherit ', ' inherit ' ], detached: process. platform!== ' win32 ' }); Enter fullscreen mode Exit fullscreen mode A timeout or a stop sends SIGTERM to the whole group ( process.kill(-pid,...) ), then SIGKILL five seconds later. On Windows it's taskkill /T /F. When the command exits normally, the group is killed anyway, because a grandchild may still be running.

Two things went wrong here before they were fixed:

For the second, every batch's command gets PMW_BATCH_FILE=<absolute path> in its environment. That doubles as a marker. On Linux, startup scans /proc/*/environ for the marker of any running-* batch and stops those groups before handing anything new out. On other systems, where that can't be checked, a running batch with no pid record makes startup refuse with instructions, instead of guessing.

We need one watcher per directory to avoid sending every fill to the agent twice. The classic pid lock file (create it, delete it on exit, take it over if the pid is dead) has a race. Starter A reads the lock, sees a dead pid, and pauses. Starter B also sees it dead, deletes it, creates its own and starts running. A wakes up, deletes B's live lock and creates its own, leaving two watchers running.

The fix was to stop reusing names. Locks are generations, watch.lock.0, watch.lock.1, and so on. The highest number is the lock. A new one is created with exclusive create, as highest + 1, only when the current highest is released or its owner is dead. Releasing writes { released: true } into the file instead of deleting it, and old generations are never removed. A starter that paused on an old scan tries to create a number that already exists and gets EEXIST. Each file is a few bytes, one per start.

Over MCP, a model calls order_buy with a JSON object. If it sends max_price instead of maxPrice, a lenient server ignores the unknown field and the order goes out with the default limit, which is the current ask, whatever that is. MCP hosts don't have to validate against the schema, so the server does, strictly:

for ( const [ k, v ] of Object. entries ( args )) { const p = props [ k ]; if (! p ) throw new Error ( `unknown argument ${ JSON. stringify ( k )} (allowed: ${ Object. keys ( props ). join ( ', ' ) || ' none ' } )` ); //...type checks; null is rejected with "leave it out instead" } Enter fullscreen mode Exit fullscreen mode The error lists the allowed names so the model can fix the call. The command line also rejects unknown flags such as --max_price. A flag with no value, like --key --all, returns an error; otherwise it could place an order under the key --all.

Exchange errors sometimes echo the request back, and the request can contain credentials. Anything written to a key record, a log line or stderr goes through redact() first. It walks the whole value, keys included, and replaces every known secret value plus anything shaped like an API key or a user:pass@ URL. It runs on the object before JSON.stringify, so field names are covered as well as values.

Many of the failure cases above came out of an adversarial review loop with Codex over the diff. We asked it specifically for crash windows, races and partial writes, and successive rounds found more cases to fix. The data CLI went from 0.6.0 to 0.6.5 on the day it shipped:

The trading CLI's 0.4.1 was the redaction fix in section 7.

None of those would show up in a normal test run. They need a crash at one specific line, or two processes starting within milliseconds of each other. What made the review useful was asking a narrow question: where can a crash between two lines cause an order to be sent twice, or a fill to be handed over twice?

In a production test, 57 real fills reached the agent command between 0.01 and 0.16 seconds after they were pushed. A headless claude -p took about 11 seconds to read a fill, look up the market and place a dry-run order; codex exec took about 27. Most of the time went into the model run. That delay is another reason we start the trading side in dry-run mode, where orders are simulated against the live book and nothing is sent.

For a similar setup, I'd check the following:

The code is MIT-licensed: src/watch.ts and src/mcp.ts in polymarketwallets/pmwallets-node, and src/agent.ts in polymarketwallets/polymarket-copy-trading-bot on GitHub.

Nothing here is financial advice, and Polymarket's regional restrictions apply to placing orders.

Originaltitel: Letting an LLM press buy: idempotency keys, fsync and process groupsQuelle: dev.to (dev.to). Original öffnen