Enhance testing harness stability and update repository documentation (#3983)

* Refactor skill turn harness, fix session serialization, and resolve E2E test failures

* Ignore symlinks during workspace copying and enforce sandbox boundaries in playbooks

* Refactor interaction loop to use clean async generator-based Event flow

* Introduce dedicated async generator test and improve autonomous tester instructions

* Enforce strict sandbox awareness and Step 8 policy import gates

* Track and display conversation context size next to turn headers

* Streamline token usage display to only appear in turn step headers

* Refactor token usage tracking to show actual active context size

* Implement progress tracking block and human recovery in test harness

* docs: document and categorize repository skills and tools

* docs: add maintenance instructions for updating FACTORIES.md tables

* docs: add missing data-catalog-policy-tag factory in FACTORIES.md

* docs: add missing networking stage sub-factories in FACTORIES.md

* docs: add systematic commands for discovering module/stage factories in FACTORIES.md

* docs: add missing vpcs factories in 0-org-setup and 2-project-factory stages
This commit is contained in:
Ludovico Magnocavallo
2026-05-24 12:25:50 +02:00
committed by GitHub
parent 81f72e8068
commit c24dae395b
13 changed files with 417 additions and 179 deletions

View File

@@ -17,6 +17,7 @@ The following table provides a granular overview of modules that implement facto
| :--- | :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- | :--- |
| **analytics-hub** | Analytics Hub Exchange | `listings` | Analytics Hub Listings | `project_id`, `region` | | **analytics-hub** | Analytics Hub Exchange | `listings` | Analytics Hub Listings | `project_id`, `region` |
| **billing-account** | Billing Account (Config) | `budgets_data_path` | Billing Budgets | `id` (Billing Account ID) | | **billing-account** | Billing Account (Config) | `budgets_data_path` | Billing Budgets | `id` (Billing Account ID) |
| **data-catalog-policy-tag** | Data Catalog Taxonomy | `taxonomy` | Data Catalog Policy Tags | `project_id`, `location` |
| **data-catalog-tag** | N/A | `tags` | Data Catalog Tags | `tags` (Merged with factory data) | | **data-catalog-tag** | N/A | `tags` | Data Catalog Tags | `tags` (Merged with factory data) |
| **data-catalog-tag-template** | N/A | `tag_templates` | Tag Templates | `project_id`, `region` | | **data-catalog-tag-template** | N/A | `tag_templates` | Tag Templates | `project_id`, `region` |
| **dataplex-aspect-types** | N/A | `aspect_types` | Aspect Types | `project_id`, `location` | | **dataplex-aspect-types** | N/A | `aspect_types` | Aspect Types | `project_id`, `location` |
@@ -75,9 +76,12 @@ The following table details how FAST stages implement factory patterns.
| Stage | Factory (Key/Feature) | Implementation Type | Underlying Module/Resource | | Stage | Factory (Key/Feature) | Implementation Type | Underlying Module/Resource |
| :--- | :--- | :--- | :--- | | :--- | :--- | :--- | :--- |
| **0-org-setup** | `projects`, `folders`, `budgets` | Module-Backed (Factory) | `project-factory` | | **0-org-setup** | `projects`, `folders`, `budgets` | Module-Backed (Factory) | `project-factory` |
| **0-org-setup** | `vpcs` | Module-Backed (Factory) | `net-vpc-factory` |
| **1-vpcsc** | `access_levels`, `egress_policies`, `ingress_policies`, `perimeters` | Module-Backed (Factory) | `vpc-sc` | | **1-vpcsc** | `access_levels`, `egress_policies`, `ingress_policies`, `perimeters` | Module-Backed (Factory) | `vpc-sc` |
| **2-networking** | `vpcs` | Module-Backed (Factory) | `net-vpc-factory` | | **2-networking** | `vpcs` | Module-Backed (Factory) | `net-vpc-factory` |
| **2-networking** | `projects` | Module-Backed (Factory) | `project-factory` | | **2-networking** | `projects` | Module-Backed (Factory) | `project-factory` |
| **2-networking** | `addresses` (VPC IP Addresses) | Stage-Implemented (Module) | `net-address` |
| **2-networking** | `cloud_nats` (VPC Cloud NATs) | Stage-Implemented (Module) | `net-cloudnat` |
| **2-networking** | `dns` (Zones) | Stage-Implemented (Module) | `dns` | | **2-networking** | `dns` (Zones) | Stage-Implemented (Module) | `dns` |
| **2-networking** | `dns_response_policies` | Stage-Implemented (Module) | `dns-response-policy` | | **2-networking** | `dns_response_policies` | Stage-Implemented (Module) | `dns-response-policy` |
| **2-networking** | `firewall_policies` | Stage-Implemented (Module) | `net-firewall-policy` | | **2-networking** | `firewall_policies` | Stage-Implemented (Module) | `net-firewall-policy` |
@@ -85,8 +89,11 @@ The following table details how FAST stages implement factory patterns.
| **2-networking** | `vlan_attachments` | Stage-Implemented (Module) | `net-vlan-attachment` | | **2-networking** | `vlan_attachments` | Stage-Implemented (Module) | `net-vlan-attachment` |
| **2-networking** | `ncc_hubs` | Stage-Implemented (Resource) | `google_network_connectivity_hub` | | **2-networking** | `ncc_hubs` | Stage-Implemented (Resource) | `google_network_connectivity_hub` |
| **2-networking** | `ncc_groups` | Stage-Implemented (Resource) | `google_network_connectivity_group` | | **2-networking** | `ncc_groups` | Stage-Implemented (Resource) | `google_network_connectivity_group` |
| **2-networking** | `peerings` (VPC Network Peerings) | Stage-Implemented (Resource) | `google_compute_network_peering` |
| **2-networking** | `routers` (VPC Routers) | Stage-Implemented (Resource) | `google_compute_router` |
| **2-networking** | `nvas` | Native (Complex) | `compute-vm`, `net-lb-int` | | **2-networking** | `nvas` | Native (Complex) | `compute-vm`, `net-lb-int` |
| **2-project-factory** | `projects`, `folders`, `budgets` | Module-Backed (Factory) | `project-factory` | | **2-project-factory** | `projects`, `folders`, `budgets` | Module-Backed (Factory) | `project-factory` |
| **2-project-factory** | `vpcs` | Module-Backed (Factory) | `net-vpc-factory` |
| **2-security** | `projects` | Module-Backed (Factory) | `project-factory` | | **2-security** | `projects` | Module-Backed (Factory) | `project-factory` |
| **2-security** | `certificate_authorities` | Stage-Implemented (Module) | `certificate-authority-service` | | **2-security** | `certificate_authorities` | Stage-Implemented (Module) | `certificate-authority-service` |
| **2-security** | `keyrings` (KMS) | Stage-Implemented (Module) | `kms` | | **2-security** | `keyrings` (KMS) | Stage-Implemented (Module) | `kms` |
@@ -96,22 +103,25 @@ The following table details how FAST stages implement factory patterns.
This documentation is maintained to track factory patterns across the `modules` and `fast/stages` directories. This documentation is maintained to track factory patterns across the `modules` and `fast/stages` directories.
### To Update ### Discovery & Maintenance Guide
#### 1. Modules Analysis To ensure this document never drifts from the actual codebase and to prevent missing any newly introduced factory patterns, use these systematic search commands to discover and audit all factories in the repository:
1. **Identify Configuration:** Search for `variable "factories_config"` in typically `modules/your-module/variables.tf`. #### 1. Discovering Module Factories
2. **Determine Keys:** Inspect the `factories_config` type (e.g., `object({ ... })`) to identify the keys like `rules`, `vpcs`, `projects`. To locate all modules supporting factory configurations, run:
3. **Find Usage:** Search for `var.factories_config.KEY` in the module's `main.tf` or `factory.tf` to see how the data is used. ```bash
4. **Classify Resources:** Determine whether the factory logic creates module resources (e.g., `google_project`) or iterates a sub-module. grep -rn "variable \"factories_config\"" modules/
5. **List Dependencies:** Note any module-level variables (e.g., `project_id`, `name`) that are injected into the factory-created resources. ```
For each discovered module, verify if its keys (defined in `variables.tf` under the `factories_config` type block) are fully documented in the [Modules](#modules) table.
#### 2. FAST Stages Analysis #### 2. Discovering FAST Stage Factories
To locate all stage-level factory implementations and helper files, run:
```bash
find fast/stages/ -name "factory-*.tf"
```
Each matching `factory-[name].tf` file indicates a distinct factory feature (e.g., `factory-addresses.tf`, `factory-cloudnat.tf`). Match these files against the [FAST Stages](#fast-stages) table to ensure every implemented feature is documented.
1. **Identify Configuration:** Search for `variable "factories_config"` in `fast/stages/your-stage/variables.tf`. #### 3. Updating the Tables
2. **Find Usage:** Search for `var.factories_config.KEY` in the stage's implementation (often in `factory*.tf`). When updating the tables manually:
3. **Classify Implementation**: - **Modules Table:** Insert any new module-backed factory in strict **alphabetical order** by module name. Document the `Primary Module Resource`, the exact `Factory Key`, the `Factory-Managed Resources` created, and any module-level `Dependencies` passed.
* **Module-Backed (Factory)**: The `factories_config` path is passed directly to an underlying module (e.g., `project-factory`). - **FAST Stages Table:** Group stage entries by stage name. List all the stage's factory keys and sub-features, classifying their `Implementation Type` and `Underlying Module/Resource` accurately.
* **Stage-Implemented (Module)**: The stage explicitly loads the YAML/files and iterates over a standard module (e.g., `dns` module).
* **Stage-Implemented (Resource)**: The stage explicitly loads the YAML/files and iterates over raw Terraform resources (e.g., `google_network_connectivity_hub`).
* **Native (Complex)**: The stage implements complex logic combining multiple modules/resources (e.g., combining `compute-vm` and `net-lb-int` for NVAs).

View File

@@ -5,3 +5,5 @@ This directory contains skills for AI agents to leverage Cloud Foundation Fabric
## Available Skills ## Available Skills
- **[fabric-builder](./fabric-builder)**: Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud. - **[fabric-builder](./fabric-builder)**: Generates idiomatic Cloud Foundation Fabric (CFF) Terraform code using CFF modules. Use when users ask to create GCP resources, use Fabric modules, or generate Terraform code for Google Cloud.
- **[fast/prerequisites](./fast/prerequisites)**: Guides the user step-by-step through the prerequisites for the FAST `0-org-setup` stage, supporting both Standard GCP and Google Cloud Dedicated (GCD) environments. Use when a user asks to prepare or run prerequisites for `0-org-setup` or bootstrap the FAST landing zone.
- **[maintenance/release-process](./maintenance/release-process)**: Guide for cutting a new release of the Cloud Foundation Fabric (CFF) repository. Use this skill when asked to create, prepare, or draft a new release.

View File

@@ -7,6 +7,36 @@ description: Guides the user step-by-step through the prerequisites for the FAST
## Core Principles & Execution Rules ## Core Principles & Execution Rules
> [!CRITICAL]
> **MANDATORY PROGRESS BLOCK (HUMAN VISIBILITY):**
> To ensure the human user always knows where they are in the workflow and can immediately spot if you skip steps or make false assumptions, **EVERY SINGLE response you output MUST start with this progress block**. Do not omit it, do not shorten it, and do not skip step sequences.
>
> Format to prepend to EVERY message:
> ```text
> FAST Prerequisites Progress:
> - Phase 1: Environment & Authentication
> (Step 1/2: Target Environment Selection - IN PROGRESS)
> - Phase 2: Admin Principal & Baseline Info
> (Not started)
> - Phase 3: Bootstrap Project & IAM
> (Not started)
> - Phase 4: Configuration & Wrap-up
> (Not started)
> ```
>
> As steps are completed, update the bracketed lines to show completed steps or current active step, e.g.:
> ```text
> FAST Prerequisites Progress:
> - Phase 1: Environment & Authentication
> (2/2 steps completed)
> - Phase 2: Admin Principal & Baseline Info
> (Step 1/2: Admin Principal Definition - IN PROGRESS)
> - Phase 3: Bootstrap Project & IAM
> (Not started)
> - Phase 4: Configuration & Wrap-up
> (Not started)
> ```
> [!CRITICAL] > [!CRITICAL]
> **Understanding Turn Boundaries:** You are running in a turn-based execution environment. > **Understanding Turn Boundaries:** You are running in a turn-based execution environment.
> - You receive one user message, then you can think and run tools. > - You receive one user message, then you can think and run tools.
@@ -16,8 +46,12 @@ description: Guides the user step-by-step through the prerequisites for the FAST
> >
> **Do NOT Skip Steps or Make Assumptions:** You MUST NOT skip any phases or steps, even if you think they are redundant or if you find information on the system (like active credentials) that suggests a step is already complete. You MUST execute every step sequentially, in order, and wait for explicit user input/confirmation at each step boundary. > **Do NOT Skip Steps or Make Assumptions:** You MUST NOT skip any phases or steps, even if you think they are redundant or if you find information on the system (like active credentials) that suggests a step is already complete. You MUST execute every step sequentially, in order, and wait for explicit user input/confirmation at each step boundary.
> >
> **MANDATORY START POINT (TURN 1):** You MUST explicitly ask the user to choose their target environment (Standard GCP or GCD) in your first turn. Do NOT check active credentials or run background commands to bypass this step. The environment selection is a fundamental gateway that dictates all downstream resources, variables, and configurations. Proceeding without explicitly getting this choice in Turn 1 is a critical failure.
>
> **Strictly One Question at a Time:** You MUST NOT bundle multiple questions or steps together in a single response. Ask exactly one question, wait for the user's answer, and only then proceed to the next question or action. > **Strictly One Question at a Time:** You MUST NOT bundle multiple questions or steps together in a single response. Ask exactly one question, wait for the user's answer, and only then proceed to the next question or action.
> >
> **Sandbox Awareness:** You are running inside an isolated, sandboxed temporary workspace (e.g., `/tmp/gemini_harness_*`). Whenever creating local files, configuration directories (like `custom-fast-config` or `fast-config`), or checking defaults, you MUST do so strictly relative to your current workspace directory (CWD). NEVER try to directly read or write to `/home/ludomagno/` or other external folders, as your file tools are sandboxed and will fail with permission errors.
>
> **Step-by-Step Execution:** Never implement a single "magical" flow. Go through each step one at a time, explaining context and asking for confirmation. > **Step-by-Step Execution:** Never implement a single "magical" flow. Go through each step one at a time, explaining context and asking for confirmation.
3. **Execution Choice:** Respect the user's execution preference (automatic via `run_shell_command` vs. manual copy/paste) throughout the entire workflow unless the user explicitly instructs you to change it. This preference will be gathered during Phase 1. 3. **Execution Choice:** Respect the user's execution preference (automatic via `run_shell_command` vs. manual copy/paste) throughout the entire workflow unless the user explicitly instructs you to change it. This preference will be gathered during Phase 1.
4. **File Modifications:** Always use `replace` or `write_file`. **Never** use opaque shell commands (like `sed`, `echo >>`, or `cat <<EOF >>`). Show proposed edits and ask for confirmation before applying them so the user can see what we're doing. 4. **File Modifications:** Always use `replace` or `write_file`. **Never** use opaque shell commands (like `sed`, `echo >>`, or `cat <<EOF >>`). Show proposed edits and ask for confirmation before applying them so the user can see what we're doing.

View File

@@ -2,10 +2,19 @@
### Step 1: Environment Assessment & Initialization ### Step 1: Environment Assessment & Initialization
> [!IMPORTANT] > [!CRITICAL]
> **Do NOT Automate Environment Choice**: You MUST explicitly ask the user to clarify their target environment (Standard GCP or GCD) and wait for their response. Do NOT assume or guess based on local config files or active credentials. > **MANDATORY PROGRESS BLOCK REMINDER:**
> - Ensure your very first Turn 1 response prepends the progress block showing:
> - Phase 1: Environment & Authentication (Step 1/2: Target Environment Selection - IN PROGRESS)
> - All subsequent phases: (Not started)
> >
> **Do NOT Automate Command Execution Preference**: You MUST ask how they prefer to run commands (automatic vs manual) and wait for their response. > **MANDATORY START POINT (TURN 1):**
> - You MUST begin the entire workflow by asking the user to clarify their target environment (**Standard GCP** or **Google Cloud Dedicated (GCD)**).
> - You **MUST NOT** run any `gcloud` commands, check active credentials, or proceed to Step 2 or any other Phase/Step in Turn 1.
> - You **MUST** stop execution immediately after asking this question and wait for the user's response.
> - Do NOT assume, pre-fill, or guess the environment based on local config files, active credentials, or background command success.
>
> **Do NOT Automate Command Execution Preference**: You MUST ask how they prefer to run commands (automatic vs manual) in a subsequent turn and wait for their response.
1. Ask the user to clarify their target environment: **Standard GCP** or **Google Cloud Dedicated (GCD)**. **Wait for their response.** 1. Ask the user to clarify their target environment: **Standard GCP** or **Google Cloud Dedicated (GCD)**. **Wait for their response.**
2. Once the environment is confirmed, ask how they prefer to run commands: Should you (Gemini CLI) run them automatically, or should you output them for manual execution? **Remember this preference for the rest of the workflow. Wait for their response.** 2. Once the environment is confirmed, ask how they prefer to run commands: Should you (Gemini CLI) run them automatically, or should you output them for manual execution? **Remember this preference for the rest of the workflow. Wait for their response.**
@@ -27,9 +36,12 @@
### Step 2: Authentication ### Step 2: Authentication
> [!CRITICAL]
> **DO NOT START STEP 2 PREMATURELY:** You MUST NOT check authentication, run `gcloud config list`, or execute Step 2 until Step 1 (Environment Assessment & Initialization) has been completely finished and confirmed by the user in previous turns.
1. Ask the user if they are already authenticated with Google Cloud using the correct principal. 1. Ask the user if they are already authenticated with Google Cloud using the correct principal.
- *If yes:* Run (or ask the user to run) `gcloud config list account --format="value(core.account)"` to retrieve the current authenticated principal. Show this principal to the user and explicitly ask them to confirm if this is the correct identity they want to use. - *If yes:* Run (or ask the user to run) `gcloud config list account --format="value(core.account)"` to retrieve the current authenticated principal. Show this principal to the user and explicitly ask them to confirm if this is the correct identity they want to use.
- *If they confirm:* Proceed directly to Phase 2 (Step 3). - *If they confirm (and Step 1 is already completed):* Proceed directly to Phase 2 (Step 3).
- *If they do not confirm:* Proceed with the authentication steps below. - *If they do not confirm:* Proceed with the authentication steps below.
- *If no:* Proceed with the authentication steps below. - *If no:* Proceed with the authentication steps below.
2. *Standard GCP:* Provide or execute the command: 2. *Standard GCP:* Provide or execute the command:

View File

@@ -1,5 +1,12 @@
# Phase 2: Admin Principal & Baseline Info # Phase 2: Admin Principal & Baseline Info
> [!CRITICAL]
> **MANDATORY PROGRESS BLOCK REMINDER:**
> - Ensure your responses in Phase 2 prepend the progress block showing:
> - Phase 1: Environment & Authentication (2/2 steps completed)
> - Phase 2: Admin Principal & Baseline Info (Step 1/2: Admin Principal Definition - IN PROGRESS)
> - All subsequent phases: (Not started)
### Step 3: Admin Principal Definition ### Step 3: Admin Principal Definition
1. Explain the concept of the **Admin Principal**. This is the identity (or group of identities) that will be granted the necessary FAST roles to deploy the foundation and manage critical organization-level configurations and policies thereafter. 1. Explain the concept of the **Admin Principal**. This is the identity (or group of identities) that will be granted the necessary FAST roles to deploy the foundation and manage critical organization-level configurations and policies thereafter.

View File

@@ -27,15 +27,17 @@
- Use `write_file` to create `0-org-setup.auto.tfvars` inside the `local_path` (`<LOCAL_PATH>/0-org-setup.auto.tfvars`). - Use `write_file` to create `0-org-setup.auto.tfvars` inside the `local_path` (`<LOCAL_PATH>/0-org-setup.auto.tfvars`).
- In `0-org-setup.auto.tfvars`, set the `factories_config` variable. The `dataset` should point to the original dataset folder (e.g., `"datasets/classic"`), but the `paths.defaults` must point to the absolute path of the copied defaults file. - In `0-org-setup.auto.tfvars`, set the `factories_config` variable. The `dataset` should point to the original dataset folder (e.g., `"datasets/classic"`), but the `paths.defaults` must point to the absolute path of the copied defaults file.
- *If GCD*, also: Create a temporary `0-org-setup-providers.tf` file containing the specific `universe_domain` configuration using `write_file` at `<LOCAL_PATH>/providers/0-org-setup-providers.tf`. - *If GCD*, also: Create a temporary `0-org-setup-providers.tf` file containing the specific `universe_domain` configuration using `write_file` at `<LOCAL_PATH>/providers/0-org-setup-providers.tf`.
11. **Present Configuration and Halt:** Briefly tell the user you have created and validated the baseline configuration files. You MUST stop execution immediately, present the generated files, ask the user if they are ready to proceed with **Step 8 (Organization Policy Import Check)**, and wait for their response. Do not proceed to Step 8 or run more tools in this turn.
### Step 8: Organization Policy Import Check ### Step 8: Organization Policy Import Check
1. Explain that pre-existing organization policies can cause `409 Conflict` errors during the first apply if not imported. 1. Explain that pre-existing organization policies can cause `409 Conflict` errors during the first apply if not imported.
2. Execute (or provide) the command to list current policies. 2. Provide (or execute if automatic) the command to list current policies.
```bash ```bash
gcloud org-policies list --organization="<ORG_ID>" --format="value(constraint)" gcloud org-policies list --organization="<ORG_ID>" --format="value(constraint)"
``` ```
3. **Update `0-org-setup.auto.tfvars`:** If any policies are returned, capture the output, format it as an HCL list in memory, and use the `replace` tool to append the `org_policies_imports` variable to the `0-org-setup.auto.tfvars` file. **ABSOLUTELY NEVER use shell redirection like `echo >>`, `awk >>`, or `cat <<EOF >>` to edit files.** Explain to the user that this tells Terraform to import these existing policies rather than attempting to recreate them. 3. **STOP Execution and Wait:** You MUST stop execution immediately here, ask the user to run the command, and wait for them to paste the output. Do NOT proceed to Step 9 or update any files until the user has explicitly provided the output of this command.
4. **Update `0-org-setup.auto.tfvars`:** If any policies are returned, capture the output, format it as an HCL list in memory, and use the `replace` tool to append the `org_policies_imports` variable to the `0-org-setup.auto.tfvars` file. **ABSOLUTELY NEVER use shell redirection like `echo >>`, `awk >>`, or `cat <<EOF >>` to edit files.** Explain to the user that this tells Terraform to import these existing policies rather than attempting to recreate them.
### Step 9: Wrap-up & Apply ### Step 9: Wrap-up & Apply

44
tools/README.md Normal file
View File

@@ -0,0 +1,44 @@
# Cloud Foundation Fabric (CFF) Tools
This directory contains utility scripts and tools used to automate linting, formatting, validation, documentation generation, and testing across the Cloud Foundation Fabric repository.
## Categorized Tools
### 1. Documentation Generation
Tools for automatically generating documentation tables or formats from source configurations.
- **[tfdoc.py](./tfdoc.py)**: Automatically generates inputs, outputs, and files documentation tables inside module READMEs.
- **[schema_docs.py](./schema_docs.py)**: Recursively parses and compiles JSON schemas into readable, markdown-based documentation tables.
- **[format_tftest.py](./format_tftest.py)**: Formats Terraform code blocks containing `# tftest` directives inside markdown files.
- **[update_schema_links.py](./update_schema_links.py)**: Updates the schema URLs in YAML modelines across a directory to match a specific CDN or versioned source.
- **[pre-commit-tfdoc.sh](./pre-commit-tfdoc.sh)**: Pre-commit hook script to run `tfdoc.py` updates and verify README alignment.
### 2. Testing, Planning & Emulation
Tools to run testing sandboxes, parse plans, and simulate/evaluate agent behaviors.
- **[skill-turn-harness/](./skill-turn-harness)**: A hybrid Python/SDK-based test harness for developing, running, and grading Antigravity skills.
- **[generate_plan_summary.py](./generate_plan_summary.py)**: Generates structured plan summaries of resources and their changes for README examples or `tftest.yaml` tests.
- **[plan_summary.py](./plan_summary.py)**: An internal helper script to parse, format, and filter Terraform plan structures for automated test assertions.
- **[create_e2e_sandbox.sh](./create_e2e_sandbox.sh)**: Bootstraps an isolated end-to-end sandbox directory to safely provision and test CFF examples.
### 3. Repository Maintenance & Automation
Tools for version management, automated reviews, GCP service definitions, and release automation.
- **[changelog.py](./changelog.py)**: Automates the generation and maintenance of the `CHANGELOG.md` based on Git diffs and version changes.
- **[versions.py](./versions.py)**: Synchronizes required engine and provider version constraints across standard provider configuration files in the repository.
- **[build_service_agents.py](./build_service_agents.py)**: Parses the official Google Cloud documentation to build a structured representation of GCP service agents and their properties.
- **[pr_review.py](./pr_review.py)**: Leverages the Gemini API to perform automated, context-aware code reviews on pull requests.
- **[state_iam.py](./state_iam.py)**: Parses and displays authoritative IAM binding configurations directly from a local Terraform state file.
### 4. Linting, Quality & Compliance
Tools for maintaining style guide compliance, licensing, validation, and content/rule verification.
- **[check_boilerplate.py](./check_boilerplate.py)**: Scans files to verify that correct Google license headers and boilerplates are present.
- **[check_documentation.py](./check_documentation.py)**: Recursively verifies that the variables and outputs tables generated inside module `README.md` files are up-to-date.
- **[check_links.py](./check_links.py)**: Recursively parses Markdown files to validate external links, internal links, and anchor destinations.
- **[check_names.py](./check_names.py)**: Evaluates name length constraints and formatting for specified GCP Terraform resources to ensure compatibility.
- **[check_schema_docs.py](./check_schema_docs.py)**: Recursively checks if the Markdown documentation generated from JSON schemas is fully up-to-date.
- **[check_yaml_schema.py](./check_yaml_schema.py)**: Validates YAML configuration and factory data files against their defined JSON schema modelines.
- **[duplicate-diff.py](./duplicate-diff.py)**: Verifies content alignment for files that must remain identical across different stages or modules (e.g. schemas, policies).
- **[lint.sh](./lint.sh)**: A shell script wrapping boilerplate, YAML, and Terraform style and format checks.
- **[tflint-fast.py](./tflint-fast.py)**: Runs the `tflint` linter against FAST stages by setting them up in temporary isolated environments.

View File

@@ -66,6 +66,7 @@ python3 harness.py playbooks/my-playbook.yaml
- `--keep-workspace` (Optional): Preserve the temporary workspace directory (`/tmp/gemini_harness_*`) after execution to inspect files generated by the agent. - `--keep-workspace` (Optional): Preserve the temporary workspace directory (`/tmp/gemini_harness_*`) after execution to inspect files generated by the agent.
- `--agent-model <model>` (Optional): Override the model the agent uses (e.g., `gemini-2.5-pro`). Overrides playbook definition. - `--agent-model <model>` (Optional): Override the model the agent uses (e.g., `gemini-2.5-pro`). Overrides playbook definition.
- `--evaluator-model <model>` (Optional): Override the model the test harness uses to grade and simulate (e.g., `gemini-2.5-flash`). Overrides playbook definition. - `--evaluator-model <model>` (Optional): Override the model the test harness uses to grade and simulate (e.g., `gemini-2.5-flash`). Overrides playbook definition.
- `--max-deviations <number>` (Optional): Set the maximum number of minor deviations/mistakes (such as rule violations or incorrect tool calls) the agent can make during autonomous/hybrid mode before the harness fails the test run. Defaults to `3`.
- `--debug` (Optional): Enable verbose debug logging for the SDK (e.g., WebSocket traffic). - `--debug` (Optional): Enable verbose debug logging for the SDK (e.g., WebSocket traffic).
⚠️ **Security Warning regarding Logs:** ⚠️ **Security Warning regarding Logs:**
@@ -74,18 +75,27 @@ A default `.gitignore` is provided in the `logs/` directory to prevent committin
### Expected Output ### Expected Output
The harness executes the CLI steps, evaluates the responses, and streams the results to the console: The harness executes the playbook, rendering thoughts and tool calls in real-time, and streams the results to the console with active context usage stats:
```text ```text
--- Tuning: FAST Setup PoC | Workspace: /tmp/gemini_harness_abc123 --- --- Tuning: FAST Setup PoC | Workspace: /tmp/gemini_harness_abc123 ---
[Step 1] Input: Hi, please activate the fast-setup-poc skill and let's configure FAST. [Step 1]
[Step 1] Output: Hi, let's configure FAST. Please provide your Google Cloud Project ID. Tester:
Hi, please activate the fast-setup-poc skill and let's configure FAST.
🧠 Thinking:
Let's activate the fast-setup-poc skill and check the requirements.
🛠️ [Tool Call]: list_directory(path=.)
...
✅ [PASS Step 1]: The agent greeted the user ('Hi'), confirmed it was configuring FAST, and asked for the Project ID. All parts of the objective were fulfilled. ✅ [PASS Step 1]: The agent greeted the user ('Hi'), confirmed it was configuring FAST, and asked for the Project ID. All parts of the objective were fulfilled.
[Step 2] [Context: 4,512]
Tester:
my-super-project-123
... ...
✅ [SUCCESS] Playbook 'FAST Setup PoC' completed successfully. ✅ [SUCCESS] Playbook 'FAST Setup PoC' completed successfully.
📄 Session JSON saved to: logs/FAST_Setup_PoC_session.json
📄 Markdown log saved to: logs/FAST_Setup_PoC_log.md 📄 Markdown log saved to: logs/FAST_Setup_PoC_log.md
``` ```
@@ -137,15 +147,12 @@ Playbooks are written in YAML. For autocompletion and validation in VS Code, add
If your playbook requires environment variables (e.g., secrets), declare them in the `env` array. You can then reference them in your `steps` using `${VAR_NAME}`. If a variable is declared but not found in the environment (or passed via `--env-file`), the harness will safely halt before execution. If your playbook requires environment variables (e.g., secrets), declare them in the `env` array. You can then reference them in your `steps` using `${VAR_NAME}`. If a variable is declared but not found in the environment (or passed via `--env-file`), the harness will safely halt before execution.
To run the test in a specific directory (e.g., the repository root), specify `working_dir`. If omitted, a temporary isolated workspace is created.
```yaml ```yaml
# yaml-language-server: $schema=../playbooks/playbook.schema.json # yaml-language-server: $schema=../playbooks/playbook.schema.json
name: "My Test Playbook" name: "My Test Playbook"
timeout: 120 timeout: 120
agent_model: "gemini-2.5-pro" agent_model: "gemini-2.5-pro"
evaluator_model: "gemini-2.5-flash" evaluator_model: "gemini-2.5-flash"
working_dir: "." # Run in the directory where harness is executed
env: env:
- MY_API_KEY - MY_API_KEY
steps: steps:

View File

@@ -45,7 +45,24 @@ import tempfile
from dataclasses import dataclass, asdict from dataclasses import dataclass, asdict
from datetime import datetime from datetime import datetime
from typing import Optional, Dict from typing import Optional, Dict, Union, AsyncIterator
@dataclass
class ThinkingDeltaEvent:
text: str
@dataclass
class ToolCallEvent:
name: str
args: dict
@dataclass
class ErrorEvent:
message: str
# Third-party imports # Third-party imports
import click import click
@@ -189,6 +206,65 @@ class StreamingTrimmer:
self.whitespace_buffer = "" self.whitespace_buffer = ""
class ConsoleRenderer:
"""Handles console formatting and streaming output for interaction turns."""
def __init__(self):
self.trimmer = StreamingTrimmer()
self.need_newline = False
self.at_start_of_line = True
def render_thinking(self, text: str):
to_print = self.trimmer.process_delta(text)
if to_print:
if not self.need_newline:
print(f" {format_color('🧠 Thinking:', C_GRAY)}", flush=True)
self.need_newline = True
self.at_start_of_line = True
parts = to_print.split('\n')
for i, part in enumerate(parts):
if i > 0:
print('\n', end='')
self.at_start_of_line = True
if part:
if self.at_start_of_line:
print(' ', end='')
self.at_start_of_line = False
print(format_color(part, C_GRAY), end='', flush=True)
def render_tool_call(self, name: str, args: dict):
if self.need_newline:
self.trimmer.flush_remaining()
print()
self.need_newline = False
cleaned_args = {
k: v for k, v in args.items() if k not in {
"output",
"results",
"num_results",
"diff_block",
"exit_code",
"combined_output",
"image_name",
}
}
args_str = ", ".join(f"{k}={v}" for k, v in cleaned_args.items())
print(f" 🛠️ {format_color(f'[Tool Call]: {name}({args_str})', C_GRAY)}")
def render_error(self, message: str):
if self.need_newline:
self.trimmer.flush_remaining()
print()
self.need_newline = False
print(f" ❌ [Error]: {message}")
def finalize(self):
if self.need_newline:
self.trimmer.flush_remaining()
print()
def init_markdown_log(md_log_path: str, playbook_name: str): def init_markdown_log(md_log_path: str, playbook_name: str):
'''Initializes the markdown log file with a header. '''Initializes the markdown log file with a header.
@@ -386,12 +462,12 @@ def check_files_contain(files_contain: dict, workspace_dir: str) -> bool:
def check_tool_calls_contain(tool_calls_criteria: dict, def check_tool_calls_contain(tool_calls_criteria: dict,
workspace_dir: str) -> bool: executed_tool_calls: list) -> bool:
'''Checks if the agent's tool calls contain expected literal strings in their arguments. '''Checks if the agent's tool calls contain expected literal strings in their arguments.
Args: Args:
tool_calls_criteria: A dictionary mapping tool names to lists of expected strings. tool_calls_criteria: A dictionary mapping tool names to lists of expected strings.
workspace_dir: The temporary workspace directory path. executed_tool_calls: A list of recorded tool calls, each being a dict with 'name' and 'args'.
Returns: Returns:
True if all tool calls contain their expected strings, False otherwise. True if all tool calls contain their expected strings, False otherwise.
@@ -400,32 +476,14 @@ def check_tool_calls_contain(tool_calls_criteria: dict,
return True return True
passed = True passed = True
workspace_name = os.path.basename(workspace_dir)
slugified_name = re.sub(r'[^a-zA-Z0-9]+', '-',
workspace_name).strip('-').lower()
session_files = glob.glob(
os.path.expanduser(
f'~/.gemini/tmp/{slugified_name}/chats/session-*.json'))
if not session_files:
print(
"❌ [CHECK FAILED]: Expected session JSON file not found in workspace for tool validation."
)
return False
session_files.sort(key=os.path.getmtime, reverse=True)
try: try:
with open(session_files[0], 'r') as f:
session_data = json.load(f)
extracted_calls: Dict[str, str] = {} extracted_calls: Dict[str, str] = {}
for m in session_data.get('messages', []): for tc in executed_tool_calls:
for tc in m.get('toolCalls', []): name = tc['name']
name = tc.get('name') args_str = json.dumps(tc['args'])
args_str = json.dumps(tc.get('args', {})) if name not in extracted_calls:
if name not in extracted_calls: extracted_calls[name] = ""
extracted_calls[name] = "" extracted_calls[name] += args_str + "\n"
extracted_calls[name] += args_str + "\n"
for tool_name, expected_strings in tool_calls_criteria.items(): for tool_name, expected_strings in tool_calls_criteria.items():
if tool_name not in extracted_calls: if tool_name not in extracted_calls:
@@ -443,19 +501,21 @@ def check_tool_calls_contain(tool_calls_criteria: dict,
passed = False passed = False
except Exception as e: except Exception as e:
print(f"❌ [CHECK FAILED]: Failed to parse session JSON: {e}") print(f"❌ [CHECK FAILED]: Failed to process tool calls: {e}")
passed = False passed = False
return passed return passed
def perform_deterministic_checks(success_criteria: dict, workspace_dir: str, def perform_deterministic_checks(success_criteria: dict, workspace_dir: str,
executed_tool_calls: list,
full_stdout: str) -> bool: full_stdout: str) -> bool:
'''Evaluates the deterministic checks defined in the persona success_criteria. '''Evaluates the deterministic checks defined in the persona success_criteria.
Args: Args:
success_criteria: The success_criteria dictionary from the playbook. success_criteria: The success_criteria dictionary from the playbook.
workspace_dir: The temporary workspace directory path. workspace_dir: The temporary workspace directory path.
executed_tool_calls: A list of recorded tool calls.
full_stdout: The combined stdout of all CLI invocations. full_stdout: The combined stdout of all CLI invocations.
Returns: Returns:
@@ -468,7 +528,7 @@ def perform_deterministic_checks(success_criteria: dict, workspace_dir: str,
passed = False passed = False
if not check_tool_calls_contain( if not check_tool_calls_contain(
success_criteria.get('tool_calls_contain', {}), workspace_dir): success_criteria.get('tool_calls_contain', {}), executed_tool_calls):
passed = False passed = False
if not check_files_exist(success_criteria.get('files_exist', []), if not check_files_exist(success_criteria.get('files_exist', []),
@@ -490,74 +550,43 @@ def _view_file_directory_check(args: dict) -> bool:
return False return False
async def run_turn(agent: Agent, user_input: str) -> None: async def run_turn(
"""Sends user input and streams steps in real-time, logging tool calls and errors.""" agent: Agent, user_input: str
) -> AsyncIterator[Union[ThinkingDeltaEvent, ToolCallEvent, ErrorEvent]]:
"""Sends user input and yields interaction events in real-time."""
await agent.conversation.send(user_input) await agent.conversation.send(user_input)
printed_calls = set() printed_calls = set()
need_newline = False
at_start_of_line = True
trimmer = StreamingTrimmer()
async for step_obj in agent.conversation.receive_steps(): async for step_obj in agent.conversation.receive_steps():
if step_obj.thinking_delta: if step_obj.thinking_delta:
to_print = trimmer.process_delta(step_obj.thinking_delta) yield ThinkingDeltaEvent(text=step_obj.thinking_delta)
if to_print:
if not need_newline:
print(f" {format_color('🧠 Thinking:', C_GRAY)}", flush=True)
need_newline = True
at_start_of_line = True
parts = to_print.split('\n')
for i, part in enumerate(parts):
if i > 0:
print('\n', end='')
at_start_of_line = True
if part:
if at_start_of_line:
print(' ', end='')
at_start_of_line = False
print(format_color(part, C_GRAY), end='', flush=True)
if step_obj.type == agy_types.StepType.TOOL_CALL: if step_obj.type == agy_types.StepType.TOOL_CALL:
for tc in step_obj.tool_calls: for tc in step_obj.tool_calls:
if tc.id not in printed_calls: if tc.id not in printed_calls:
printed_calls.add(tc.id) printed_calls.add(tc.id)
if need_newline: yield ToolCallEvent(name=tc.name, args=dict(tc.args))
trimmer.flush_remaining()
print()
need_newline = False
cleaned_args = {
k: v for k, v in tc.args.items() if k not in {
"output",
"results",
"num_results",
"diff_block",
"exit_code",
"combined_output",
"image_name",
}
}
args_str = ", ".join(f"{k}={v}" for k, v in cleaned_args.items())
print(
f" 🛠️ {format_color(f'[Tool Call]: {tc.name}({args_str})', C_GRAY)}"
)
if step_obj.status == agy_types.StepStatus.ERROR:
if need_newline:
trimmer.flush_remaining()
print()
need_newline = False
error_msg = step_obj.error or "Unknown step error"
print(f" ❌ [Error]: {error_msg}")
if need_newline: if step_obj.status == agy_types.StepStatus.ERROR:
trimmer.flush_remaining() yield ErrorEvent(message=step_obj.error or "Unknown step error")
print()
def _get_usage_str(agent: Agent) -> str:
"""Safely retrieves the active context size from the agent's conversation."""
try:
usage = agent.conversation.last_turn_usage
if usage and usage.prompt_token_count is not None:
return f" [Context: {usage.prompt_token_count:,}]"
except Exception:
pass
return ""
async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str, async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
skill_src: str = None, skill_src: str = None,
keep_workspace: bool = False, keep_workspace: bool = False,
cli_agent_model: str = None, cli_agent_model: str = None,
cli_evaluator_model: str = None): cli_evaluator_model: str = None,
max_deviations: int = 3):
'''Executes the test playbook and evaluates the agent's responses. '''Executes the test playbook and evaluates the agent's responses.
Args: Args:
@@ -571,6 +600,15 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
Returns: Returns:
True if the playbook passes completely, False if any step fails. True if the playbook passes completely, False if any step fails.
''' '''
# Initialize all finally-block dependencies at the very top to avoid NameErrors on early failure
log_prefix = "unknown_playbook"
conversation_history = []
executed_tool_calls = []
interaction_log = []
is_tmpdir = False
workspace_dir = os.getcwd()
original_cwd = os.getcwd()
evaluator_client = genai.Client() evaluator_client = genai.Client()
log_dir = os.path.abspath(log_dir) log_dir = os.path.abspath(log_dir)
os.makedirs(log_dir, exist_ok=True) os.makedirs(log_dir, exist_ok=True)
@@ -594,25 +632,33 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
tmpdir_config = playbook.get('tmpdir') tmpdir_config = playbook.get('tmpdir')
is_tmpdir = tmpdir_config is not None is_tmpdir = tmpdir_config is not None
original_cwd = os.getcwd()
if is_tmpdir: if is_tmpdir:
workspace_dir = tempfile.mkdtemp(prefix='gemini_harness_') workspace_dir = tempfile.mkdtemp(prefix='gemini_harness_')
open(os.path.join(workspace_dir, '.project_root'), 'w').close() open(os.path.join(workspace_dir, '.project_root'), 'w').close()
def _ignore_symlinks_and_patterns(directory, names):
ignore_func = shutil.ignore_patterns('.terraform', '.git', '.venv',
'venv', '__pycache__',
'.pytest_cache',
'skill-turn-harness')
ignored = set(ignore_func(directory, names))
for name in names:
if os.path.islink(os.path.join(directory, name)):
ignored.add(name)
return list(ignored)
link_paths = tmpdir_config.get('link_paths', []) link_paths = tmpdir_config.get('link_paths', [])
for path in link_paths: for path in link_paths:
src_abs = os.path.abspath(os.path.join(original_cwd, path)) src_abs = os.path.abspath(os.path.join(original_cwd, path))
dst_abs = os.path.join(workspace_dir, path) dst_abs = os.path.join(workspace_dir, path)
os.makedirs(os.path.dirname(dst_abs), exist_ok=True) os.makedirs(os.path.dirname(dst_abs), exist_ok=True)
try: try:
if os.path.isdir(src_abs): if os.path.islink(src_abs):
shutil.copytree( print(f'🔗 Skipped symlink: {path}')
src_abs, dst_abs, elif os.path.isdir(src_abs):
ignore=shutil.ignore_patterns('.terraform', '.git', '.venv', shutil.copytree(src_abs, dst_abs,
'venv', '__pycache__', ignore=_ignore_symlinks_and_patterns)
'.pytest_cache',
'skill-turn-harness'))
print(f'📁 Copied directory: {path} -> {dst_abs}') print(f'📁 Copied directory: {path} -> {dst_abs}')
else: else:
shutil.copy2(src_abs, dst_abs) shutil.copy2(src_abs, dst_abs)
@@ -633,13 +679,19 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
full_stdout = "" full_stdout = ""
conversation_history = [] conversation_history = []
executed_tool_calls = []
step_index = 0 step_index = 0
fallback_to_persona = False fallback_to_persona = False
# Configure SDK Agent # Configure SDK Agent
skills_paths = [] skills_paths = []
if skill_src: if skill_src:
skills_paths.append(os.path.abspath(skill_src)) if is_tmpdir:
# If sandboxed in tmpdir, point to the copied skill path inside the sandbox
skills_paths.append(
os.path.abspath(os.path.join(workspace_dir, skill_src)))
else:
skills_paths.append(os.path.abspath(skill_src))
# Allow all tools to emulate CLI -y/--dangerously-skip-permissions # Allow all tools to emulate CLI -y/--dangerously-skip-permissions
policies = [ policies = [
@@ -654,7 +706,12 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
standard_instructions = ( standard_instructions = (
"GUIDELINES:\n" "GUIDELINES:\n"
"- Always check if a path is a directory before trying to view it. " "- Always check if a path is a directory before trying to view it. "
"Use list_directory to inspect directories, never view_file.") "Use list_directory to inspect directories, never view_file.\n"
"- You are running inside an isolated, sandboxed temporary workspace (e.g., /tmp/gemini_harness_*). "
"Whenever creating local files, configuration directories (like custom-fast-config or fast-config), "
"or checking defaults, you MUST do so strictly relative to your current workspace directory (CWD). "
"NEVER try to directly read or write to /home/ludomagno/ or other external folders, as your file tools "
"are sandboxed and will fail with permission/step errors.")
config = LocalAgentConfig( config = LocalAgentConfig(
model=agent_model, model=agent_model,
@@ -668,6 +725,19 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
try: try:
async with Agent(config) as agent: async with Agent(config) as agent:
async def _execute_turn(user_input_str: str):
renderer = ConsoleRenderer()
async for event in run_turn(agent, user_input_str):
if isinstance(event, ThinkingDeltaEvent):
renderer.render_thinking(event.text)
elif isinstance(event, ToolCallEvent):
executed_tool_calls.append({'name': event.name, 'args': event.args})
renderer.render_tool_call(event.name, event.args)
elif isinstance(event, ErrorEvent):
renderer.render_error(event.message)
renderer.finalize()
# --- PHASE 1: SCRIPTED STEPS --- # --- PHASE 1: SCRIPTED STEPS ---
for step_dict in playbook_steps: for step_dict in playbook_steps:
raw_user_input = step_dict['user_input'] raw_user_input = step_dict['user_input']
@@ -681,13 +751,15 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
step = StepData(step_index=step_index, user_input=subbed_user_input, step = StepData(step_index=step_index, user_input=subbed_user_input,
expected_outcome=subbed_expected_outcome) expected_outcome=subbed_expected_outcome)
turn_str = format_color(f'[Step {step.step_index + 1}]', C_BOLD_WHITE) usage_str_start = _get_usage_str(agent) if step_index > 0 else ""
turn_str = format_color(
f'[Step {step.step_index + 1}]{usage_str_start}', C_BOLD_WHITE)
print( print(
f"\n{turn_str}\n{format_color('Tester:', C_BLUE)}\n{step.user_input.rstrip()}" f"\n{turn_str}\n{format_color('Tester:', C_BLUE)}\n{step.user_input.rstrip()}"
) )
try: try:
await asyncio.wait_for(run_turn(agent, step.user_input), await asyncio.wait_for(_execute_turn(step.user_input),
timeout=playbook_timeout) timeout=playbook_timeout)
step.skill_response = agent.conversation.last_response step.skill_response = agent.conversation.last_response
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -817,15 +889,19 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
if next_input: if next_input:
print(f"{format_color('Tester:', C_BLUE)}\n{next_input.rstrip()}") print(f"{format_color('Tester:', C_BLUE)}\n{next_input.rstrip()}")
deviation_count = 0
for turn in range(max_turns): for turn in range(max_turns):
if next_input: if next_input:
turn_display = len(conversation_history) + 1 turn_display = len(conversation_history) + 1
turn_str = format_color(f'[Autonomous Turn {turn_display}]', usage_str_start = _get_usage_str(agent)
C_BOLD_WHITE) turn_str = format_color(
f'[Autonomous Turn {turn_display}]{usage_str_start}',
C_BOLD_WHITE)
print(f"\n{turn_str}") print(f"\n{turn_str}")
try: try:
await asyncio.wait_for(run_turn(agent, next_input), await asyncio.wait_for(_execute_turn(next_input),
timeout=playbook_timeout) timeout=playbook_timeout)
agent_response = agent.conversation.last_response agent_response = agent.conversation.last_response
except asyncio.TimeoutError: except asyncio.TimeoutError:
@@ -899,20 +975,31 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
parsed_eval = json.loads(eval_response.text) parsed_eval = json.loads(eval_response.text)
if not parsed_eval['agent_followed_skill_rules']: if not parsed_eval['agent_followed_skill_rules']:
label = format_color('[AUTONOMOUS FAIL]', C_GRAY) deviation_count += 1
msg = format_color(parsed_eval['reasoning'], C_RED) label = format_color('[AGENT DEVIATION]', C_YELLOW)
print(f"{label}: {msg}") msg = format_color(
dump_failed_log(log_dir, log_prefix, interaction_log) f"{parsed_eval['reasoning']} (Deviation {deviation_count}/{max_deviations})",
return False C_YELLOW)
print(f"⚠️ {label}: {msg}")
if deviation_count > max_deviations:
label_fail = format_color('[AUTONOMOUS FAIL]', C_GRAY)
msg_fail = format_color(
f"Exceeded max allowed deviations ({max_deviations}). Failing test.",
C_RED)
print(f"{label_fail}: {msg_fail}")
dump_failed_log(log_dir, log_prefix, interaction_log)
return False
fallback_to_persona = True # Flag as passed with warning since we recovered from a deviation
if parsed_eval['test_completed_successfully']: elif parsed_eval['test_completed_successfully']:
label = format_color('[AUTONOMOUS SEMANTIC SUCCESS]', C_GRAY) label = format_color('[AUTONOMOUS SEMANTIC SUCCESS]', C_GRAY)
msg = format_color(parsed_eval['reasoning'], C_GREEN) msg = format_color(parsed_eval['reasoning'], C_GREEN)
print(f"{label}: {msg}") print(f"{label}: {msg}")
print("🔍 Performing deterministic checks...") print("🔍 Performing deterministic checks...")
if perform_deterministic_checks(interpolated_success_criteria, if perform_deterministic_checks(interpolated_success_criteria,
workspace_dir, full_stdout): workspace_dir, executed_tool_calls,
full_stdout):
if fallback_to_persona: if fallback_to_persona:
label = format_color('[PASS WITH WARNINGS]', C_GRAY) label = format_color('[PASS WITH WARNINGS]', C_GRAY)
msg = format_color( msg = format_color(
@@ -946,19 +1033,30 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
dump_failed_log(log_dir, log_prefix, interaction_log) dump_failed_log(log_dir, log_prefix, interaction_log)
return False return False
except Exception as e:
print(format_color(f'\n💥 [CRASH] Unexpected error: {e}', C_RED),
file=sys.stderr)
import traceback
traceback.print_exc()
dump_failed_log(log_dir, log_prefix, interaction_log)
return False
except KeyboardInterrupt: except KeyboardInterrupt:
print('\n🛑 [INTERRUPTED] Shutting down cleanly...') print('\n🛑 [INTERRUPTED] Shutting down cleanly...')
dump_failed_log(log_dir, log_prefix, interaction_log) dump_failed_log(log_dir, log_prefix, interaction_log)
return False return False
finally: finally:
# Locate and copy the session json to the logs directory # Save the session trace json to the logs directory
# The SDK saves it in save_dir/chats/session-*.json session_log_path = os.path.join(log_dir, f'{log_prefix}_session.json')
session_files = glob.glob(os.path.join(log_dir, 'chats', 'session-*.json')) session_data = {
if session_files: "messages": conversation_history,
session_files.sort(key=os.path.getmtime, reverse=True) "toolCalls": executed_tool_calls
session_log_path = os.path.join(log_dir, f'{log_prefix}_session.json') }
shutil.copy2(session_files[0], session_log_path) try:
with open(session_log_path, 'w') as f:
json.dump(session_data, f, indent=2)
print(f'📄 Session JSON saved to: {session_log_path}') print(f'📄 Session JSON saved to: {session_log_path}')
except Exception as e:
print(f'⚠️ [WARNING] Failed to write session JSON: {e}', file=sys.stderr)
if is_tmpdir: if is_tmpdir:
os.chdir(original_cwd) os.chdir(original_cwd)
@@ -1006,13 +1104,20 @@ async def run_hybrid_tuning_loop(playbook_path: str, log_dir: str,
help= help=
'Override the model the test harness uses to grade (e.g., gemini-2.5-flash).', 'Override the model the test harness uses to grade (e.g., gemini-2.5-flash).',
) )
@click.option(
'--max-deviations',
type=int,
default=3,
help=
'Number of deviations/mistakes the agent can make before failing (allows human recovery).',
)
@click.option( @click.option(
'--debug', '--debug',
is_flag=True, is_flag=True,
help='Enable debug logging for the SDK.', help='Enable debug logging for the SDK.',
) )
def main(playbook, log_dir, skill_src, env_file, keep_workspace, agent_model, def main(playbook, log_dir, skill_src, env_file, keep_workspace, agent_model,
evaluator_model, debug): evaluator_model, max_deviations, debug):
'''Hybrid Python SDK Test Harness. '''Hybrid Python SDK Test Harness.
Executes a YAML playbook using the Antigravity SDK and evaluates the Executes a YAML playbook using the Antigravity SDK and evaluates the
@@ -1037,7 +1142,7 @@ def main(playbook, log_dir, skill_src, env_file, keep_workspace, agent_model,
asyncio.run( asyncio.run(
run_hybrid_tuning_loop(playbook, log_dir, skill_src, keep_workspace, run_hybrid_tuning_loop(playbook, log_dir, skill_src, keep_workspace,
agent_model, evaluator_model)) agent_model, evaluator_model, max_deviations))
if __name__ == '__main__': if __name__ == '__main__':

View File

@@ -70,7 +70,7 @@ persona:
- "domain: custom-apis.domain" - "domain: custom-apis.domain"
- "prefix: cust" - "prefix: cust"
- "primary: u-custom-region1" - "primary: u-custom-region1"
- "gcp-organization-admins: principal://iam.googleapis.com/locations/global/workforcePools/my-pool/subject/my-user@custom.cloud.domain" - "principal://iam.googleapis.com/locations/global/workforcePools/my-pool/subject/my-user@custom.cloud.domain"
"custom-fast-config/providers/0-org-setup-providers.tf": "custom-fast-config/providers/0-org-setup-providers.tf":
- "universe_domain" - "universe_domain"
- "custom-apis.domain" - "custom-apis.domain"

View File

@@ -4,7 +4,7 @@ description: 'A simple skill to test if the agent correctly executes tools.'
--- ---
# Instructions # Instructions
You are a simple file-creating agent. When the user asks you to create a file, you MUST use the `write_file` tool to create a file named `output.txt` in the current directory. You are a simple file-creating agent. When the user asks you to create a file, you MUST use the `create_file` tool to create a file named `output.txt` in the current directory.
The content of the file must be exactly: `Hello World` The content of the file must be exactly: `Hello World`
Once you have successfully executed the tool, tell the user that the file has been created. Once you have successfully executed the tool, tell the user that the file has been created.

View File

@@ -14,19 +14,25 @@
# yaml-language-server: $schema=../../playbooks/playbook.schema.json # yaml-language-server: $schema=../../playbooks/playbook.schema.json
name: "Tool Call Test Autonomous" name: "Tool Call Test Autonomous"
timeout: 30 timeout: 120
tmpdir:
link_paths:
- tools/skill-turn-harness/test/fixtures/mock-tool-use-skill
persona: persona:
initial_user_input: "Hi, please activate the tool-test-skill and create the file as instructed." initial_user_input: "Hi, please activate the tool-test-skill and create the file as instructed."
context: | context: |
You are a user asking the agent to create a file. You are a user asking the agent to create a file.
Wait for the agent to confirm the file has been created. Wait for the agent to confirm the file has been created.
max_turns: 3 If the agent asks any questions or says there is an error, repeat your request to create the file.
max_turns: 5
success_criteria: success_criteria:
llm_checks: llm_checks:
- "The agent confirmed the file was created." - "The agent confirmed the file was created."
tool_calls_contain: tool_calls_contain:
write_file: edit_file:
- "output.txt" - "output.txt"
- "Hello World"
files_exist: files_exist:
- "output.txt" - "output.txt"
files_contain:
output.txt:
- "Hello World"

View File

@@ -196,6 +196,49 @@ steps:
assert 'SYSTEM_ERROR: Timeout' in content assert 'SYSTEM_ERROR: Timeout' in content
@pytest.mark.asyncio
@patch('harness.Agent')
async def test_run_turn_generator(mock_agent_class):
# Mock steps returned by the SDK
async def mock_receive_steps():
yield harness.agy_types.Step(type=harness.agy_types.StepType.UNKNOWN,
status=harness.agy_types.StepStatus.DONE,
thinking_delta="Thinking about it")
yield harness.agy_types.Step(
type=harness.agy_types.StepType.TOOL_CALL,
status=harness.agy_types.StepStatus.DONE, tool_calls=[
harness.agy_types.ToolCall(id="tc-1", name="list_directory",
args={"path": "/tmp"})
])
yield harness.agy_types.Step(type=harness.agy_types.StepType.TEXT_RESPONSE,
status=harness.agy_types.StepStatus.ERROR,
error="Something went wrong")
mock_conversation = MagicMock()
mock_conversation.send = AsyncMock()
mock_conversation.receive_steps.return_value = mock_receive_steps()
mock_agent = MagicMock()
mock_agent.conversation = mock_conversation
# Consume our new run_turn async generator
events = []
async for event in harness.run_turn(mock_agent, "Hi"):
events.append(event)
# Verify correct types and data are yielded
assert len(events) == 3
assert isinstance(events[0], harness.ThinkingDeltaEvent)
assert events[0].text == "Thinking about it"
assert isinstance(events[1], harness.ToolCallEvent)
assert events[1].name == "list_directory"
assert events[1].args == {"path": "/tmp"}
assert isinstance(events[2], harness.ErrorEvent)
assert events[2].message == "Something went wrong"
# --- Phase C: E2E Test --- # --- Phase C: E2E Test ---
@@ -275,37 +318,3 @@ def test_e2e_tool_calls_contain(tmp_path):
session_files = list(tmp_path.glob('*_session.json')) session_files = list(tmp_path.glob('*_session.json'))
assert len(session_files) == 1 assert len(session_files) == 1
assert session_files[0].exists() assert session_files[0].exists()
@pytest.mark.e2e
def test_e2e_working_dir(tmp_path):
'''
Runs an evaluation loop to verify working_dir functionality.
'''
fixtures_dir = os.path.join(os.path.dirname(__file__), 'fixtures')
skill_dir = os.path.join(fixtures_dir, 'mock-tool-use-skill')
# Create a specific subdirectory in tmp_path
workdir_target = tmp_path / "workdir_target"
workdir_target.mkdir()
# Dynamically create a playbook YAML file
playbook_content = f"""# yaml-language-server: $schema=../../playbooks/playbook.schema.json
name: "Tool Test with Workdir"
working_dir: "{workdir_target.resolve()}"
steps:
- user_input: "Hi, please activate tool-test-skill and create the file output.txt."
expected_outcome: "The agent confirms it has created the file."
"""
playbook_path = tmp_path / "playbook_workdir.yaml"
playbook_path.write_text(playbook_content)
result = asyncio.run(
harness.run_hybrid_tuning_loop(str(playbook_path), log_dir=str(tmp_path),
skill_src=skill_dir))
assert result is True
# Verify that output.txt was created INSIDE workdir_target
output_file = workdir_target / "output.txt"
assert output_file.exists()
assert output_file.read_text().strip() == "Hello World"