feat: add experimental Code Mode tools (run-code + get-code-docs)#1044
feat: add experimental Code Mode tools (run-code + get-code-docs)#1044MQ37 wants to merge 5 commits into
Conversation
06bb42f to
85d0b92
Compare
|
Configurator wiring (adds |
8aba8d1 to
e76cb2e
Compare
jirispilka
left a comment
There was a problem hiding this comment.
I would love to test it, I'll try to test it on Friday
|
We are running only TS/JS, what about Python? That is usually the type of language language models go for when writing a small script. |
|
Tested this branch locally — built from What we tested
Tool loading / pairing (
End-to-end chaining + "keep big data out of context" (the core motivation) Findings
Overall: the feature works and the core value (server-side orchestration, big data kept out of context) is real. But two issues stand out and should be resolved before this is trusted for autonomous use: (1) |
|
Thanks for the thorough testing. Verified findings 2 and 3 against Finding 2 — confirmed. Rather than ship a knob that does nothing, we'll remove the cost cap from Finding 3 — real gotcha, but it's not a fixed Finding 1 — we want to keep the run as |
|
Thanks Jakub — the plan makes sense on all three. Ran a deeper test pass since; validation against your responses plus the extra coverage below. On your responsesFinding 1 — the
So for the new field to be a reliable signal it needs to be the user script's effective exit status (non-zero on a runtime throw even though the run is SUCCEEDED / exit-0), and callers will need to check both Finding 2 — agree with removing the cap. Just flagging that removal leaves the code-runtime run's Finding 3 — thanks for the Extended test pass — full
|
|
@MQ37 Here are the sandbox-hardening notes for apify/code-runtime. I kept them in private Github issue. |
code-runtime is a platform-usage Actor, so the pay-per-event / pay-per-result caps (maxTotalChargeUsd, maxItems) are silently ignored by the platform. Narrow run-code's callOptions to memory + timeout, the only options that apply. Re-introduce a cap once the platform supports one for pay-per-usage Actors.
The code-runtime Actor now writes { stdout, stderr, exitCode } to the run's
default dataset. exitCode is the user script's effective status (0 = returned,
1 = threw) and is the reliable failed-script signal — stderr is also written by
ordinary console.error / console.warn logging. Update the run-code description
and the Code Mode overview guide.
e76cb2e to
869444c
Compare
The code-runtime sandbox now runs without workerd's nodejs_compat, so neither npm packages nor Node node:* built-ins are importable. Correct the run-code description and Code Mode guide, which previously implied only npm packages were blocked. See apify/ai-team#216.
get-code-docs serves a fully static guide (no token, no network), so a docs-only server can now start unauthenticated. Add it to unauthEnabledTools; run-code (paired with it by the loader) still requires a token, and the token gate keys off the explicitly requested tools, so requesting get-code-docs alone stays unauth. See #1044 review.
|
@vojtechj-apify thanks again for the thorough test passes — all the actionable findings are now resolved. Summary of what landed: In this PR (
|
vojtechj-apify
left a comment
There was a problem hiding this comment.
Thanks for the fix!
jirispilka
left a comment
There was a problem hiding this comment.
I'm really sorry this review took a while. This is an important feature and I wanted to do it properly rather than yolo an approval :).
And I'm really sorry for this long text 🤦🏻 but I wanted to make it clear.
I had pretty long session with Claude, putting in together, thoughts my mine but I asked Claude to wrap it up.
I'm glad I did: a few things came out of it that I think should hold the release, not because the code is bad, but because we're about to ship something we can't yet judge.
And let me be blunt, in a friendly way: the PR as it stands didn't really help me judge whether Code Mode is worth it. No examples, no metrics, no evals, and no follow-up plan for how we'll measure success once it's out. For a feature whose entire pitch is "this is cheaper and faster than manual tool calls," that's the one thing I most needed to see — so I went and measured it myself (below).
The good part up front: the code itself is genuinely fine. Running untrusted code in a separate sandboxed Actor instead of in-process, reusing the call-actor pipeline, keeping the bulky data in the sandbox — all correct. The binding is justified. And the earlier round with Vojta already resolved the big runtime issues (the exitCode signal, dropping the dead cost cap, the sandbox hardening). Thank you for that work.
My main point isn't the code. It's that we reached for two dedicated tools where one Actor would be simpler, and I think we missed the chance to make this smaller instead of bigger. Let me make that case concretely.
1. I measured it: 5 tasks, manual vs Code Mode — manual won 3–2
It is also possible that I picked up wrong examples but I had to invent it because I did not have anything better.
Full detail in the artifact: https://claude.ai/code/artifact/8052d747-6c68-4cba-848a-e25b8291316b?via=auto_preview
| Metric | Manual | Code Mode |
|---|---|---|
| Wins (of 5) | 3 | 2 |
| Total tokens | 373,331 | 414,477 |
| Total time | 20.8 min | 30.2 min |
What I tested
I picked five tasks that scale from trivial to hard, to see where Code Mode's advantage kicks in:
- Top-rated restaurants — 1 actor (
compass/crawler-google-places). Search "Italian restaurants," return top 5 by rating (min 50 reviews), sorted by rating then review count. Basic filter+sort on one dataset — baseline, small expected edge since there's nothing to chain. → Manual (57.9k tok / 81s vs 65.8k / 224.6s). - Tech YouTuber lookup — 2 actors, sequential (
apify/rag-web-browser→streamers/youtube-scraper). Web-search "top tech YouTube channels 2026," extract top 3 channel names, then look up each channel's subscribers and latest views. Parse-then-lookup dependency; one actor's output seeds the
next. → Manual (67.2k / 127.1s vs 102.6k / 583.7s; Code Mode needed 4 attempts). - Restaurant review sentiment — 2 actors, fan-out (
compass/crawler-google-places→compass/Google-Maps-Reviews-Scraper). Top 5 restaurants, then 20 recent reviews each (5 parallel Actor calls), compute rating trend + top keywords locally. Real fan-out + bulk local text
aggregation. → Code Mode (66.0k / 151.1s vs 93.3k / 311.6s). - Local business lead-gen — 3 actors, 2-hop chain (
compass/crawler-google-places→renzomacar/website-contact-finder→harvestapi/linkedin-profile-search). 5 boutique gyms → crawl each site for emails → find the owner on LinkedIn → join into one lead per business. Join-by-key
across differently-shaped datasets. → Split: Code Mode won every efficiency number but picked worse search terms (duplicate chain gyms + park facilities instead of real boutique studios) — cheaper/faster, not better. - Competitive product intel — 3 actors, fan-out + join (
apify/rag-web-browser→junglee/Amazon-crawler→scraper_one/x-posts-search). Web-search "best wireless earbuds 2026," extract top 3, get Amazon price/rating/reviews for each, search X for buzz, compute a composite
score and rank. Entity extraction + two fan-out branches per product + heterogeneous join + derived metric. → Manual (69.7k / 290.2s vs 99.6k / 543.2s; Code Mode needed 2 attempts, one feeding garbage like "1. Home, 2. Headphones" into a paid Amazon search).
The pattern (matters more than the scoreline)
- Code Mode's one clean win (Exp 3) was pure structured-data work: real parallel fan-out (5 Actor calls via
Promise.all, launched within ~15ms of each other) and 100 raw reviews reduced to a summary before any model saw them. There it's genuinely better — cheaper and faster. - Every loss involved pulling a fact out of unstructured text — a ranked list in an article, a channel name, a product name — with a regex and no judgment. A wrong guess fails silently until the whole script finishes, and the fix is a full container retry.
- Manual is slower, but the model reads the messy text and course-corrects on the spot — which is
exactly what models are good at. Code Mode's core trade (skip the model mid-script to save tokens)
backfires precisely when the mid-script step needed the model's judgment.
So the ask that follows from the data: the description opens with "USE THIS FIRST FOR ANY MULTI-STEP TASK," and the numbers say the opposite — it's a narrow win, not a first resort, and that line will push agents straight into the free-text cases where it's most fragile. I'd flip the framing: name the sweet spot (genuine fan-out + reducing bulk structured data), and say plainly when
not to reach for it (any step that needs reading or understanding free text — keep the model in the loop with individual calls). Same tool, honest steering.
2. The case for one Actor instead of two dedicated tools
I want to argue this on verified facts. I checked each of these by running both variants side by side through mcpc.
The proposal: drop the internal run-code tool and the get-code-docs tool, and expose apify/code-runtime as a curated direct Actor tool — the same mechanism we already use for apify/rag-web-browser(whitelisted input + an extra description line). Docs move into the Actor and come back throughfetch-actor-details`.
2.1 — It deletes ~380 lines of hand-written, security-adjacent code, replaced by a few lines of config. run_code.ts (144 lines) + code_docs_content.ts (165) + get_code_docs.ts (72) = 381 source lines, plus ~200 lines of bespoke tests (tools.run_code.test.ts 127, tools.get_code_docs.test.ts
55, loader-pairing cases ~20). rag-web-browser's whole curation is a whitelist array and a description constant.
2.2 — The core behavior is identical. I loaded both run-code and apify--code-runtime in one mcpc session and compared: happy path, waitSecs, waitSecs:0 fire-and-forget, --task mode, error path, and the get-dataset-items follow-up are all byte-for-byte the same envelope. Helper auto-injection (get-actor-run, get-dataset-items, get-key-value-store-record, abort-actor-run)
triggers for both.
2.3 — It fixes two real bugs for free — bugs that otherwise need manual patching in run_code.ts:
- Progress. The Actor dispatch path creates a progress tracker unconditionally (
server.ts:1218),
gated only by whether the client asked for progress. The internalrun-codepath hardcodes it tonull(server.ts:1092-1093only opts incall-actorandget-actor-run), so a blocking wait of up to 45s is silent today. - Telemetry. Actor tools carry
actorId/actorNameinto telemetry on every call (server.ts:921-925;extractActorIdcoversTOOL_TYPE.ACTOR).run-codesets neither — so today every code-mode call is logged with no actor id, unlikecall-actor(call_actor.ts:658,677,683).
2.4 — No whitelist gymnastics needed. apify/code-runtime's own published input schema is already just { code } (confirmed live from the Actor-tool schema), so the direct-tool surface is a clean { code, waitSecs } out of the box.
2.5 — Docs stop being a tool. get-code-docs only ever served static text, and the name never sat right next to run-code because the thing itself is off — documentation isn't an action. fetch-actor-details already does exactly this job, it's already the call the guide tells the agent to make for every other Actor, and moving the guide into the Actor gives us one source of truth. Today the
guide lives here (code_docs_content.ts) while the implementation lives in the Actor repo, and it's already drifting — a dead docs/API.md link, and several binding methods documented but never exampled. Deleting get-code-docs also removes the loader-pairing hack and its unauth carve-out.
2.6 — The only thing lost is the per-call memory/timeout knob — and that knob is unusable as shipped. A direct Actor tool exposes only waitSecs, not callOptions. But the model has no way to know how much memory a script needs or how long it'll run; nothing in the docs guides the value; and a
timeout or OOM doesn't come back as "raise this and retry" — it fails opaquely and slips past the exitCode signal entirely (that signal only catches script throws, not resource-limit kills). So the right move isn't to keep an un-actionable knob — it's to set sensible defaults on the Actor, including a real default timeout (the Actor doesn't set one today). Net: simpler and no worse.
Put together: same behavior, two fewer bugs, ~580 fewer lines to maintain, one source of truth for the docs. If we want to keep the run-code name specifically, we can name the Actor accordingly — it isn't
published yet.
3. Before we ship: Code Mode hides the sub-Actor runs from MCP's attribution
This is the one I'm least sure about and most want us to check, because it changes how we'll read our own numbers. It's a property of Code Mode itself, not of the keep-or-drop decision above.
When a run-code script runs an Actor, it does so from inside the sandbox via the apify binding. The MCP server never starts those runs — it starts exactly one run, apify/code-runtime (run_code.ts:102-104 starts that Actor and nothing else). Every Actor the script then triggers is launched by the binding with the run's token, so it's recorded as an ordinary Actor run, not as a run
the MCP server initiated.
4. Small code notes
Quick, and most of these disappear if we go the Actor-tool route:
- No progress notifications on a blocking wait —
server.ts:1092-1093(see 2.3). - No
actorIdon telemetry —run_code.tssets none vscall_actor.ts:658,677,683(see 2.3). run_code.ts:99-142re-implements theexecuteCallActorpipeline by hand — that's where both of the
above crept in; @ already spotted it. If we keep a dedicated tool,
parameterizingexecuteCallActorfixes all three at once.run_code.ts:70— the${APIFY_CODE_RUNTIME_ACTOR}in the JSDoc prints literally (plain comment, not
a template string).src/tools/AGENTS.mddoesn't list the three new files.
5. Follow-up I'd want before/around release
This ties back to the top. The 5-task comparison I ran is the kind of thing that should live be present during the review so that I don't have to do it:
- A small standing eval — a few fan-out tasks, a few text-extraction tasks, pass/fail on the result — so we know whether the steering works and catch it when the binding drifts.
- A success metric we actually agree on. Right now "is this useful?" has no answer we can point to, and §3 means one obvious candidate (MCP-driven runs) may not even count Code Mode correctly.
Let's write it down.
None of this is a knock on the implementation. We should have caught in the spec earlier, it's my fault, sorry.
|
Superseded by a self-contained design: no dedicated All the design/guide content here (sandbox limits, output contract, recipes) now lives in the Actor's own README/docs: apify/actor-code-runtime#1. That PR also closes the critical Closing without merge. |
The Actor is deployed https://apify.com/apify/code-runtime and lives in github.com/apify/actor-code-runtime/ (currently PR apify/actor-code-runtime#1)
What
Add two experimental tools.
run-coderuns a JS/TS script in a sandboxedapify/code-runtimeActor exposing anapifybinding (search/run Actors, read/write datasets & key-value stores); it reuses the existingcall-actorstart→wait→respond pipeline and returns the run plus anextSteppointing atget-dataset-itemsfor the script's{ stdout, stderr }output.get-code-docsserves a paginated guide for writing those scripts. Selecting either Code Mode tool loads both (loader pairing), and when present the run/storage helpers are auto-injected (same ascall-actor).Why
Multi-step Actor work (chain Actors, then filter/aggregate) otherwise costs many separate tool calls and round-trips every intermediate dataset through the model. One Code Mode script does it in a single run, keeping large intermediate data out of context. Implements #1017; the sandbox is the
apify/code-runtimeActor (repoapify/actor-code-runtime), which runs with limited permissions (no filesystem, no imports, outbound network limited to*.apify.com).Note on concurrency
The guide shows a plain sequential loop for running an Actor over many inputs and does not hint at parallel fan-out. This avoids nudging agents into spinning up many concurrent runs by default, which free-tier accounts can hit as concurrent-run/memory limits. It does not forbid parallelism either — the guide stays neutral.
Testing
tools.run_code.test.ts,tools.get_code_docs.test.ts, loader-pairing cases inutils.tools_loader.test.ts; updatedtools.mode_contract.test.tsand integrationsuite.ts(experimental tool count).type-check,lint(0/0),test:unit1018 pass / 2 skip,format,check:agents.Configurator wiring (web tool selector): apify/apify-mcp-server-internal#628 — merges after the package bump that includes this PR.