Skip to content

Commit d3a8956

Browse files
authored
Honor optional native task outputs (#1780)
1 parent a759805 commit d3a8956

11 files changed

Lines changed: 98 additions & 36 deletions

.github/scripts/run-agent-task/execute-native-agent-task.mjs

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -164,17 +164,37 @@ async function verifyPublishedPullRequest(publication, targetRepo, cwd) {
164164
function projections(value, runtimeResult) {
165165
const entries = Object.entries(record(value))
166166
const output = {}
167-
for (const [name, source] of entries) {
168-
if (typeof source !== "string" || !source.trim() || !/^[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)*$/.test(source)) {
169-
throw new Error(`output_projections.${name} must be a dot-delimited result path.`)
167+
for (const [name, declaration] of entries) {
168+
const descriptor = typeof declaration === "string" ? { path: declaration, required: true } : record(declaration)
169+
if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
170+
throw new Error(`output_projections.${name} must have a non-empty output name.`)
170171
}
171-
const projected = resultValue(runtimeResult, source.trim())
172-
if (projected === undefined) throw new Error(`output_projections.${name} did not resolve from ${source}.`)
172+
if (Object.keys(descriptor).some((key) => key !== "path" && key !== "required")) {
173+
throw new Error(`output_projections.${name} descriptor supports only path and required.`)
174+
}
175+
const path = string(descriptor.path)
176+
if (!path || !/^[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)*$/.test(path)) {
177+
throw new Error(`output_projections.${name}.path must be a dot-delimited result path.`)
178+
}
179+
if (typeof descriptor.required !== "boolean") {
180+
throw new Error(`output_projections.${name}.required must be a boolean.`)
181+
}
182+
const projected = resultValue(runtimeResult, path)
183+
if (projected === undefined && descriptor.required) throw new Error(`output_projections.${name} did not resolve from ${path}.`)
184+
if (projected === undefined) continue
173185
output[name] = projected
174186
}
175187
return output
176188
}
177189

190+
function requiredArtifacts(declarations) {
191+
return Array.from(new Set((Array.isArray(declarations) ? declarations : []).flatMap((declaration) => {
192+
const artifact = record(declaration)
193+
const name = string(artifact.name)
194+
return artifact.required === true && artifact.direction !== "input" && name ? [name] : []
195+
})))
196+
}
197+
178198
function workflowPath(path) {
179199
const relativePath = relative(workspace, resolve(path))
180200
return relativePath && !relativePath.startsWith("..") ? relativePath.replaceAll("\\", "/") : ".codebox/agent-task-workflow-result.json"
@@ -340,7 +360,7 @@ const taskInput = {
340360
runner_workspace_policy: { allowed_repos: request.access.allowed_repos },
341361
},
342362
artifact_declarations: request.artifacts?.declarations || [],
343-
required_artifacts: request.artifacts?.expected || [],
363+
required_artifacts: requiredArtifacts(request.artifacts?.declarations),
344364
output_projections: [],
345365
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [] },
346366
},

.github/workflows/run-agent-task.yml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,7 +84,7 @@ name: Run Agent Task (reusable)
8484
type: number
8585
default: 600000
8686
output_projections:
87-
description: JSON object mapping workflow output names to result paths.
87+
description: JSON object mapping workflow output names to paths or {path,required} descriptors.
8888
type: string
8989
default: '{}'
9090
transcript_artifact_name:
@@ -96,7 +96,7 @@ name: Run Agent Task (reusable)
9696
type: string
9797
default: ""
9898
expected_artifacts:
99-
description: JSON array of expected artifact names.
99+
description: JSON array of expected artifact names used for collection; required outputs come from artifact_declarations.required.
100100
type: string
101101
default: '[]'
102102
artifact_declarations:

docs/agent-task-reusable-workflow.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ jobs:
1919
success_requires_pr: true
2020
access_token_repos: Automattic/example-target
2121
allowed_repos: '["Automattic/example-target"]'
22-
output_projections: '{"pr_url":"outputs.artifact_result.result.outputs.runner_workspace_publication.pull_request.url"}'
22+
output_projections: '{"pr_url":{"path":"outputs.artifact_result.result.outputs.runner_workspace_publication.pull_request.url","required":false}}'
2323
secrets:
2424
EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }}
2525
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -65,7 +65,8 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c
6565
- `success_requires_pr`: require a successful, published runner-workspace pull request for `target_repo`.
6666
- `access_token_repos`: comma-separated repositories available to the supplied access token.
6767
- `allowed_repos`: JSON repository allowlist. It and `access_token_repos` must explicitly include `target_repo`.
68-
- `output_projections`: JSON object mapping output names to dot-delimited paths in the native result. Every projection must resolve.
68+
- `expected_artifacts`: JSON allowlist and collection metadata. Required runtime artifacts are derived only from `artifact_declarations` entries with `required: true` and must be declared output artifacts.
69+
- `output_projections`: JSON object mapping output names to dot-delimited paths in the native result. A string value remains a required projection for compatibility. A descriptor value has the exact shape `{ "path": "result.path", "required": false }`; unresolved optional projections are omitted, while unresolved required projections fail the run.
6970

7071
## Access And Publication
7172

docs/public-api-contract.md

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -433,9 +433,10 @@ Runtime package callers use `wp-codebox/run-runtime-package` or
433433
`wp-codebox/runtime-package-output-projection/v1`; results use
434434
`wp-codebox/runtime-package-result/v1`. `package.slug` is package identity;
435435
`package.source` is the import path. Workspace-relative sources must be
436-
normalized against an explicit workspace root before execution. Required
437-
artifacts are declared explicitly, runtime import failures return structured
438-
diagnostics, and semantic outputs plus explicit typed/structured artifacts remain
436+
normalized against an explicit workspace root before execution. Required artifacts
437+
derive only from output declarations marked `required: true`. Runtime import
438+
failures return structured diagnostics, and semantic outputs plus
439+
explicit typed/structured artifacts remain
439440
separate result fields. These contracts are generic Codebox runtime/package
440441
shapes for consumers using the runtime package API.
441442
Consumers can read the same ids from `runtimeContractManifest().schemas` and

packages/runtime-core/src/runtime-package-contracts.ts

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ export function normalizeRuntimePackageTask(options: RuntimePackageTaskOptions):
104104
input: isPlainObject(options.input) ? options.input : {},
105105
artifact_declarations: normalizeRuntimePackageArtifactDeclarations(options.artifact_declarations),
106106
output_projections: normalizeRuntimePackageOutputProjections(options.output_projections),
107-
required_artifacts: runtimePackageRequiredArtifacts(options.required_artifacts, options.artifact_declarations),
107+
required_artifacts: runtimePackageRequiredArtifacts(options.artifact_declarations),
108108
metadata,
109109
}) as RuntimePackageTask
110110
}
@@ -148,6 +148,13 @@ export function validateRuntimePackageTask(value: unknown): RuntimePackageTaskVa
148148
}
149149
if (!Array.isArray(value.required_artifacts)) {
150150
diagnostics.push(runtimePackageDiagnostic("missing_required_artifacts", "Runtime package task requires required_artifacts array.", { path: "required_artifacts" }))
151+
} else {
152+
const declaredRequired = runtimePackageRequiredArtifacts(value.artifact_declarations)
153+
for (const name of stringList(value.required_artifacts)) {
154+
if (!declaredRequired.includes(name)) {
155+
diagnostics.push(runtimePackageDiagnostic("undeclared_required_artifact", `Runtime package required_artifacts contains ${name}, which is not declared as a required output artifact.`, { path: "required_artifacts", details: { name } }))
156+
}
157+
}
151158
}
152159
if (isPlainObject(value.package) && stringValue(value.package.source) && isWorkspaceRelativePackageSource(stringValue(value.package.source))) {
153160
diagnostics.push(runtimePackageDiagnostic("workspace_root_required", "Workspace-relative package.source requires explicit workspace root normalization before execution.", { path: "package.source" }))
@@ -197,9 +204,7 @@ function runtimePackageWorkflow(value: unknown, fallbackId: string): RuntimePack
197204
return fallbackId ? { id: fallbackId } : undefined
198205
}
199206

200-
function runtimePackageRequiredArtifacts(value: unknown, declarations: unknown): string[] {
201-
const explicit = stringList(value)
202-
if (explicit.length > 0) return explicit
207+
function runtimePackageRequiredArtifacts(declarations: unknown): string[] {
203208
return normalizeRuntimePackageArtifactDeclarations(declarations)
204209
.filter((artifact) => artifact.direction !== "input" && artifact.required === true)
205210
.map((artifact) => artifact.name)

packages/runtime-core/src/runtime-package-execution.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -125,8 +125,8 @@ export function normalizeRuntimePackageArtifactDeclarations(value: unknown): Run
125125
direction,
126126
required: typeof entry.required === "boolean" ? entry.required : undefined,
127127
path: stringValue(entry.path) || undefined,
128-
contentType: stringValue(entry.contentType) || undefined,
129-
payloadSchema: payloadSchema(entry.payloadSchema),
128+
contentType: stringValue(entry.contentType ?? entry.content_type) || undefined,
129+
payloadSchema: payloadSchema(entry.payloadSchema ?? entry.artifact_schema),
130130
metadata: isPlainObject(entry.metadata) ? entry.metadata : {},
131131
})]
132132
})

packages/wordpress-plugin/src/class-wp-codebox-agents-api-adapter.php

Lines changed: 1 addition & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -654,10 +654,6 @@ private static function runtime_package_workflow_for_agents_api( array $input ):
654654

655655
/** @param array<string,mixed> $input Runtime input. @return array<string,mixed> */
656656
private static function runtime_package_required_artifacts_for_agents_api( array $input ): array {
657-
if ( is_array( $input['required_artifacts'] ?? null ) ) {
658-
return $input;
659-
}
660-
661657
$required = array();
662658
foreach ( is_array( $input['artifact_declarations'] ?? null ) ? $input['artifact_declarations'] : array() as $artifact ) {
663659
if ( is_array( $artifact ) && true === ( $artifact['required'] ?? false ) && 'input' !== (string) ( $artifact['direction'] ?? 'output' ) ) {
@@ -667,9 +663,7 @@ private static function runtime_package_required_artifacts_for_agents_api( array
667663
}
668664
}
669665
}
670-
if ( ! empty( $required ) ) {
671-
$input['required_artifacts'] = array_values( array_unique( $required ) );
672-
}
666+
$input['required_artifacts'] = array_values( array_unique( $required ) );
673667

674668
return $input;
675669
}

packages/wordpress-plugin/src/class-wp-codebox-runtime-package-service.php

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,8 @@ private function normalize_task_input( array $input ): array|WP_Error {
3131
return new WP_Error( 'wp_codebox_runtime_package_task_invalid', 'Runtime package task does not match wp-codebox/runtime-package-task/v1.', array( 'status' => 400, 'diagnostics' => $invalid ) );
3232
}
3333

34+
$input['required_artifacts'] = $this->required_artifacts( $input );
35+
3436
return $input;
3537
}
3638

@@ -59,17 +61,21 @@ private function task_validation_errors( array $task ): array {
5961
}
6062
if ( ! is_array( $task['required_artifacts'] ?? null ) ) {
6163
$errors[] = $this->diagnostic( 'runtime_package_task_invalid_required_artifacts', 'Runtime package task requires required_artifacts array.', 'required_artifacts' );
64+
} else {
65+
$declared_required = $this->required_artifacts( $task );
66+
foreach ( $task['required_artifacts'] as $required ) {
67+
$name = $this->string_value( $required );
68+
if ( '' !== $name && ! in_array( $name, $declared_required, true ) ) {
69+
$errors[] = $this->diagnostic( 'runtime_package_task_undeclared_required_artifact', 'Runtime package required_artifacts contains an artifact that is not declared as a required output artifact: ' . $name . '.', 'required_artifacts' );
70+
}
71+
}
6272
}
6373

6474
return $errors;
6575
}
6676

6777
/** @param array<string,mixed> $input Runtime package input. @return string[] */
6878
private function required_artifacts( array $input ): array {
69-
if ( is_array( $input['required_artifacts'] ?? null ) ) {
70-
return array_values( array_unique( array_filter( array_map( 'strval', $input['required_artifacts'] ) ) ) );
71-
}
72-
7379
$required = array();
7480
foreach ( is_array( $input['artifact_declarations'] ?? null ) ? $input['artifact_declarations'] : array() as $artifact ) {
7581
if ( is_array( $artifact ) && true === ( $artifact['required'] ?? false ) && 'input' !== (string) ( $artifact['direction'] ?? 'output' ) ) {

scripts/php-runtime-package-public-contract-smoke.php

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,16 @@ function wp_get_ability( string $name ): ?WP_Codebox_Runtime_Package_Smoke_Abili
114114
assert( 'contract-runtime' === $result['metadata']['runtime_provider']['id'] );
115115
assert( isset( $result['metadata']['received'] ) );
116116

117+
$optional_artifact_task = $task;
118+
$optional_artifact_task['artifact_declarations'] = array(
119+
array( 'name' => 'optional-report', 'type' => 'markdown', 'required' => false ),
120+
array( 'name' => 'required-report', 'type' => 'markdown', 'required' => true ),
121+
);
122+
$optional_artifact_task['required_artifacts'] = array( 'optional-report' );
123+
$undeclared_required = WP_Codebox_Abilities::run_runtime_package( $optional_artifact_task );
124+
assert( is_wp_error( $undeclared_required ) );
125+
assert( 'wp_codebox_runtime_package_task_invalid' === $undeclared_required->get_error_code() );
126+
117127
$bundle_root = realpath( (string) ( getenv( 'WP_CODEBOX_RUNTIME_PACKAGE_FIXTURE' ) ?: __DIR__ . '/../tests/fixtures/wpsg-runtime-package' ) );
118128
assert( false !== $bundle_root );
119129
$staged_bundle_root = sys_get_temp_dir() . '/wp-codebox-runtime-package-' . bin2hex( random_bytes( 8 ) );

tests/runtime-package-execution.test.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -59,11 +59,11 @@ assert.deepEqual(normalizeRuntimePackageOutputProjections([{ name: "artifactInde
5959
}])
6060

6161
const task = normalizeRuntimePackageTask({
62-
runtimePackage: "bundles/example-agent",
62+
package: { slug: "example-agent", source: "bundles/example-agent" },
6363
workspaceRoot: "/workspace/example-project",
6464
input: { prompt: "collect typed outputs" },
65-
artifactDeclarations: [{ name: "report", type: "markdown", required: true, path: "files/report.md" }],
66-
outputProjections: [{ name: "summary", source: "result.summary", type: "text", required: true }],
65+
artifact_declarations: [{ name: "report", type: "markdown", required: true, path: "files/report.md" }],
66+
output_projections: [{ name: "summary", source: "result.summary", type: "text", required: true }],
6767
metadata: { caller: "contract-test" },
6868
})
6969
assert.equal(task.schema, RUNTIME_PACKAGE_TASK_SCHEMA)
@@ -72,6 +72,20 @@ assert.deepEqual(task.workflow, { id: "example-agent" })
7272
assert.deepEqual(task.required_artifacts, ["report"])
7373
assert.deepEqual(validateRuntimePackageTask(task), { valid: true, task, diagnostics: [] })
7474

75+
const optionalArtifactTask = normalizeRuntimePackageTask({
76+
package: { slug: "example-agent", source: "/workspace/example-agent" },
77+
input: {},
78+
artifact_declarations: [
79+
{ name: "optional-report", type: "markdown", required: false },
80+
{ name: "required-report", type: "markdown", required: true },
81+
],
82+
required_artifacts: ["optional-report"],
83+
})
84+
assert.deepEqual(optionalArtifactTask.required_artifacts, ["required-report"], "required artifacts derive only from required declarations")
85+
const undeclaredRequired = validateRuntimePackageTask({ ...optionalArtifactTask, required_artifacts: ["optional-report"] })
86+
assert.equal(undeclaredRequired.valid, false)
87+
assert.equal(undeclaredRequired.diagnostics.at(-1)?.code, "undeclared_required_artifact")
88+
7589
const missingPublicFields = validateRuntimePackageTask({ schema: RUNTIME_PACKAGE_TASK_SCHEMA, package: { slug: "example-agent" } })
7690
assert.equal(missingPublicFields.valid, false)
7791
assert.deepEqual(missingPublicFields.diagnostics.map((diagnostic) => diagnostic.code), [

0 commit comments

Comments
 (0)