Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 26 additions & 6 deletions .github/scripts/run-agent-task/execute-native-agent-task.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -164,17 +164,37 @@ async function verifyPublishedPullRequest(publication, targetRepo, cwd) {
function projections(value, runtimeResult) {
const entries = Object.entries(record(value))
const output = {}
for (const [name, source] of entries) {
if (typeof source !== "string" || !source.trim() || !/^[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)*$/.test(source)) {
throw new Error(`output_projections.${name} must be a dot-delimited result path.`)
for (const [name, declaration] of entries) {
const descriptor = typeof declaration === "string" ? { path: declaration, required: true } : record(declaration)
if (!/^[A-Za-z0-9_.-]+$/.test(name)) {
throw new Error(`output_projections.${name} must have a non-empty output name.`)
}
const projected = resultValue(runtimeResult, source.trim())
if (projected === undefined) throw new Error(`output_projections.${name} did not resolve from ${source}.`)
if (Object.keys(descriptor).some((key) => key !== "path" && key !== "required")) {
throw new Error(`output_projections.${name} descriptor supports only path and required.`)
}
const path = string(descriptor.path)
if (!path || !/^[A-Za-z0-9_.-]+(?:\.[A-Za-z0-9_.-]+)*$/.test(path)) {
throw new Error(`output_projections.${name}.path must be a dot-delimited result path.`)
}
if (typeof descriptor.required !== "boolean") {
throw new Error(`output_projections.${name}.required must be a boolean.`)
}
const projected = resultValue(runtimeResult, path)
if (projected === undefined && descriptor.required) throw new Error(`output_projections.${name} did not resolve from ${path}.`)
if (projected === undefined) continue
output[name] = projected
}
return output
}

function requiredArtifacts(declarations) {
return Array.from(new Set((Array.isArray(declarations) ? declarations : []).flatMap((declaration) => {
const artifact = record(declaration)
const name = string(artifact.name)
return artifact.required === true && artifact.direction !== "input" && name ? [name] : []
})))
}

function workflowPath(path) {
const relativePath = relative(workspace, resolve(path))
return relativePath && !relativePath.startsWith("..") ? relativePath.replaceAll("\\", "/") : ".codebox/agent-task-workflow-result.json"
Expand Down Expand Up @@ -340,7 +360,7 @@ const taskInput = {
runner_workspace_policy: { allowed_repos: request.access.allowed_repos },
},
artifact_declarations: request.artifacts?.declarations || [],
required_artifacts: request.artifacts?.expected || [],
required_artifacts: requiredArtifacts(request.artifacts?.declarations),
output_projections: [],
metadata: { workload: request.workload, ...(materializedPackage ? { imported_agent: materializedPackage.identity } : {}), runtime_sources: materializedRuntimeSources?.descriptors ?? [] },
},
Expand Down
4 changes: 2 additions & 2 deletions .github/workflows/run-agent-task.yml
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ name: Run Agent Task (reusable)
type: number
default: 600000
output_projections:
description: JSON object mapping workflow output names to result paths.
description: JSON object mapping workflow output names to paths or {path,required} descriptors.
type: string
default: '{}'
transcript_artifact_name:
Expand All @@ -96,7 +96,7 @@ name: Run Agent Task (reusable)
type: string
default: ""
expected_artifacts:
description: JSON array of expected artifact names.
description: JSON array of expected artifact names used for collection; required outputs come from artifact_declarations.required.
type: string
default: '[]'
artifact_declarations:
Expand Down
5 changes: 3 additions & 2 deletions docs/agent-task-reusable-workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ jobs:
success_requires_pr: true
access_token_repos: Automattic/example-target
allowed_repos: '["Automattic/example-target"]'
output_projections: '{"pr_url":"outputs.artifact_result.result.outputs.runner_workspace_publication.pull_request.url"}'
output_projections: '{"pr_url":{"path":"outputs.artifact_result.result.outputs.runner_workspace_publication.pull_request.url","required":false}}'
secrets:
EXTERNAL_PACKAGE_SOURCE_POLICY: ${{ secrets.EXTERNAL_PACKAGE_SOURCE_POLICY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
Expand Down Expand Up @@ -65,7 +65,8 @@ This release-coherence contract fixes [#1759](https://github.com/Automattic/wp-c
- `success_requires_pr`: require a successful, published runner-workspace pull request for `target_repo`.
- `access_token_repos`: comma-separated repositories available to the supplied access token.
- `allowed_repos`: JSON repository allowlist. It and `access_token_repos` must explicitly include `target_repo`.
- `output_projections`: JSON object mapping output names to dot-delimited paths in the native result. Every projection must resolve.
- `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.
- `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.

## Access And Publication

Expand Down
7 changes: 4 additions & 3 deletions docs/public-api-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,9 +433,10 @@ Runtime package callers use `wp-codebox/run-runtime-package` or
`wp-codebox/runtime-package-output-projection/v1`; results use
`wp-codebox/runtime-package-result/v1`. `package.slug` is package identity;
`package.source` is the import path. Workspace-relative sources must be
normalized against an explicit workspace root before execution. Required
artifacts are declared explicitly, runtime import failures return structured
diagnostics, and semantic outputs plus explicit typed/structured artifacts remain
normalized against an explicit workspace root before execution. Required artifacts
derive only from output declarations marked `required: true`. Runtime import
failures return structured diagnostics, and semantic outputs plus
explicit typed/structured artifacts remain
separate result fields. These contracts are generic Codebox runtime/package
shapes for consumers using the runtime package API.
Consumers can read the same ids from `runtimeContractManifest().schemas` and
Expand Down
13 changes: 9 additions & 4 deletions packages/runtime-core/src/runtime-package-contracts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ export function normalizeRuntimePackageTask(options: RuntimePackageTaskOptions):
input: isPlainObject(options.input) ? options.input : {},
artifact_declarations: normalizeRuntimePackageArtifactDeclarations(options.artifact_declarations),
output_projections: normalizeRuntimePackageOutputProjections(options.output_projections),
required_artifacts: runtimePackageRequiredArtifacts(options.required_artifacts, options.artifact_declarations),
required_artifacts: runtimePackageRequiredArtifacts(options.artifact_declarations),
metadata,
}) as RuntimePackageTask
}
Expand Down Expand Up @@ -148,6 +148,13 @@ export function validateRuntimePackageTask(value: unknown): RuntimePackageTaskVa
}
if (!Array.isArray(value.required_artifacts)) {
diagnostics.push(runtimePackageDiagnostic("missing_required_artifacts", "Runtime package task requires required_artifacts array.", { path: "required_artifacts" }))
} else {
const declaredRequired = runtimePackageRequiredArtifacts(value.artifact_declarations)
for (const name of stringList(value.required_artifacts)) {
if (!declaredRequired.includes(name)) {
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 } }))
}
}
}
if (isPlainObject(value.package) && stringValue(value.package.source) && isWorkspaceRelativePackageSource(stringValue(value.package.source))) {
diagnostics.push(runtimePackageDiagnostic("workspace_root_required", "Workspace-relative package.source requires explicit workspace root normalization before execution.", { path: "package.source" }))
Expand Down Expand Up @@ -197,9 +204,7 @@ function runtimePackageWorkflow(value: unknown, fallbackId: string): RuntimePack
return fallbackId ? { id: fallbackId } : undefined
}

function runtimePackageRequiredArtifacts(value: unknown, declarations: unknown): string[] {
const explicit = stringList(value)
if (explicit.length > 0) return explicit
function runtimePackageRequiredArtifacts(declarations: unknown): string[] {
return normalizeRuntimePackageArtifactDeclarations(declarations)
.filter((artifact) => artifact.direction !== "input" && artifact.required === true)
.map((artifact) => artifact.name)
Expand Down
4 changes: 2 additions & 2 deletions packages/runtime-core/src/runtime-package-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,8 +125,8 @@ export function normalizeRuntimePackageArtifactDeclarations(value: unknown): Run
direction,
required: typeof entry.required === "boolean" ? entry.required : undefined,
path: stringValue(entry.path) || undefined,
contentType: stringValue(entry.contentType) || undefined,
payloadSchema: payloadSchema(entry.payloadSchema),
contentType: stringValue(entry.contentType ?? entry.content_type) || undefined,
payloadSchema: payloadSchema(entry.payloadSchema ?? entry.artifact_schema),
metadata: isPlainObject(entry.metadata) ? entry.metadata : {},
})]
})
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -654,10 +654,6 @@ private static function runtime_package_workflow_for_agents_api( array $input ):

/** @param array<string,mixed> $input Runtime input. @return array<string,mixed> */
private static function runtime_package_required_artifacts_for_agents_api( array $input ): array {
if ( is_array( $input['required_artifacts'] ?? null ) ) {
return $input;
}

$required = array();
foreach ( is_array( $input['artifact_declarations'] ?? null ) ? $input['artifact_declarations'] : array() as $artifact ) {
if ( is_array( $artifact ) && true === ( $artifact['required'] ?? false ) && 'input' !== (string) ( $artifact['direction'] ?? 'output' ) ) {
Expand All @@ -667,9 +663,7 @@ private static function runtime_package_required_artifacts_for_agents_api( array
}
}
}
if ( ! empty( $required ) ) {
$input['required_artifacts'] = array_values( array_unique( $required ) );
}
$input['required_artifacts'] = array_values( array_unique( $required ) );

return $input;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ private function normalize_task_input( array $input ): array|WP_Error {
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 ) );
}

$input['required_artifacts'] = $this->required_artifacts( $input );

return $input;
}

Expand Down Expand Up @@ -59,17 +61,21 @@ private function task_validation_errors( array $task ): array {
}
if ( ! is_array( $task['required_artifacts'] ?? null ) ) {
$errors[] = $this->diagnostic( 'runtime_package_task_invalid_required_artifacts', 'Runtime package task requires required_artifacts array.', 'required_artifacts' );
} else {
$declared_required = $this->required_artifacts( $task );
foreach ( $task['required_artifacts'] as $required ) {
$name = $this->string_value( $required );
if ( '' !== $name && ! in_array( $name, $declared_required, true ) ) {
$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' );
}
}
}

return $errors;
}

/** @param array<string,mixed> $input Runtime package input. @return string[] */
private function required_artifacts( array $input ): array {
if ( is_array( $input['required_artifacts'] ?? null ) ) {
return array_values( array_unique( array_filter( array_map( 'strval', $input['required_artifacts'] ) ) ) );
}

$required = array();
foreach ( is_array( $input['artifact_declarations'] ?? null ) ? $input['artifact_declarations'] : array() as $artifact ) {
if ( is_array( $artifact ) && true === ( $artifact['required'] ?? false ) && 'input' !== (string) ( $artifact['direction'] ?? 'output' ) ) {
Expand Down
10 changes: 10 additions & 0 deletions scripts/php-runtime-package-public-contract-smoke.php
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,16 @@ function wp_get_ability( string $name ): ?WP_Codebox_Runtime_Package_Smoke_Abili
assert( 'contract-runtime' === $result['metadata']['runtime_provider']['id'] );
assert( isset( $result['metadata']['received'] ) );

$optional_artifact_task = $task;
$optional_artifact_task['artifact_declarations'] = array(
array( 'name' => 'optional-report', 'type' => 'markdown', 'required' => false ),
array( 'name' => 'required-report', 'type' => 'markdown', 'required' => true ),
);
$optional_artifact_task['required_artifacts'] = array( 'optional-report' );
$undeclared_required = WP_Codebox_Abilities::run_runtime_package( $optional_artifact_task );
assert( is_wp_error( $undeclared_required ) );
assert( 'wp_codebox_runtime_package_task_invalid' === $undeclared_required->get_error_code() );

$bundle_root = realpath( (string) ( getenv( 'WP_CODEBOX_RUNTIME_PACKAGE_FIXTURE' ) ?: __DIR__ . '/../tests/fixtures/wpsg-runtime-package' ) );
assert( false !== $bundle_root );
$staged_bundle_root = sys_get_temp_dir() . '/wp-codebox-runtime-package-' . bin2hex( random_bytes( 8 ) );
Expand Down
20 changes: 17 additions & 3 deletions tests/runtime-package-execution.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,11 +59,11 @@ assert.deepEqual(normalizeRuntimePackageOutputProjections([{ name: "artifactInde
}])

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

const optionalArtifactTask = normalizeRuntimePackageTask({
package: { slug: "example-agent", source: "/workspace/example-agent" },
input: {},
artifact_declarations: [
{ name: "optional-report", type: "markdown", required: false },
{ name: "required-report", type: "markdown", required: true },
],
required_artifacts: ["optional-report"],
})
assert.deepEqual(optionalArtifactTask.required_artifacts, ["required-report"], "required artifacts derive only from required declarations")
const undeclaredRequired = validateRuntimePackageTask({ ...optionalArtifactTask, required_artifacts: ["optional-report"] })
assert.equal(undeclaredRequired.valid, false)
assert.equal(undeclaredRequired.diagnostics.at(-1)?.code, "undeclared_required_artifact")

const missingPublicFields = validateRuntimePackageTask({ schema: RUNTIME_PACKAGE_TASK_SCHEMA, package: { slug: "example-agent" } })
assert.equal(missingPublicFields.valid, false)
assert.deepEqual(missingPublicFields.diagnostics.map((diagnostic) => diagnostic.code), [
Expand Down
Loading
Loading