Skip to content

feat: Resolve a bare Actor name in call-actor and fetch-actor-details - #1615

Draft
DaveHanns wants to merge 127 commits into
masterfrom
feat/bare-actor-names
Draft

DaveHanns wants to merge 127 commits into
masterfrom
feat/bare-actor-names

Conversation

@DaveHanns

Copy link
Copy Markdown
Contributor

Why

Part of #1614. Stacked on #1453 and targets its branch.

In the versions evals, agents named their Actor by its bare name, as the user's message did. The versions tools and, with #1453, build-actor resolve a bare name against your own Actors, so the agents kept using it. call-actor and fetch-actor-details answered "not found". In the eval round with this branch, agents passed a bare name to call-actor 8 times and to fetch-actor-details once; with #1453 alone, each of those calls fails.

What changed

Before: a bare name such as my-actor went to the API as is. The API reads it as an ID and answers 404.

Now: call-actor and fetch-actor-details resolve it the way the versions tools do. So does call-actor-widget, which shares call-actor's pre-execution.

  • A name without a username means one of your own Actors. A value shaped like an ID (17 letters and digits) is tried as an ID first, then as one of your own names. An ID, username/name, or username~name works as before and makes no extra request.
  • A bare name is read once as ~name with a direct, uncached request. The call then continues with username/name. So ~name never becomes a key in the process-wide Actor definition cache, where one account's Actor could answer another account's lookup. That is the bug fixed earlier for schedules.
  • The :toolName suffix of MCP server Actors is kept: my-actor:add calls add on your-username/my-actor.
  • Under a payment provider (x402 or Skyfire), nothing is resolved: the call has no Apify account whose Actors a bare name could mean. The standby check that runs before the call and the call itself both read the name as given.
  • When none of your Actors matches, the reply says: "A name without a username is looked up among your own Actors only; an Actor from Apify Store needs its full name as username/name". When one matches but has no successful build yet, it says: "Actor '/' exists in your account but has no successful build yet."
  • The actor parameter text says a bare name always means one of your own Actors, never another account's Actor. The description lines that told the agent to resolve a name with search-actors now state a fact: what search-actors returns.

This changes the tool text contract for call-actor, call-actor-widget, and fetch-actor-details. The unit tests that pin that text are updated.

What the hosted server consumes:

  • call-actor structuredContent.actorName is the full username/name when the input was a bare name. This includes the MCP tool pass-through result.
  • fetch-actor-details passes the full name to actorStore.getActorOutputSchemaAsTypeObject and to the MCP tool listing.
  • Telemetry actor_name for call-actor is still the value as typed, a bare name, while the Actor ID in telemetry is the resolved one.
  • ?payment= sessions behave as before. No internals.js export changes.

Trade-offs:

  • If you own an Actor with the same bare name as a Store Actor, such as instagram-scraper, call-actor runs yours, and that costs money. Before, the call failed. The actor text says this.
  • If a bare value shaped like an ID matches another account's public Actor ID, the ID wins. feat!: Add create-actor and update-actor-version tools #1453 already accepts this trade-off.
  • fetch-actor-details-widget is unchanged. It still takes only an ID or a full name.

Notes for reviewers (human-written)

Proof it works

The versions evals (create an Actor from scratch, fix one line, change several files, each ending in a successful build and run) ran locally on a merge of #1454 at 8d51cb99 and this branch at 74b20dd7:

Model Judge No tool errors Checked on the platform
Haiku 5.5 5/5 5/5 5/5
Sonnet 5.5 5/5 5/5 5/5
Opus 4.8 5/5 4/5 5/5

All 20 bare-name calls succeeded: 11 to build-actor, 8 to call-actor, and 1 to fetch-actor-details. Without this branch, the previous round had 7 bare-name errors in 15 trials. The one remaining error is unrelated: Opus wrote to an existing empty file without its hash, got FILE_EXISTS, and retried. The later commits here (cffc456f to c49a92de) only change one reply and one phrase of tool text, and were not in that run.

New unit tests run against the real definition cache and its ownership check. They show that:

  • Two accounts using the same bare name each get their own Actor, in both tools.
  • The cache never gets a ~ key, including for a literal ~name input.
  • name:add keeps its suffix and calls the tool on username/name.
  • A run started by bare name starts the resolved Actor.
  • An ID-shaped value is tried as an ID, then as ~name.
  • IDs and full names make one lookup and no ~ request.
  • Under a payment provider, nothing is resolved, and the standby check and the call read the same name.
  • A bare name with no match gets the fact above, while a full name keeps today's text.
  • fetch-actor-details passes the full name to the MCP tool listing and to the output schema store.

Not covered yet: Apify Store flows. The call-actor and fetch-actor-details text changed, so the PR eval suite, which runs when this PR is marked ready, is the check for those.

AI disclosure: implemented with Claude Code; awaiting human review.

🤖 Generated with Claude Code

Adds get-actor-version, the read side of the source tools that replace
push-actor and pull-actor, in a new source category that is not enabled
by default.

It returns a version's metadata, a file manifest (path, decoded size,
hash, format), a revision for the whole file set, and the content the
caller asks for within MAX_INLINE_BYTES. Without paths it returns every
text file only when all of them fit, so one large file cannot crowd out
the rest; paths: [] gives the listing only; named paths fill the limit
in order, and a single large text file can be read in line ranges.
Environment variables come back as names and isSecret only.

The hash is the first 16 hex characters of the SHA-256 of the file's
bytes, and the revision hashes the sorted path and hash lines, so both
are the same for a version stored inline and one stored as a zip.

Zip-stored versions are read only from a key-value store record on the
session's own API host, with the caller's token. The zip is parsed from
its central directory with no new dependency, because apify push writes
zeros for sizes in local headers. Archives over 50 MiB, over 10,000
entries, or over 64 MiB uncompressed are refused, as are encrypted,
zip64, symlink, and unsafe or duplicate names, and every entry's length
and CRC-32 are checked after inflating. Git, gist, and outside zip
sources return only their URL, without its query string.
Zip reading:
- Refuse an archive whose end record declares fewer entries than the
  central directory holds, and an entry whose local header names another
  file or method, so the files reported match what other zip readers see.
- Check entry names for length, NUL, absolute paths, and '..' first, so
  no refusal message repeats an overlong name.
- Skip a root folder entry such as './' instead of refusing the archive.
- Detect text with isUtf8 and decode only the files that are returned,
  so a listing holds only the inflated bytes the 64 MiB cap bounds.

Inline files:
- Read a file stored without format as TEXT and one without content as
  empty, as the build worker does, instead of failing or breaking the
  output schema.
- Normalize inline paths the way the zip reader does, so the same files
  give the same manifest and revision however they are stored.
- Return a UTF-8 file stored as BASE64 as text, as a zip read does. The
  manifest keeps the stored format.

Results:
- A line over the 256 KiB limit no longer fails the call. The file goes
  to omittedPaths and the summary names the startLine that skips it, or
  says a single-line file cannot be read by lines.
- The summary no longer tells the caller to name a base64 file too large
  to return; it points to the Apify CLI instead.
- Remove the query string from Git and gist URLs too, and http user and
  password from every URL, before returning them or hashing the revision.
- Word the outside zip summary without claiming the source is not on
  Apify.
- The description says the API hides the source of most Actors the
  caller cannot modify, and gives the macOS hash command.

Tests cover each rule and the boundaries that no test pinned before:
hidden sources for URL types, a record URL on another host, local extra
fields that differ from central ones, byte order of the manifest, base64
files next to text at the limit, and the entry count, size, CRC, and
encryption flag edges.
Add two write tools to the opt-in source category. Both write only to the
caller's own account and store files inline in the version.

create-actor creates a private Actor with one version in a single POST, from
files (which must include .actor/actor.json) or a Git repository. It refuses a
name the account already uses, and warns about a missing Dockerfile and empty
files.

update-actor-version applies write, edit, delete, and move operations to a
fresh read of the version and saves the result with one version PUT that
carries only the source keys and buildTag, never envVars. Each operation has a
precondition (an absent path, expectedHash, or an exact-once match), and
expectedRevision guards replaceFiles and gitRepoUrl. A failure writes nothing
and names the operation, the path, and a reason code. It refuses zip-stored
versions and results over the platform's 3 MiB limit, and refuses file
operations on Git and gist versions. A save that lands between the read and
the PUT is not detected until the platform gets a conditional version PUT.

Both take autoBuild, which starts a build after the write and returns without
waiting. get-actor-version's description now says its hashes and revision are
what update-actor-version takes, when that tool is loaded.
- Check for a cancel right before the version PUT and the Actor POST, so a
  request cancelled during the reads writes nothing.
- create-actor builds its files, hashes, and revision from the files the
  platform stored, since it sets the name field of .actor/actor.json on
  create, and warns when that changed the file. A taken name (including the
  race case, name-not-unique) now gives the full name update-actor-version
  takes. The missing Dockerfile warning says the build uses the platform's
  default Node.js Dockerfile, and matches paths regardless of case.
- update-actor-version refuses a missing .actor/actor.json only when the
  changes remove it, or when a Git version switches to stored files.
- Sending back the Git URL that get-actor-version shows no longer removes
  the stored credentials. Another URL on the same host keeps them, with a
  warning, and a URL on another host is refused.
- Stored entries the call leaves alone go back verbatim and in place,
  duplicates included; a changed file keeps its stored name.
- Refuse a result where a file path is also a folder of other files or a
  folder entry.
- Cap the NO_MATCH context at 4 KiB, show a window of a long line, show the
  lines around newText when only it is found, and suggest reading again when
  no context is shown.
- Share splitLines, formatMib, the MiB and 255-character constants, and the
  500-file cap across the source tools, and give the 2 MiB refusal text that
  fits each tool.
- Output schema texts no longer name another tool, and describe sourceType
  and changes as the tool returns them.
- Add tests for the new rules and for the cases the reviewers found untested.
Move the files input rules, the manifest builder, and the missing Dockerfile
warning from create-actor to source_helpers.ts, so create-actor-version can
use the same rules. create-actor behaves as before.

(cherry picked from commit ea8fbb4)
The Apify API cannot read or change single files of an Actor's source, so the version GET returns every file and a zip-stored version is downloaded and unpacked whole, even for one file. TODO comments mark both places for when such an API exists.
The Apify API cannot change single files of an Actor's source, so a one-line edit reads the whole version and writes all its files back, which also leaves the gap between the read and the PUT. A TODO marks the place for when an API for atomic source changes exists.
…y it

Remove the zip reader, its test helper, and its tests. A version stored as a
zip in a key-value store of this API is now refused as a user error; a zip at
an outside URL is still reported by its URL. A TODO marks where zip reading
comes back and the library planned for it.

Other simplifications, with the same output data:
- One summary function replaces the format helpers.
- A line over the limit is reported only when nothing of the range fits.
- No Apify CLI hint for files too large to return.
- Paths sort in plain JavaScript string order instead of UTF-8 byte order,
  for the manifest and the revision lines.
- One hash helper, and the manifest builder and folder check live in
  source_files.ts.
# Conflicts:
#	src/tools/AGENTS.md
#	src/tools/source/get_actor_version.ts
#	src/tools/source/source_archive.ts
#	src/tools/source/source_files.ts
After the merge of get-actor-version's simplification:
- create-actor and update-actor-version use buildFilesManifest and
  isFolderEntry from source_files.ts, and source_helpers.ts drops its copies.
- The absolute path check (ABSOLUTE_NAME_REGEX) now lives in source_files.ts,
  since the zip reader it came from is gone.
- update-actor-version refuses a zip-stored version with the same wording as
  get-actor-version and no longer points to apify push.
- Say that the tool does not download zips when it reports an outside
  zip URL, since a URL on the session's own API host is not outside the
  Apify API.
- Describe the path normalization correctly: empty and `.` segments are
  dropped as the build worker does, and backslashes become `/`, which
  the build worker does not do for inline names.
- Pass only the API base URL to the version reader, since it no longer
  calls the API, and fix comments that still mention the source store.
- Stop exporting the hash helper and the format type, and read an
  entry's name, format, and content in one destructuring.
- Test that a BASE64 file with a text extension and invalid UTF-8 comes
  back as base64, that a byte order mark is kept, and that the last of
  two entries with the same path wins.
# Conflicts:
#	src/tools/source/source_files.ts
Move the rule that turns a stored entry into bytes into source_files.ts
as decodeSourceFileEntry, and use it for the hash that get-actor-version
reports, for the unchanged-file check in update-actor-version, and for
the config comparison in create-actor. The three can no longer disagree
about how a missing format or content is read. A null content now reads
as empty in all three, as it already did for the hash.
Narrow the tool to what the spec asks for:
- Without paths, return the file list only. Drop pathPrefix and the
  read-all-text-files default.
- Keep basic line ranges; a range over the limit goes to omittedPaths.
  Drop the automatic range and the long-line handling.
- Refuse every version not stored as files with one message that names
  its URL without credentials. Drop Git, gist, and zip reporting, the URL
  revision, and the split of Git URLs.
- Read the version from the Actor GET, which already hides the source the
  same way. Drop the second GET, the 4xx catch, and the no-versions branch.
- Drop env var names, the stored format, the source type, and the build
  tag from the output, and keep the text result to one sentence.
- Normalize stored names with posix.normalize, as the build worker does.
Hide the password of a URL in any scheme, not only http and https, so an
ssh:// Git URL with a password no longer shows it in the refusal. The SSH
user stays.

Test that a BASE64 file with a binary extension comes back as base64 even
when its bytes are valid UTF-8.
# Conflicts:
#	README.md
#	src/tools/AGENTS.md
#	src/tools/source/get_actor_version.ts
#	src/tools/source/source_files.ts
…rsion simplification

The simplified get-actor-version dropped helpers that create-actor and update-actor-version still import. Define them in source_helpers.ts until the write tools are simplified too.
Narrow both tools to the updated spec and leave to the platform the checks it already makes.

- update-actor-version takes write, edit, and delete operations, reads the version from the one Actor GET, and saves it with one PUT of sourceType and sourceFiles, skipped when the revision does not change. Move, replaceFiles, gitRepoUrl, buildTag, allOccurrences, edit excerpts, match context, caps, and the size and path checks are gone. The edit logic moves in from source_edits.ts, which is deleted.
- create-actor takes files only and sends versionNumber and buildTag only when given. The name rules, token check, defaults, caps, path and base64 checks, actor.json rules, Git source, and extra warnings are gone.
- The Actor lookup no longer compares the owner with users/me, and API errors reach the agent unchanged.
- The Actor lookup, version choice, and refusal of versions not stored as files move to source_helpers.ts, shared with get-actor-version. The build start after a write and its next step are written once.
- Results are shorter: update returns revision, changed, and changes with each file's new hash; both return build or buildError and the empty-files warning.
… simplification

create-actor sends version number 0.0 again when the caller gives none. The platform refuses a version without a number, so the default call failed and created nothing. The build tag is still sent only when given, since the platform sets latest on the only version.

The create-actor test mock no longer fills in a version number the platform does not add, and the update-actor-version test comment now describes the PUT file order the code produces.
# Conflicts:
#	tests/unit/tools.mode_contract.test.ts
# Conflicts:
#	tests/unit/tools.mode_contract.test.ts
Any other error from starting the build is a bug, so it goes to the tool-call engine
as a server error instead of reading as a failed start.
# Conflicts:
#	README.md
#	src/tools/registry.ts
# Conflicts:
#	README.md
#	src/tools/registry.ts
… get-actor-version

- Compare the text block with structuredContent for file contents, base64,
  CRLF text, and a line range.
- A file stored without format is text even with a binary extension.
- Binary extensions match in any case, and base64 comes back as stored.
- A path with no file uses none of the byte limit.
- The description states the spec's 256 KiB, not the shared constant.
- A version with no files is an empty listing, not a hidden source.
- A version without a number is left out of the several-versions message.
- A sub-resource with a username but no name is a missing Actor.
- buildFilesRevision sorts its input by path itself.
build-actor now looks the Actor up with getResourceByIdOrName, like the
versions tools: a name that is not ID-shaped goes out once as ~name, and
an ID-shaped value is tried as an ID first, then as ~name. The actor
description and the not-found text say a name alone works for your own
Actors. The live bare-name case also builds by the bare name.
The actorId filter of get-actor-build-list and get-actor-run-list now
goes through getResourceByIdOrName. The list endpoints take ~name, so a
name costs no extra request; only an ID-shaped value that matches no ID
is listed a second time, as ~name. The actorId descriptions and the
not-found text say a name alone works for your own Actors.
A name without a username now means one of your own Actors, as in the
versions tools: an ID-shaped value is tried as an ID first, then as
~name. getActorByIdOrName in utils/actor.ts reads ~name uncached and
goes on with username/name, so ~name never keys actorDefinitionCache
and the run, the MCP tool call, the MCP tool listing and the output
schema store all see a full name. A :toolName suffix is kept. IDs and
full names make no extra request. Under a payment provider nothing is
resolved, so the standby check and the call read the same name.

call-actor-widget shares the pre-execution, so it resolves too. The
actor descriptions say a bare name never means an Apify Store Actor,
and the search-actors lines state a fact instead of asking for a call.
When a name alone matched one of the caller's own Actors but there is no
definition or details under its full name, call-actor and
fetch-actor-details said the name is looked up among your own Actors
only and that a Store Actor needs its full name, which points away from
the Actor that was found. That happens before the first successful build
and while it is still running. getActorByIdOrName now returns the found
full name with a null actor in that case, and both tools reply that the
Actor exists in your account but has no successful build yet.
The call-actor, call-actor-widget and fetch-actor-details text said a
name without a username never means an Actor from Apify Store, which is
false for a creator's own published Actor: it resolves as one of their
own Actors. The text now says it never means another account's Actor.
The getActorByIdOrName docstring and src/tools/AGENTS.md said ~name
never keys actorDefinitionCache. That holds only outside payment
sessions: under a payment provider nothing is resolved, and a literal
~name is passed on as given, as it was before.
The edit cap assumed a file of at most 3 MiB, but edits could grow the text within one call, each later edit searched the grown text, and the platform checks the size only at the PUT. A 9 MiB request blocked the event loop for over a second.

Each edit now fails before its CRLF conversion and its search when the file could pass 3 MiB of UTF-16 code units. In a CRLF file each character of newText counts as two, so the text never passes the limit.
The edits branch of an entry's logged copy nested five levels deep. The logged copy is unchanged.
applyOperation fell through to the edit branch after the write and delete branches returned. Each type now has its own function, and applyOperations takes the loop out of the tool call. Checks, their order, and the errors are unchanged.
respondAfterWrite declared the link context and the build outside the try that set them. The build start now returns the build, the error, or the cancel, and respondAfterWrite builds the response from it. The responses are unchanged.
…e refusal

The catch nested its one special case in an if. isRetriedNameTaken now names the case, and every other error returns first. The responses are unchanged.
…h write tools

create-actor checked them with a set and a map, and update-actor-version with a scan of every file per write. SourcePathIndex now holds the rule for both: it looks up only the folders of a path, and update-actor-version adds and deletes paths in it as its operations run. Each refusal names the same collision as before.

listFolders now finds the slashes with indexOf, with the same output, so indexing 20,000 files takes about 6 ms instead of 13 ms.
…hange

apify-client retries a PUT after a 429, a 5xx, or a network error. When an earlier attempt was saved but not answered and the last one got a 4xx such as a 429, the error was rethrown, and a retried call without hash or revision checks would apply its edits twice. An ApifyApiError from an attempt after the first now gets the reply that names the revisions, whatever its status.
The reply to a version PUT that may have saved the change did not say what happened to the requested build. None starts, so runs keep the previous build even when the save went through. With autoBuild, the reply now ends with "No build was started."
…e searching for its oldText

Only the check before the next edit's search was pinned, so moving the length check after the search of the same edit passed every test. The new case has an oldText that is not in the file and a newText that passes the limit, and expects the length refusal, not NO_MATCH.
The regex that skips a leading byte order mark in .actor/actor.json, and the test of it, held the character itself, which no editor shows. Both now use the \uFEFF escape. The behavior is unchanged.
…ck of an edit

In a file with CRLF line breaks, the check counted every character of newText twice, so an edit whose result fits could be refused, for example a newText of 0.4 MiB without line breaks in a file of 2.5 MiB. It now adds one code unit per LF without a CR before it, the LFs the conversion turns into CRLF, still before the conversion and the search. The refusal no longer needs its CRLF sentence.

Counting reads newText once: about 50 ms for the 4.5 million LFs a 9 MiB request body can hold.
…of throwing it

The abort in finally ran in a try whose error replaced the error of a failed check. It is now caught and logged, and the Actor is still deleted.
Base automatically changed from feat/update-actor-version to master October 19, 2026 11:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants