Timothy Kelvin 修正 29 個 Apify Actor 的 AI 智能體呼叫驗證問題
AI agents were told my inputs were optional, then rejected for leaving them out 🤖
Timothy Kelvin 檢查自己發布的 100 個 Apify Actor,並修正其中 29 個 Actor 的輸入設定,以處理 AI 智能體因欄位驗證而遭拒的情況。
One word in 29 config files: required. Every field it guarded already had a default.
Hi! I'm Tim, a self-taught developer in Nairobi. I build small data tools that run as Apify Actors, serverless jobs that people pay for per result. 100 of them are public.
This week Apify's DEV account published What AI agents see when they call your Apify Actors, by SIÁN Agency. One finding in it sent me straight to my own input schemas.
🤖 What an agent sees
A person runs an Actor from a form. The fields have labels, the dropdowns list the valid values, and an example is already filled in.
An AI agent connected through the Apify MCP server gets none of that. It gets the input schema as JSON Schema, and it sends what the schema says.
The article found that two parts of the MCP server read that schema differently:
-
tools/listtells the model what a tool accepts. It leaves a field out ofrequiredwhen that field has adefault. -
tools/callchecks the model's arguments. It validates against the original schema and doesn't fill in defaults first.
So a field that is both required and has a default is listed as optional. When the model leaves it out, the call is rejected with a validation error (-32602) for the very field it was told it could skip.
An agent can recover by fetching the Actor's details and trying again. It still burns a round trip on the first call, every time. The author found this in 59 of the 253 schemas they checked.
🔍 How many of mine?
I wrote a short script that reads every .actor/input_schema.json in my portfolio. For each required field, it records whether the field has a default and whether that default equals the prefill, the example shown in the form.
| Count | |
|---|---|
| Actors with at least one required field | 29 of 100 |
| Required fields that also had a default | 32, in 26 Actors |
| Required fields with no default | 3, in 3 Actors |
| Defaults identical to the prefill | 32 of 32 |
Here's a typical one, from a vehicle safety ratings lookup (trimmed to the parts that matter):
{
"required": ["year", "make", "model"],
"properties": {
"year": { "type": "integer", "prefill": 2022, "default": 2022 },
"make": { "type": "string", "prefill": "Honda", "default": "Honda" },
"model": { "type": "string", "prefill": "Accord", "default": "Accord" }
}
}
All three fields are required, and all three have a default. In the form, nothing looks wrong, because the form is already filled in. To an agent, all three are listed as optional, and a call without them fails.
🙈 Why nothing ever caught it
Apify runs every public Actor once a day with its prefilled input, and flags it if that run fails three days in a row.
All 32 defaults were identical to the prefill. So the daily test always sent every required field, and it always passed. None of these Actors was ever flagged for it.
The test walks the path a person takes. An agent sending {} takes a different one, and nothing was walking it.
🚫 Why I didn't run the probe
The article comes with a probe that calls each Actor through the MCP server with {}. I didn't run it on mine. My own runs show up in my Actors' analytics as a user, and that analytics page is the only signal I have about real customers, so I debug locally and keep platform runs to a minimum.
That left the static half: what the schemas say. The article is clear that this half can't find everything. An Actor can report SUCCEEDED with nothing useful in it, and only running it shows that. So after the schemas, I read through the code paths of my best-earning Actor by hand, looking for that kind of silent result. More on that in fix 3.
🔧 Fix 1: a field with a default doesn't need required
Apify writes schema defaults into every run's input. A required field with a default is filled in whether the caller sends it or not, so the required flag adds nothing to the run. All it does is mislead the agent.
The rule I applied to the 26 Actors:
// Drop `required` only when every required field already has a default.
const noDefault = schema.required.filter((k) => schema.properties[k].default === undefined);
if (noDefault.length) throw new Error(`no default for ${noDefault.join(', ')}`);
schema.required = [];
Nothing else in any schema changed, and the script checked that too. A person in the form sees exactly the same thing as before.
🔧 Fix 2: three fields had no default at all
A court opinion search (query), a travel per diem lookup (locations) and a package health check (packages) each had a required field with no default.
These were honest: with no default, nothing gets dropped from the tool listing, so an agent is told to send the field. But their code also threw on empty input:
if (!query) {
throw new Error('query is required.');
}
I wanted every one of my Actors to answer {} the same way: with the example from the form, not an error. Each of the three got a default equal to its prefill, and the code now falls back to that example:
// An empty call (an AI agent calling with {}, or an empty API call) searches the input form's example instead of failing.
const query = String(queryInput ?? '').trim() || 'antitrust';
if (!String(queryInput ?? '').trim()) log.info('No query given, so searching the example "antitrust".');
In the two that take a list, a value that isn't a list still fails with a clear message. Only "nothing at all" falls back to the example.
🔇 Fix 3: zero rows is not an answer
My best-earning Actor audits small business websites and scores each business as a lead for web designers. It has a filter: only keep businesses that score above a minimum.
Set that minimum high enough and every business gets filtered out. The run used to end SUCCEEDED, with zero rows. An agent reading that would tell its user "no leads found", as a fact about the world. The real answer was "your filter was stricter than every business it checked".
And an empty call ({}) used to fail outright with "Nothing to audit", even though the schema told an agent nothing was required. Now it audits the three example businesses from the form.
For the empty case, a run that saves nothing now saves one row explaining why, and doesn't charge for it:
// Never end with an empty dataset: say why nothing was saved, in one uncharged row.
if (stats.pushed === 0) {
const note = emptyRunNote(stats, counters, options, search, categoryTitle);
await Actor.pushData(note);
}
Here's the row it writes when the filter removes all 5 businesses it audited:
{
"name": "No businesses saved in this run (not charged)",
"websiteStatus": null,
"opportunityScore": null,
"note": "All 5 businesses audited scored below 100, so none were saved (filtered businesses aren't charged). Lower minScore or turn off onlyOpportunities to see them.",
"processed": 5,
"filteredOut": 5
}
The row says what happened and what to change. A person can read that. So can a model.
🚀 Shipping 29 Actors, one at a time
29 Actors meant 29 builds. I didn't want a loop that pushes everything and checks nothing, so each Actor went through the same steps, and the script stopped at the first problem (trimmed):
before=$(node live-build.js "$a") # the build to roll back to
git commit -q -m "$msg" && git push -q origin HEAD
out=$(timeout 480 apify push 2>&1)
echo "$out" | grep -q "Build: SUCCEEDED" || { echo "STOP $a"; exit 1; }
verify=$(node live-build.js "$a" --verify) # live schema: required is [] and matches local
echo "OK $a | before $before | after $verify"
The --verify step reads the input schema back from the new live build, not from my disk. All 29 passed, and the previous build of every Actor is saved in case one needs rolling back.
🎯 What I learned
- My input schemas have a second reader now. I wrote them for a form. A model reads them as a contract, and a contradiction in the contract becomes a failed call.
-
requiredplusdefaultis a contradiction. If the platform fills a field in anyway, don't tell callers they must send it. - A daily test only checks the path it takes. Passing it says nothing about any other input, including an empty one.
- Zero rows needs a reason. If a filter removes everything, say so in a row, not in a log nobody opens.
-
There's more to check. 33 fields across 16 of my Actors still have a prefill but no default. A person sees the example. An agent sending
{}gets nothing for that field. Some of those Actors already fall back to an example in code; I haven't checked the rest yet.
If you publish Actors, this one-liner lists the fields in a schema that are both required and defaulted:
node -e "const s=require('./.actor/input_schema.json');console.log((s.required||[]).filter(k=>s.properties[k]?.default!==undefined))"
An empty list means an agent gets an honest contract. Anything else is a field it was told it could skip, and then got rejected for skipping.
來源:DEV Community · dev.to
