Skip to content

fix(api): give POST /api/agent/execute_command a JSON body model both SDKs can send (#15527) - #15541

Merged
mrveiss merged 10 commits into
Dev_new_guifrom
issue-15536-unserved-backend
Sep 3, 2026
Merged

mrveiss merged 10 commits into
Dev_new_guifrom
issue-15536-unserved-backend

Conversation

@mrveiss

@mrveiss mrveiss commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Refs #15527, #15536, #15534.

Thinking Path

Three issues were in scope. I verified each against Dev_new_gui before building, and
only one of the three could be delivered from this base.

#15527 — measured, confirmed, delivered. autobot-backend/api/agent.py declared
command_data: dict beside user_role: str = Form("user"). One Form default makes
FastAPI read the whole body as application/x-www-form-urlencoded, where every field
arrives as a string, so command_data could never validate. The checked-in contract
agreed: the operation published application/x-www-form-urlencoded with a required
command_data object.

Route or clients? The route. Every caller sends JSON — send_command in the Python
SDK, sendCommand in the TypeScript SDK — and nothing in the repo sends a form to it.
The sibling route in the same module, POST /command_approval, already takes a JSON
CommandApprovalPayload with the identical user_role: str = "user" field, so the
JSON model is the pattern this file already follows and the Form default was the
outlier. Making the clients form-encode would have meant three callers bending to one
mistake.

Wire-contract note. Changing a route's request content type is a wire-contract change.
It is safe here in the strict sense that no body was accepted before: every candidate
shape answered 422, so no external caller can have a working integration to break. An
out-of-repo client that was already receiving 422 will now receive 200 or 400.

Where the model had to live. api/schemas_agent.py is the canonical home, and it sits
exactly on its file-size ceiling — the scripts/python_file_size_known_large.py /
repo_tests/python_file_size_ratchet_baseline.py pair pins it to its exact line count and
only ever shrinks, so it could not take one more model. Per the constraint, I split rather
than raised: api/schemas_agent_requests.py now holds the five request payloads
api/agent.py binds to a body, moved verbatim, plus the new CommandExecutePayload.
schemas_agent.py drops from 2283 to 2225 lines and both ratchet entries are lowered in
the same commit. api/agent.py lands back on exactly 1380, its unchanged ceiling.

#15536 — DEFERRED, with evidence. The premise that the debt is "already pinned in a
shrink-only _KNOWN_UNSERVED baseline" does not hold on this base:
repo_tests/slm_frontend_calls_reach_served_routes_test.py does not exist on
Dev_new_gui
. It lives on the unmerged PR #15538. That guard's
test_the_known_unserved_baseline_is_exact fails in both directions, so any call I
removed here would leave a stale entry behind and redden #15538 the moment it lands.
Branch protection on Dev_new_gui has required_status_checks.strict = false, so a
stale #15538 can merge without a rebase and take Dev_new_gui red with it. #15536 also
collides at file level with #15538, which edits
autobot-slm-frontend/src/views/settings/admin/UserManagementSettings.vue — the only
external caller of the getUsers() half. So #15536 is blocked_by #15538, not
blocked_by a missing decision. Full measurement and the per-call caller/route verdicts
are posted on the issue.

#15534 — left needs-decision, as filed. The question is whether VNC input forwarding
belongs on the SLM at all; nothing in the code answers it, and adding seven endpoints to
make four buttons look correct is the wrong fix. Options and a recommendation are posted
on the issue rather than guessed at here. A forced batch would have been worse than the
two it is not in.

What Changed

File Change
autobot-backend/api/schemas_agent_requests.py New. The five api/agent.py request payloads moved verbatim out of schemas_agent.py, plus CommandExecutePayload (command: str, user_role: str = "user"). 93 lines, well under the 600 cap.
autobot-backend/api/schemas_agent.py Those five classes removed; the now-unused Metadata import dropped; the personality models given their own section header. 2283 -> 2225 lines.
autobot-backend/api/agent.py execute_command takes payload: CommandExecutePayload; the Form default and the dict body are gone. Form stays imported — /pause and /resume still use it, and they are form-only, which is not the defect. Exactly 1380 lines, unchanged ceiling.
autobot-backend/api/agent_test.py Import follows ResearchTaskRequest to its new module.
autobot-frontend/src/types/generated/api.ts Regeneration mirrored by hand: Body_execute_command_api_agent_execute_command_post removed, CommandExecutePayload added in alphabetical position, the operation's request body moved to application/json, and the path entry's description updated to the new docstring.
libs/autobot-sdk-python/.../resources/agents.py send_command(command) posts {"command": command}. session_id removed — the route has no session concept and never read it.
libs/autobot-sdk-ts/src/resources/agents.ts sendCommand(command), same body, same reason.
repo_tests/route_form_and_json_body_are_not_mixed_test.py New guard for the defect class, not the instance.
scripts/python_file_size_known_large.py, repo_tests/python_file_size_ratchet_baseline.py Both lowered 2283 -> 2225 in this commit.

Verification

The route's declared content type, before and after. Measured out of tree, with both
signatures mounted on a bare FastAPI app so body parsing is what is observed:

before: media=['application/x-www-form-urlencoded']
after:  media=['application/json']
before: POST json {"command": "ls", "session_id": "s1"} -> 422 {"detail":[{"type":"missing","loc":["body","command_data"],...
after:  POST json {"command": "ls", "session_id": "s1"} -> 200 {"command":"ls","user_role":"user"}
after:  caller-supplied user_role                        -> 200 {"command":"ls","user_role":"admin"}
after:  no command                                       -> 422 {"detail":[{"type":"missing","loc":["body","command"],...

Row 4 is the exact body the Python SDK builds, and row 3 is user_role semantics
unchanged: absent means "user", supplied is honoured. admin_check is untouched. The
checked-in contract now says the same thing — api.ts moved from
"application/x-www-form-urlencoded": components["schemas"]["Body_execute_command_..."]
to "application/json": components["schemas"]["CommandExecutePayload"].

One behaviour change worth stating: a body with no command now answers 422 rather than
the hand-rolled 400 in _validate_command_request. Nothing can regress on it, because no
body reached that branch before. An empty-string command still takes the 400 path.

The new guard fires. Population floors are evaluated before the substantive assertion,
so a collapsed sweep fails by name. Three mutations, verbatim:

  1. Restore the mixed signature in api/agent.py —
    1 failed, 6 passed; autobot-backend/api/agent.py:845 execute_command Form=['user_role'] body=['command_data']
  2. Point _PACKAGES at a directory that does not exist —
    2 failed, 5 passed; FIX THE SWEEP: found only 0 route handlers, floor is 2400.
  3. Misspell the Form token in _MARKERS as FormData —
    5 failed, 2 passed; FIX THE SWEEP: found only 0 routes declaring a Form field, floor is 20.

Mutation 3 is the one that matters: it proves the literal token matched is one the subject
actually emits. test_the_extractor_sees_form_in_the_source_it_reads pins that to two real
handlers, pause_agent_api and resume_agent_api, which still declare
user_role: str = Form("user") — so drift off the real spelling loses them by name
instead of reporting zero mixtures everywhere.

Precision was measured, not assumed. A naive sweep reports 4 hits on this tree. Three
are correct code: chat_knowledge.upload_file_to_chat and
conversation_files.upload_conversation_file declare path parameters beside a Form
field, and code_source.upload_package reaches Depends through Annotated. The guard
handles all three and has regression tests for each, so the tree reports exactly 1 hit
before the fix and 0 after.

Suites run. Restored to the committed state, then:

  • repo_tests/route_form_and_json_body_are_not_mixed_test.py — 7 passed
  • repo_tests/python_file_size_ratchet_test.py — the pair is consistent, 38 passed for both files together
  • repo_tests/sdk_request_url_test.py, sdk_request_signature_params_test.py,
    sdk_response_model_contract_test.py — 133 passed, 1 failed. The failure is
    test_every_sdk_response_model_is_paired_or_explicitly_unpaired on five
    DataResponse[Session*] generics; it passes when that file runs alone, touches nothing
    in this diff, and is a pre-existing cross-file import-order artifact.
  • black --check, isort --check-only, flake8 clean on every changed Python file; no
    line over 120 characters.

Probes were written outside the repository and never staged; git status is clean.

Not closing #15527. Three of its four acceptance criteria are met. The fourth asks for
the _UNSATISFIABLE entry to come out of repo_tests/sdk_request_body_test.py — a file
that does not exist on Dev_new_gui; it is on the unmerged PR #15526. That entry is
checked to still be necessary, so #15526 must drop it when it rebases onto this. Flagged
there.

Model Used

Claude Opus 5 (1M context).

@github-actions

github-actions Bot commented Sep 2, 2026

Copy link
Copy Markdown
Contributor

✅ SSOT Configuration Compliance: Passing

🎉 No new hardcoded values of either class — ssot and other both block.

Known backlog in pipeline-scripts/hardcoded_values_baseline.txt is suppressed and tracked in #14371.

@mrveiss
mrveiss merged commit 61968cf into Dev_new_gui Sep 3, 2026
74 checks passed
@mrveiss
mrveiss deleted the issue-15536-unserved-backend branch September 3, 2026 07:36
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.

1 participant