template-first agent APIs and high-level authoring surfaces#6
Conversation
There was a problem hiding this comment.
Pull request overview
This PR raises the authoring/ops surface of void-control by introducing template-first bridge endpoints and corresponding voidctl commands, plus new batch/yolo and team phase-1 flows, and SDKs (Python/Node/Go) to consume these higher-level APIs.
Changes:
- Add file-backed control templates and bridge routes for listing/getting/dry-run/execute of templates.
- Add canonical
batchremote-offload API (withyoloalias) and phase-1teamauthoring that compiles toExecutionSpec. - Add
voidctlCLI + interactive console commands, plus SDK scaffolds/tests/examples for Python, Node, and Go.
Reviewed changes
Copilot reviewed 56 out of 57 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| tests/voidctl_execution_cli.rs | CLI regression tests for template/batch/yolo/team commands (incl. interactive). |
| tests/template_api.rs | Unit tests for template parsing/loading/compilation. |
| tests/team_api.rs | Unit tests for team parsing/validation and bridge round-trips. |
| tests/execution_bridge.rs | Bridge tests covering new template routes and benchmark template behavior. |
| tests/batch_api.rs | Unit + bridge tests for batch/yolo parsing and compilation. |
| templates/warm-agent-basic.yaml | Checked-in warm-agent control template definition. |
| templates/single-agent-basic.yaml | Checked-in single-agent control template definition. |
| templates/benchmark-runner-python.yaml | Checked-in benchmark control template definition with multi-proposal bindings. |
| src/templates/schema.rs | Template schema structs + validation. |
| src/templates/mod.rs | Template module public API: parse/list/load. |
| src/templates/compile.rs | Template input normalization + binding application into ExecutionSpec. |
| src/team/schema.rs | Team schema structs + validation rules (phase-1 constraints). |
| src/team/mod.rs | Team module exports. |
| src/team/compile.rs | Team spec compilation into ExecutionSpec. |
| src/lib.rs | Expose batch, team, and templates modules under serde feature. |
| src/bridge.rs | Add bridge routes/handlers for templates, batch/yolo, and team. |
| src/bin/voidctl.rs | Add `voidctl template |
| src/batch/schema.rs | Batch/yolo schema structs + validation/normalization. |
| src/batch/mod.rs | Batch module exports. |
| src/batch/compile.rs | Batch spec compilation into ExecutionSpec. |
| sdks/python/tests/test_client.py | Python SDK client tests for templates/executions and batch/yolo. |
| sdks/python/src/void_control/templates.py | Python Templates subclient. |
| sdks/python/src/void_control/models.py | Python SDK models for templates/executions/batch runs. |
| sdks/python/src/void_control/executions.py | Python Executions subclient (get/wait). |
| sdks/python/src/void_control/client.py | Python root client wiring + error decoding. |
| sdks/python/src/void_control/batch.py | Python batch/yolo clients + run waiters. |
| sdks/python/src/void_control/init.py | Python package export surface. |
| sdks/python/pyproject.toml | Python SDK packaging metadata. |
| sdks/python/examples/template_execute.py | Python example for template execute + wait. |
| sdks/python/examples/batch_run.py | Python example for batch/yolo run + wait. |
| sdks/python/README.md | Python SDK usage docs. |
| sdks/node/test/client.test.mjs | Node SDK tests for templates/executions and batch/yolo. |
| sdks/node/src/templates.js | Node Templates subclient. |
| sdks/node/src/models.js | Node model mapping helpers + error type. |
| sdks/node/src/index.js | Node SDK entry export. |
| sdks/node/src/executions.js | Node Executions subclient (get/wait). |
| sdks/node/src/client.js | Node root client wiring + error decoding. |
| sdks/node/src/batch.js | Node batch/yolo clients + run waiters. |
| sdks/node/examples/templateExecute.mjs | Node example for template execute + wait. |
| sdks/node/examples/batchRun.mjs | Node example for batch/yolo run + wait. |
| sdks/node/README.md | Node SDK usage docs. |
| sdks/go/templates.go | Go Templates subclient. |
| sdks/go/models.go | Go SDK models for templates/executions/batch runs. |
| sdks/go/go.mod | Go SDK module definition. |
| sdks/go/executions.go | Go Executions subclient (get/wait). |
| sdks/go/examples/template_execute/main.go | Go example for template execute + wait. |
| sdks/go/examples/batch_run/main.go | Go example for batch/yolo run + wait. |
| sdks/go/client_test.go | Go SDK integration tests via mocked HTTP transport. |
| sdks/go/client.go | Go root client wiring + error decoding. |
| sdks/go/batch.go | Go batch/yolo clients + run waiters. |
| sdks/go/README.md | Go SDK usage docs. |
| examples/team/rust_article_team.yaml | Example team spec. |
| examples/runtime-templates/warm_agent_basic.yaml | Runtime worker template referenced by templates/batch/team. |
| examples/batch/background_repo_work.yaml | Example batch spec. |
| README.md | Operator docs for template/batch/yolo/team surfaces and CLI usage. |
| AGENTS.md | Repository/module map updates documenting new surfaces. |
| .gitignore | Ignore Python bytecode artifacts. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| pub fn load_template(id: &str) -> Result<ControlTemplate, TemplateValidationError> { | ||
| let path = template_dir().join(format!("{id}.yaml")); | ||
| load_template_from_path(&path) | ||
| } |
There was a problem hiding this comment.
load_template joins template_id directly into a filesystem path (templates/{id}.yaml). Because template_id ultimately comes from the HTTP path, this allows path traversal (e.g. ../..) and reading arbitrary files outside templates/. Please validate id (reject path separators / .. components, or enforce an allowlist regex) before constructing the path.
| Err(err) if err.to_string().contains("No such file or directory") => json_response( | ||
| 404, | ||
| &ApiError { | ||
| code: "NOT_FOUND", | ||
| message: format!("template '{}' not found", template_id), | ||
| retryable: false, | ||
| }, | ||
| ), |
There was a problem hiding this comment.
handle_template_get detects missing templates by checking whether the error string contains "No such file or directory". This is brittle (OS-/locale-dependent) and may misclassify other I/O errors. Consider preserving the underlying std::io::ErrorKind from the loader (e.g., return a structured error enum) so you can reliably map NotFound to HTTP 404.
| let template = templates::load_template(template_id).map_err(|err| { | ||
| if err.to_string().contains("No such file or directory") { | ||
| json_response( | ||
| 404, | ||
| &ApiError { | ||
| code: "NOT_FOUND", | ||
| message: format!("template '{}' not found", template_id), | ||
| retryable: false, | ||
| }, | ||
| ) | ||
| } else { | ||
| json_response( | ||
| 400, | ||
| &ApiError { | ||
| code: "INVALID_TEMPLATE", | ||
| message: err.to_string(), | ||
| retryable: false, | ||
| }, | ||
| ) | ||
| } | ||
| })?; |
There was a problem hiding this comment.
compile_template_request also relies on string matching ("No such file or directory") to return 404 for missing templates. This has the same brittleness as the GET handler and can produce incorrect status codes on different platforms. Please switch to a structured not-found signal from templates::load_template (or validate the id and check std::fs::metadata/ErrorKind::NotFound).
| #[cfg(feature = "serde")] | ||
| fn parse_submitted_batch_spec(body: &str) -> Result<batch::BatchSpec, JsonHttpResponse> { | ||
| let trimmed = body.trim_start(); | ||
| let parsed = if trimmed.starts_with('{') || trimmed.starts_with('[') { | ||
| batch::parse_batch_json(body) | ||
| } else { | ||
| batch::parse_batch_yaml(body) | ||
| }; | ||
| parsed.map_err(|err| { | ||
| json_response( | ||
| 400, | ||
| &ApiError { | ||
| code: "INVALID_BATCH", | ||
| message: err.to_string(), | ||
| retryable: false, | ||
| }, | ||
| ) | ||
| }) | ||
| } | ||
|
|
||
| #[cfg(feature = "serde")] | ||
| fn parse_submitted_team_spec(body: &str) -> Result<team::TeamSpec, JsonHttpResponse> { | ||
| let trimmed = body.trim_start(); | ||
| let parsed = if trimmed.starts_with('{') || trimmed.starts_with('[') { | ||
| team::parse_team_json(body) | ||
| } else { | ||
| team::parse_team_yaml(body) | ||
| }; |
There was a problem hiding this comment.
parse_submitted_batch_spec/parse_submitted_team_spec decide JSON vs YAML by looking at the first non-whitespace character. YAML supports flow-style documents that can start with { or [; those would be misrouted to the JSON parser and fail without a YAML fallback. The existing execution parsing (parse_execution_spec_request) tries JSON first and then falls back to YAML; mirroring that approach here would make the behavior more robust and consistent.
| "integer" => { | ||
| let Some(number) = value.as_i64().or_else(|| value.as_u64().map(|n| n as i64)) else { | ||
| return Err(TemplateValidationError::new(format!( | ||
| "input '{}' must be an integer", | ||
| name | ||
| ))); | ||
| }; | ||
| validate_numeric_range(name, field, number as f64)?; | ||
| } |
There was a problem hiding this comment.
In integer input validation, as_u64() values are cast to i64 with as, which will wrap for values > i64::MAX (silently turning a large positive into a negative). Please reject out-of-range u64 values (or validate using i128/u64 directly) before casting so range checks and bindings can't be bypassed/incorrect.
| BestCandidateID string `json:"best_candidate_id"` | ||
| CompletedIterations int `json:"completed_iterations"` | ||
| TotalCandidateFailures int `json:"total_candidate_failures"` |
There was a problem hiding this comment.
ExecutionResult.BestCandidateID is typed as string, but the bridge API returns best_candidate_id: null while runs are pending. JSON unmarshalling will fail on null into a string, breaking Executions.Get/Wait (and also BatchRunDetail). Use *string (or sql.NullString-style wrapper) for BestCandidateID so null is handled correctly.
| BestCandidateID string `json:"best_candidate_id"` | |
| CompletedIterations int `json:"completed_iterations"` | |
| TotalCandidateFailures int `json:"total_candidate_failures"` | |
| BestCandidateID *string `json:"best_candidate_id"` | |
| CompletedIterations int `json:"completed_iterations"` | |
| TotalCandidateFailures int `json:"total_candidate_failures"` |
| fn temp_inputs_path(name: &str) -> PathBuf { | ||
| let mut path = std::env::temp_dir(); | ||
| path.push(format!("voidctl-test-{}-{name}", std::process::id())); | ||
| path | ||
| } |
There was a problem hiding this comment.
The tests write input files to a deterministic path in the OS temp dir based only on PID (and a caller-provided name). This can collide under parallel test execution, and it can leave temp files behind on failure. Prefer a unique temp dir/file helper (e.g., tempfile::tempdir / NamedTempFile) and/or ensure cleanup at the end of each test.
Problem
The current execution spec surface is too low-level for normal operators. It exposes orchestration internals directly and makes common workflows like template execution, remote offload, and simple team authoring harder than they need to be.
Summary of changes
voidctl template ...commands and interactive/template ...supportbatchas the canonical remote-offload API withyoloaliasingteamauthoring that compiles into the existing swarm/supervision engineSpecs and docs
Verification
Follow-up
feat/compute-sandbox-apivoid-boxdaemon support