diff --git a/.dockerignore b/.dockerignore
new file mode 100644
index 00000000..14a14253
--- /dev/null
+++ b/.dockerignore
@@ -0,0 +1,8 @@
+.git
+.idea
+.vscode
+*.log
+eventlog/
+tmp/
+node_modules/
+bin/
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
new file mode 100644
index 00000000..3fd354fc
--- /dev/null
+++ b/.github/workflows/go.yml
@@ -0,0 +1,73 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# This workflow will build a golang project
+# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go
+
+name: Go
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Cache addlicense
+ id: cache-addlicense
+ uses: actions/cache@v4
+ with:
+ path: ~/go/bin/addlicense
+ key: ${{ runner.os }}-addlicense-v1.2.0
+
+ - name: Install addlicense
+ if: steps.cache-addlicense.outputs.cache-hit != 'true'
+ run: go install github.com/google/addlicense@v1.2.0
+
+ - name: Check license headers
+ run: |
+ $(go env GOPATH)/bin/addlicense -check .
+
+ - name: Set up Go
+ uses: actions/setup-go@v4
+ with:
+ go-version-file: go.mod
+
+ - name: Verify go mod tidy
+ run: |
+ go mod tidy
+ git diff --exit-code go.mod go.sum
+
+ - name: Build
+ run: go build -v ./...
+
+ - name: Test
+ run: go test -v ./...
+
+ - name: Check for binary files in PR
+ run: |
+ # Get the list of files where Git shows no line count (binary)
+ BINARIES=$(git diff --numstat origin/${{ github.base_ref }}...HEAD | grep "^-" | cut -f3)
+
+ if [ -n "$BINARIES" ]; then
+ echo "Binary files detected in diff:"
+ echo "$BINARIES"
+ exit 1 # Fail the check if binaries are found
+ fi
diff --git a/.github/workflows/python.yml b/.github/workflows/python.yml
new file mode 100644
index 00000000..135f8911
--- /dev/null
+++ b/.github/workflows/python.yml
@@ -0,0 +1,51 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Runs pytest against the antigravity harness sidecar
+# (python/antigravity/harness_server_test.py). Split from go.yml so the
+# Go workflow stays Go-only and this can evolve independently as more
+# Python surface area lands.
+
+name: Python
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main" ]
+
+jobs:
+
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Set up Python
+ uses: actions/setup-python@v5
+ with:
+ python-version: '3.11'
+
+ - name: Install Python deps
+ # Runtime deps from python/antigravity/requirements.txt plus test-only
+ # tooling. Version floors on pytest match the upstream google-antigravity
+ # SDK's own dev extras (see google-antigravity/antigravity-sdk-python
+ # pyproject.toml) so this doesn't drift. pytest-timeout is added here
+ # only -- our tests spin up real gRPC servers on random ports and can hang.
+ run: pip install -r python/antigravity/requirements.txt 'pytest>=7.0' 'pytest-timeout>=2.0'
+
+ - name: Run Python tests
+ # --timeout guards against hung gRPC servers so a stuck test can't
+ # eat the whole workflow budget.
+ run: python -m pytest python/antigravity/ --timeout=30 --timeout-method=thread -v
diff --git a/.gitignore b/.gitignore
index 7fd6df11..727d1a2e 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,2 +1,8 @@
bin/*
-eventlog/*
\ No newline at end of file
+eventlog/*
+/ax
+*.log
+ax-bin
+python/__pycache__
+python/proto/__pycache__
+__pycache__/
diff --git a/.ko.yaml b/.ko.yaml
new file mode 100644
index 00000000..0041fed6
--- /dev/null
+++ b/.ko.yaml
@@ -0,0 +1,15 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+defaultBaseImage: gcr.io/distroless/static
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index bb80dabe..6b31efbf 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -1,6 +1,17 @@
# How to contribute
-We'd love to accept your patches and contributions to this project.
+We'd love to accept your patches and contributions to this project. However, please note our current temporary policy below.
+
+> [!IMPORTANT]
+> **Temporary Pause on External Contributions**
+>
+> AX is currently undergoing a significant architectural redesign, and the codebase is highly unstable. To ensure we can focus on stabilizing the core framework, we are temporarily **pausing the acceptance of external Pull Requests**.
+>
+> Please do not open Pull Requests at this time, as they are likely to conflict with ongoing changes and may be closed without review. Instead, we encourage you to:
+> - Open an **Issue** to propose feature requests or report bugs.
+> - Join discussions on existing issues to share your feedback.
+>
+> We will update this guide once the core architecture stabilizes and we are ready to welcome contributions again. Thank you for your understanding and patience!
## Before you begin
@@ -49,8 +60,81 @@ make proto
make test
```
-### Run local agent
+### Install the ax CLI
```bash
-make run-local
+make install
+```
+
+Ensure that $GOPATH/bin is in your $PATH.
+
+```bash
+export PATH="$PATH:$(go env GOPATH)/bin"
+```
+
+If you have the `GOBIN` environment variable set, it will be installed there instead.
+Make sure the installation directory is in your `$PATH`.
+
+To add the default location to your `PATH` for the current session, run:
+
+```bash
+export PATH="$PATH:$(go env GOPATH)/bin"
+```
+
+### Creating a pull request
+
+First, clone the repo:
+
+```
+git clone git@github.com:google/ax.git
+```
+
+If you already have cloned the repo locally, make sure that
+your main branch is up to date:
+
+```
+git checkout main
+git pull -q -r origin main
+```
+
+Check a new feature branch:
+
+```
+git checkout -b my-feature
+```
+
+Make edits to files, and test them locally. Add the changes (e.g. git add .) to stage.
+Commit the changes once you staged the changes:
+
+```
+git commit -m "Describe he changes made"
+```
+
+Push the branch to the origin and open a pull request:
+
+```
+git push origin my-feature
+```
+
+Visit https://github.com/google/ax to open a pull request.
+
+
+## Troubleshooting
+
+### Outdated table schema
+
+AX is still under heavy development and the database schema is not yet stable. If you encounter errors related to outdated table schemas, you can reset the database by deleting the `eventlog` directory.
+
+An example:
+
+```bash
+ax exec --input "hello"
+
+Error: error creating controller: failed to create event log: sqlite_eventlog: create index exec_checkpoint_id: SQL logic error: no such column: checkpoint_id (1)
+```
+
+Delete the `eventlog` directory and try again.
+
+```bash
+rm -rf ./eventlog
```
diff --git a/Makefile b/Makefile
index 45d1e015..ce04e638 100644
--- a/Makefile
+++ b/Makefile
@@ -1,33 +1,40 @@
-.PHONY: all build proto test clean install
+.PHONY: all build proto test test-python clean install deps clean-logs
# Build all binaries
all: proto build
# Build binaries
build:
- @echo "Building gar..."
+ @echo "Building ax..."
@mkdir -p bin
- @go build -o bin/gar ./cmd/gar
- @echo "Building local agent example..."
- @go build -o bin/local_agent ./examples/local_agent
- @echo "Building remote agent example..."
- @go build -o bin/remote_agent ./examples/remote_agent
+ @go build -o bin/ax ./cmd/ax
@echo "Build complete!"
+
# Generate protobuf code
proto:
@echo "Generating protobuf code..."
@export PATH=$$PATH:$$(go env GOPATH)/bin && \
protoc --go_out=. --go_opt=paths=source_relative \
--go-grpc_out=. --go-grpc_opt=paths=source_relative \
- proto/gar.proto
+ proto/ax.proto proto/content.proto
+ @python3 -m grpc_tools.protoc -I. --python_out=python --grpc_python_out=python proto/ax.proto proto/content.proto
@echo "Protobuf generation complete!"
-# Run tests
+# Run Go tests
test:
- @echo "Running tests..."
+ @echo "Running Go tests..."
@go test -v ./...
+# Run Python tests for the antigravity harness sidecar.
+# Assumes deps are installed for the same interpreter as `python3`, e.g.:
+# python3 -m pip install -r python/antigravity/requirements.txt \
+# 'pytest>=7.0' 'pytest-timeout>=2.0'
+# --timeout guards against hung gRPC servers.
+test-python:
+ @echo "Running Python tests..."
+ @python3 -m pytest python/antigravity/ --timeout=30 --timeout-method=thread
+
# Clean build artifacts
clean:
@echo "Cleaning..."
@@ -35,20 +42,12 @@ clean:
@rm -rf eventlog/
@echo "Clean complete!"
-# Install gar to GOPATH/bin
+# Install ax to GOPATH/bin
install:
- @echo "Installing gar..."
- @go install ./cmd/gar
+ @echo "Installing ax..."
+ @go install ./cmd/ax
@echo "Install complete!"
-# Run local agent example
-run-local:
- @go run ./examples/local_agent
-
-# Run remote agent example
-run-remote:
- @go run ./examples/remote_agent
-
# Install dependencies
deps:
@echo "Installing dependencies..."
@@ -56,3 +55,8 @@ deps:
@go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
@go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
@echo "Dependencies installed!"
+
+clean-logs:
+ @echo "Cleaning the event logs..."
+ rm -rf ./eventlog
+ mkdir ./eventlog
diff --git a/README.md b/README.md
index bcbfbf64..f142d3a9 100644
--- a/README.md
+++ b/README.md
@@ -1,406 +1,385 @@
-# gar
-
-GAR, a short for Google Agent Runtime, is a single-writer agent orchestrator system built in Go. It provides a minimal runtime that coordinates agentic loops, manages sessions with event logging, and communicates with both local and remote agents via streaming protocols.
+# Agent Executor (AX)
+
+> [!WARNING]
+> 🚧 **AX is in active early development.**
+>
+> We are actively refining our core, resumption protocols,
+> and runtime specifications, which will introduce major breaking
+> changes prior to a stable release.
+>
+> **Temporary Policy:** We are temporarily pausing the acceptance of external Pull Requests while we stabilize the core architecture. We warmly encourage you to open Issues for feedback and feature requests instead.
+>
+> We will announce this project
+> widely soon. If you are interested in collaborating with us,
+> please reach out to **ax-dev@google.com**!
+
+AX, short for Agent Executor, is a distributed harness runtime.
+It dynamically provisions isolated environments from suspendable/resumable
+images to execute harnesses and agents.
+AX is designed for reliability, with native support for recovery
+and execution resumption, even in distributed setups.
## Features
-- **Streaming**: gRPC bidirectional streaming for agent communication
-- **Local & Remote Agents**: Support for both in-process and remote agent deployment
-- **Session Management**: Start, pause, resume, and inspect agentic loop sessions
-- **Agent Registry**: Automatic health monitoring and agent discovery
+- **Distributed Runtime**: Harnesses, skills, tools, and agents can execute in isolation
+- **Resumption**: Automatic recovery from failures or interruptions
+- **Built-in Harnesses**: Support for frontier harnesses and custom implementations
+- **Auditing & Policy**: All user and agentic calls are coordinated by a common controller, easy to control and audit the overall execution and skill/tool/agent calls
+- **Portability**: Runs anywhere, scales to small and large deployments
+- **Customizability**: Agnostic of harness and model
Built-in consistency and resumability features:
-- **Single-Writer Architecture**: Centralized controller ensures consistent state management
-- **Event Log**: Durable session state with automatic recovery
+- **Single-Writer Architecture**: Single controller ensures consistent state management
+- **Event Log**: Durable execution state with automatic recovery
+- **Advanced Resumption**: Support for compute-layer actor resumption on compatible platforms
+
+## Demo
+
+[](https://www.youtube.com/watch?v=L5Iw1IrZ6Nc)
+
+Watch our demo to see AX works when deployed on [Agent Substrate](https://github.com/agent-substrate/substrate).
## Overview
+```mermaid
+%%{init: {"flowchart": {"diagramPadding": 80}}}%%
+graph LR
+ Client
+
+ subgraph Cluster[" "]
+ Server["AX Server (multi-tenant)"]
+ DB[("Event Log" Storage)]
+ ControlService["Control API"]
+ Actor["AX Harness Server (stateful session-tenant)"]
+ end
+
+ SnapshotService["Snapshots"]
+ HarnessService["Harness or model service"]
+ MCPServer["MCP server"]
+
+ Client <-->|resumable stream| Server
+ Server <-->|scan/append| DB
+ Server --> ControlService
+ ControlService -->|resume/suspend| Actor
+ Server <-->|resumable stream| Actor
+ ControlService <-->|read/write| SnapshotService
+ Actor -.-> HarnessService
+ Actor --> MCPServer
+ Actor -.-> Environment
```
-┌────────────────────────┐
-│ [Controller] │
-│ - Session Manager │
-│ - Event Log │
-│ - Loop Executor │
-│ - Agent Registry │
-└──────┬──────────┬──────┘
- │ │
- (in-process) (gRPC stream)
- │ │
- ┌───────┐ ┌───────┐
- │ Local │ │Remote │
- │ Agent │ │ Agent │
- └───────┘ └───────┘
-```
+
+As agents evolve from simple assistants to autonomous long running workers,
+developers need a robust runtime to manage state, ensure reliability,
+and audit execution. As we are moving away from monolithic agents towards
+distributed harnesses where tools, skills and agents are deployed as
+isolated actors, a distributed runtime with dynamically spawned isolated
+workers becomes a necessity. AX provides the foundational layer to fill these gaps.
+
+While compute-agnostic, AX is aiming to provide the best
+experience on Kubernetes.
+
+We expect every sophisticated agentic application will need the
+capabilities provided by AX.
+We are building this layer as a widely available foundation,
+enabling developers to focus on building their applications rather
+than infrastructure. We decided to build this project in public to
+validate every design decision before a stable release is cut.
+We highly encourage you to give us feedback.
## Installation
-Install the gar CLI directly from the repository:
+Install the ax CLI directly from the repository:
```bash
-go install github.com/google/gar/cmd/gar@latest
+go install github.com/google/ax/cmd/ax@latest
```
### Verify Installation
-Check that gar is installed correctly:
+Check that ax is installed correctly:
```bash
-gar --help
+ax --help
```
-You should see the gar CLI usage information.
+You should see the ax CLI usage information.
-## Quick Start
-
-### 1. Run Local Agent Example
+### Kubernetes
-```bash
-# Run the local agent example by linking the local agent with controller.
-go run examples/local_agent/main.go
-```
+AX is natively supported on
+[Agent Substrate](https://github.com/agent-substrate/substrate)
+on Kubernetes and it's the recommended deployment option for production
+use. For more details on setup and configuration, see the
+[deployment guide](./manifests/README.md).
+Read more about [this new layer](https://cloud.google.com/blog/products/containers-kubernetes/bringing-you-agent-sandbox-on-gke-and-agent-substrate)
+that provides higher density to agentic workloads on Kubernetes.
-### 2. Run Remote Agent with GAR Server
+### Built-in Antigravity harness
-This example demonstrates how the gar server triggers remote agents through the
-AgentService.Process RPC.
-
-**Terminal 1** - Start the remote agent server:
-```bash
-go run examples/remote_agent/main.go
-```
+Antigravity SDK is a reference harness implementation. Local execution needs
+`python3` and `pip` available on your `PATH`. AX handles the rest: on first
+`ax exec` it starts the harness server as a Python sidecar and installs the
+pinned Antigravity SDK dependencies for you.
-The remote agent runs as a gRPC server implementing AgentService on port :50051.
+## Authentication
-**Terminal 2** - Start the gar controller server:
-```bash
-gar serve
-```
+The built-in Antigravity harness supports Google AI Studio and Vertex AI.
-The gar server exposes the GARService on port :8494.
+For Google AI Studio, set a Gemini API key:
-**Terminal 3** - Register the remote agent and trigger a session:
```bash
-# Register the remote agent with gar
-gar register \
- --agent-id remote-echo-agent \
- --agent-name "Echo Agent" \
- --agent-description "Echoes input in uppercase" \
- --agent-addr localhost:50051
-
-# Trigger a session - gar will trigger the remote agent via Process RPC
-gar trigger \
- --session-id session123 \
- --input "Hello remote agent"
-
-# Inspect session details
-gar inspect --session-id session123
+export GEMINI_API_KEY="your-api-key"
```
-## Usage
-
-### GAR CLI
-
-The `gar` command provides several subcommands:
-
-#### Trigger a Session
+For Vertex AI, configure Application Default Credentials and the target project
+and location:
```bash
-gar trigger \
- --input \
- [--session-id ] \
- [--checkpoint ] \
- [--server ]
+gcloud auth application-default login
+export GOOGLE_CLOUD_PROJECT="your-project-id"
+export GOOGLE_CLOUD_LOCATION="us-central1"
+export GOOGLE_GENAI_USE_VERTEXAI=true
```
-Triggers a new agentic loop session or automatically resumes an existing one. If the session ID already exists, the session will be resumed from its last checkpoint (or a specific checkpoint if provided) with the new input.
+## Quick Start
-Options:
-- `--input`: Input message to send to agents (required)
-- `--session-id`: Unique session identifier (optional, generates UUID if not provided, or resumes if exists)
-- `--checkpoint`: Resume from specific checkpoint (empty for latest)
-- `--server`: gRPC controller server address (default: "localhost:8494")
+### Execute
-**Examples:**
+The CLI starts the built-in Antigravity harness automatically. No separate harness server setup is required.
```bash
-# Trigger a new session
-gar trigger --input "Hello agent"
-
-# Resume an existing session with new input
-gar trigger --session-id abc123 --input "Continue processing"
+# Using the checked-in ax.yaml, which sets Antigravity as the default harness.
+ax exec --input "Can you list this directory?"
-# Resume from a specific checkpoint (useful for undoing mistakes or exploring alternatives)
-gar trigger --session-id abc123 \
- --checkpoint "550e8400-e29b-41d4-a716-446655440000" \
- --input "Try a different approach"
+# Using exec with an AX server
+ax exec --input "Can you list this directory?" --server localhost:8494
```
-#### Inspect a Session
+Conversations can be continued any time:
```bash
-gar inspect --session-id [--server ]
+ax exec \
+ --conversation d85a4b4e-c53b-4c84-b879-f10d905bce40 \
+ --input "Show me the contents of README.md"
```
-Options:
-- `--session-id`: Session identifier to inspect (required)
-- `--server`: gRPC controller server address (default: "localhost:8494")
+If the client gets disconnected, pass the last sequence it saw to
+replay the events it missed. This catches the client up; it does not
+rewind the conversation.
-#### Register a Remote Agent
+In this example, we catch up a client from sequence number 12:
```bash
-gar register \
- --agent-id \
- --agent-addr \
- --agent-name \
- --agent-description \
- [--server ]
+ax exec \
+ --conversation d85a4b4e-c53b-4c84-b879-f10d905bce40 \
+ --last-seq 12 \
+ --resume
```
-Options:
-- `--agent-id`: Unique agent identifier (required)
-- `--agent-addr`: gRPC agent server address (e.g., "localhost:50051") (required)
-- `--agent-name`: Human-readable name for the agent (required)
-- `--agent-description`: Description of agent capabilities (required)
-- `--server`: gRPC controller server address (default: "localhost:8494")
+Instead of running the default harness, you can start executing
+any registered harness:
-#### Run Server
+```bash
+ax exec \
+ --harness antigravity \
+ --input "Can you write me a simple HTTP server in Python?"
+```
+If anything goes wrong during the execution of a harness,
+you can resume an incomplete execution in a conversation:
```bash
-gar serve [--config ]
+ax exec \
+ --conversation edf98ef5-4bb1-4a9e-a091-3a77e03727e6 \
+ --harness antigravity \
+ --resume
```
-Starts the controller as a gRPC server using a YAML configuration file.
-Options:
-- `--config`: Path to YAML configuration file (default: "gar.yaml")
+## Usage
-Example configuration file (`gar.yaml`):
-```yaml
-server:
- address: ":8494"
+The `ax` command provides several subcommands:
-eventlog:
- dir: "eventlog"
-
-# Maximum steps per trigger
-max_steps: 50
-
-# Health check interval for agents
-health_check_interval: 30s
-
-# Remote agents to register on startup
-remote_agents:
- - id: "text-processing-agent"
- name: "Text Processing Agent"
- description: "An agent for text processing"
- address: "localhost:50051"
- metadata:
- version: "1.0"
-```
+### Execute
-Example:
```bash
-# Start server with default config (gar.yaml)
-gar serve
-
-# Start server with custom config
-gar serve --config my-config.yaml
+ax exec \
+ [--input ] \
+ [--conversation ] \
+ [--harness ] \
+ [--harness-config ] \
+ [--harness-config-json ] \
+ [--server ] \
+ [--config ] \
+ [--resume] \
+ [--last-seq ]
```
-### Checkpoints
+Executes a new harness execution or automatically resumes an existing one. If the conversation ID already exists, the execution will be resumed from its last state.
-Checkpoints provide a mechanism to save and resume session state at specific points. Every content event (both `CONTENT_IN` and `CONTENT_OUT`) automatically creates a checkpoint with a unique UUID.
+Options:
+- `--input`: Input message to send to agents (optional if `--resume` is set, otherwise required)
+- `--conversation`: Conversation ID (optional, generates UUID if not provided, or resumes if exists)
+- `--harness`: Harness ID to use (optional, defaults to the default harness)
+- `--harness-config`: Path to a JSON file with per-request harness configuration (optional)
+- `--harness-config-json`: Per-request harness configuration as an inline JSON string (optional, mutually exclusive with `--harness-config`)
+- `--server`: gRPC controller server address (optional. If not provided, runs with a local built-in AX server)
+- `--config`: Path to YAML configuration file (only used with a local built-in AX server, default: "ax.yaml")
+- `--resume`: Resume a conversation without inputs (optional, mutually exclusive with `--input`)
+- `--last-seq`: Last sequence number seen by the client to resume from (optional). The server replays any later events so the client can catch up after a disconnect.
-**Usage Examples:**
+**Examples:**
```bash
-# Inspect a session to see available checkpoints
-gar inspect --session-id session123
+# Execute a new execution
+ax exec --input "Hello agents!"
-# Resume from a specific checkpoint
-gar trigger --session-id session123 \
- --checkpoint "550e8400-e29b-41d4-a716-446655440000" \
- --input "Try different approach"
-```
+# Resume an existing execution with new input
+ax exec --conversation a53d4db3-1165-4925-87da-be6c72bbdeb1 --input "Ok, now let's do something else..."
+# Execute using server mode
+ax exec --server localhost:8494 --input "Hello agents!"
-### Event Log Format
+# Execute using a specific harness
+ax exec --harness antigravity --input "Write me a cool Go program!"
-Event logs use JSON Lines format (one JSON object per line). Each entry includes the session ID, a monotonic sequence number, and checkpoint ID (for content events) for traceability:
+# Execute with per-request harness config
+ax exec \
+ --harness-config-json '{"system_instructions":"Answer in one sentence.","model":"gemini-3.5-flash"}' \
+ --input "Explain durable execution."
-```json
-{"session_id": "session123", "timestamp": "2026-01-02T10:30:00Z", "seq": 0, "type": "CONTENT_IN", "checkpoint_id": "550e8400-e29b-41d4-a716-446655440000", "data": {...}}
-{"session_id": "session123", "timestamp": "2026-01-02T10:30:01Z", "seq": 1, "type": "CONTENT_OUT", "checkpoint_id": "6ba7b810-9dad-11d1-80b4-00c04fd430c8", "data": {...}}
-{"session_id": "session123", "timestamp": "2026-01-02T10:30:02Z", "seq": 2, "type": "STATE", "data": {...}}
+# To keep the same JSON in a file, use `--harness-config` instead:
+ax exec --harness-config antigravity.json --input "Explain durable execution."
```
-**Event Types:**
-- `CONTENT_IN`: Incoming content from user or external source (includes `checkpoint_id`)
-- `CONTENT_OUT`: Outgoing content from agents (includes `checkpoint_id`)
-- `STATE`: Session state changes, e.g. running, completed, failed.
-
-## Building Custom Agents
-
-### Local Agent
-
-```go
-import (
- "context"
- "github.com/google/gar/agent"
- "github.com/google/gar/proto"
-)
-
-// Define your process function using callback handler
-processFunc := func(ctx context.Context, sessionID string, inputs []*proto.Content, handler agent.OutputHandler) error {
- for _, content := range inputs {
- output := &proto.Content{
- Role: "assistant",
- Type: "text",
- Mimetype: "text/plain",
- Data: "Your response: " + content.Data,
- }
- if err := handler(output); err != nil {
- return err
- }
- }
- return nil
-}
-
-// Define health check function (optional)
-healthCheckFunc := func(ctx context.Context) error {
- // Return nil if healthy, error otherwise
- return nil
-}
-
-// Create the agent
-myAgent, err := agent.NewLocalAgent(agent.LocalAgentConfig{
- ID: "my-agent",
- ProcessFunc: processFunc,
- HealthCheckFunc: healthCheckFunc, // optional
-})
-```
+### Serve
-### Remote Agent
-
-Remote agents run as gRPC servers implementing the `AgentService` interface defined in `proto/gar.proto`. The gar controller triggers remote agents by calling their `Process` RPC with bidirectional streaming.
-
-```go
-type server struct {
- proto.UnimplementedAgentServiceServer
-}
-
-// Process handles bidirectional streaming - gar controller calls this RPC
-func (s *server) Process(stream proto.AgentService_ProcessServer) error {
- for {
- // Receive input content from gar controller
- content, err := stream.Recv()
- if err == io.EOF {
- return nil
- }
- if err != nil {
- return err
- }
-
- // Process the content
- output := &proto.Content{
- Role: "assistant",
- Type: "text",
- Mimetype: "text/plain",
- Data: "Processed: " + content.Data,
- }
-
- // Send response back to gar controller
- if err := stream.Send(output); err != nil {
- return err
- }
- }
-}
-
-func (s *server) HealthCheck(ctx context.Context, req *proto.HealthCheckRequest) (*proto.HealthCheckResponse, error) {
- // Return health status for gar controller health monitoring
- return &proto.HealthCheckResponse{
- Healthy: true,
- Message: "Agent is healthy",
- }, nil
-}
+```bash
+ax serve [--config ]
```
-**Workflow:**
-1. Remote agent starts as gRPC server on a port (e.g., :50051)
-2. Start gar controller: `gar serve`
-3. Register with gar: `gar register --agent-id my-agent --agent-name "My Agent" --agent-description "Agent description" --agent-addr localhost:50051`
-4. When gar triggers a session, it calls the agent's `Process` RPC
-5. GAR streams input content → Agent processes → Agent streams output back
-
-See `examples/remote_agent/main.go` for a complete implementation.
-
-### Remote Python Agent
+Starts the controller as a gRPC server using a YAML configuration file.
-Python agents can be built using the GAR agent framework. First, install dependencies and generate Python gRPC code:
+Options:
+- `--config`: Path to YAML configuration file (default: "ax.yaml")
-```bash
-# Install dependencies
-pip install grpcio grpcio-tools
+Example configuration file (`ax.yaml`):
+```yaml
+version: v1alpha
-# Generate Python code from proto file
-python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. proto/gar.proto
-```
+server:
+ address: ":8494"
-Then implement your agent using the framework:
-
-```python
-from gar import Agent
-import proto.gar_pb2 as pb2
-
-def process(session_id, inputs):
- """Process incoming content list and yield responses"""
- for content in inputs:
- yield pb2.Content(
- role="assistant",
- type="text",
- mimetype="text/plain",
- data=f"Python processed: {content.data.upper()}"
- )
-
-def health_check():
- """Health check function that always returns healthy"""
- return True, "OK", {}
-
-# Create and start the agent
-agent = Agent(
- process_func=process,
- health_check_func=health_check
-)
-agent.serve(port=50051)
+eventlog:
+ sqlite:
+ filename: "eventlog/log.sqlite"
```
-**Register and use:**
+Example:
```bash
-# Start the Python agent
-python agent.py
-
-# Register with gar (in another terminal)
-gar register \
- --agent-id "text-processing-agent" \
- --agent-name "Text Processing Agent" \
- --agent-description "An agent that processes text" \
- --agent-addr localhost:50051
-
-# Trigger a session
-gar trigger \
- --session-id session123 \
- --input "Hello, I heard that there is an agent that can help with processing this text!"
-```
+# Start server with default config (ax.yaml)
+ax serve
-## Future Enhancements
+# Start server with custom config
+ax serve --config my-config.yaml
+```
-- [ ] Replace Content with Interactions Content
-- [ ] Observability and trajectory collection
-- [ ] TLS support for remote agents
-- [ ] gar deploy from container
-- [ ] Fork session when resuming from a checkpoint that isn't the latest
-- [ ] Create a package of local agents (listing files, reading files, grep'ing the directory)
-- [ ] Web UI
+## Extensions
+
+### Harnesses
+
+AX provides built-in harnesses (e.g. Antigravity) but you can bring your
+own harness implementation by implementing `HarnessService`. On supported
+compute services (e.g. Agent Substrate), AX automatically runs the
+harness in isolation with automatic resumption and suspension.
+
+Traditional agents (e.g. tool use or workflow agents), or
+language models can be implemented as harnesses.
+
+### Skills
+
+Built-in harnesses like Antigravity includes built-in support for
+Agent Skills. See [Skills](examples/skills) for more.
+
+### MCP Tools
+
+Built-in harnesses like Antigravity provides support for discovering
+and making calls to MCP tools when they are configured.
+
+## What AX is NOT?
+* A managed service. AX is self-hosted and not a managed service.
+ We aim to make it easy for users to deploy and operate it on
+ their Kubernetes clusters.
+* An agentic framework. AX is agnostic of the framework used
+ to build agents.
+* A specific harness like a specific coding agent, e.g. Antigravity.
+ AX provides the serving layer around harnesses and is agnostic of the
+ harness implementation. Soon, we will allow users to bring their own
+ harnesses.
+* A model specific controller. AX is agnostic of the models used.
+
+## Roadmap
+
+Below is an overview of our upcoming features and planned changes:
+
+1. Support for more frontier harnesses besides Antigravity
+1. Support for BYOH (Bring Your Own Harness)
+1. Support for tool call approvals from harnesses
+1. Improvements to resumption protocols
+1. Forking from event log and snapshots
+1. Trajectory exposition
+1. Better telemetry exposition
+1. Integrations for policy, auditing, and more
+
+## Contributing
+
+Please refer to the [CONTRIBUTING.md](CONTRIBUTING.md) guide for instructions
+on how to contribute to this project.
+
+We are currently undergoing a significant architectural redesign, and external contributions are temporarily paused.
+However, in the meantime, we warmly encourage you to file bugs and
+send feature requests.
+
+## History
+
+Over the years, teams across Google built and operated several
+distributed execution engines. As these systems evolved, certain
+architectural patterns consistently stood the test of time.
+The teams realized they were repeatedly solving similar
+orchestration problems, prompting the push to extract these
+lessons into common runtime layer.
+
+While this common layer was taking shape, the AI landscape underwent
+a massive shift. Applications were transitioning from
+statless tool use agents to autonomous, long-running, self improving
+agents that often need isolated resumable execution environments.
+Also, from an efficiency standpoint, agentic workloads are inherently bursty.
+An agent might compute intensively for a minute, then sit idle for
+hours or days awaiting human approval. Keeping a stateful actor active
+during these long idle periods is highly inefficient and cost-prohibitive
+at scale.
+Over the last 10 years, Kubernetes has become the standard for
+large scale job orchestration, but it was fundamentally designed for
+stateless microservices or predictable batch jobs --
+not for suspending and resuming stateful, sandboxed agent actors.
+
+Driven by these dual challenges, we decided to build a robust, common
+agentic orchestrator designed specifically for the new compute
+layers we are developing on Kubernetes. Our goal is to ease
+the productionization of agents, allowing developers
+and researchers to focus on building and evaluating their
+applications rather than dealing with underlying infrastructure.
+
+AX is developed and maintained by the team actively working on
+Google's internal runtime. Although the two projects operate
+at different layers today, we are applying our knowledge
+and insights to AX in the public domain every day.
+
+## Acknowledgements
+
+We thank Google DeepMind for their earlier work in distributed harnesses which
+heavily influenced AX.
+We thank the Google Kubernetes Engine team for their deep contributions
+regarding isolation, resumption and job scheduling.
## License
diff --git a/agent/agent.go b/agent/agent.go
deleted file mode 100644
index 97c6aeda..00000000
--- a/agent/agent.go
+++ /dev/null
@@ -1,43 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Package agent provides interfaces and implementations for local and remote agents.
-// Agents process content through callback handlers and can be registered with the controller.
-package agent
-
-import (
- "context"
-
- "github.com/google/gar/proto"
-)
-
-// OutputHandler is a callback function that handles output content from an agent.
-// It is called for each piece of content the agent generates.
-type OutputHandler func(content *proto.Content) error
-
-// Agent defines the common interface for both local and remote agents.
-// Agents process content using callback handlers.
-type Agent interface {
- // Process handles processing of input content.
- // It calls the output handler for each piece of content generated.
- // The handler may be called multiple times during processing.
- Process(ctx context.Context, sessionID string, inputs []*proto.Content, handler OutputHandler) error
-
- // HealthCheck checks if the agent is healthy and responsive.
- // Returns an error if the agent is unhealthy or unreachable.
- HealthCheck(ctx context.Context) error
-
- // Close gracefully shuts down the agent and releases resources.
- Close() error
-}
diff --git a/agent/local.go b/agent/local.go
deleted file mode 100644
index c9af3432..00000000
--- a/agent/local.go
+++ /dev/null
@@ -1,68 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package agent
-
-import (
- "context"
- "fmt"
-
- "github.com/google/gar/proto"
-)
-
-// LocalAgent wraps a local (in-process) agent implementation.
-// It implements the Agent interface for agents running in the same process as the controller.
-type LocalAgent struct {
- processFunc func(ctx context.Context, sessionID string, inputs []*proto.Content, handler OutputHandler) error
- healthCheckFunc func(ctx context.Context) error
-}
-
-// LocalAgentConfig configures a local agent.
-type LocalAgentConfig struct {
- ProcessFunc func(ctx context.Context, sessionID string, inputs []*proto.Content, handler OutputHandler) error
- HealthCheckFunc func(ctx context.Context) error
-}
-
-// NewLocalAgent creates a new local agent with the provided configuration.
-func NewLocalAgent(config LocalAgentConfig) (*LocalAgent, error) {
- if config.ProcessFunc == nil {
- return nil, fmt.Errorf("ProcessFunc cannot be nil")
- }
-
- // Provide defaults for optional functions
- if config.HealthCheckFunc == nil {
- config.HealthCheckFunc = func(ctx context.Context) error { return nil }
- }
-
- return &LocalAgent{
- processFunc: config.ProcessFunc,
- healthCheckFunc: config.HealthCheckFunc,
- }, nil
-}
-
-// Process handles processing of input content with callback handler.
-func (a *LocalAgent) Process(ctx context.Context, sessionID string, inputs []*proto.Content, handler OutputHandler) error {
- return a.processFunc(ctx, sessionID, inputs, handler)
-}
-
-// HealthCheck checks if the agent is healthy.
-func (a *LocalAgent) HealthCheck(ctx context.Context) error {
- return a.healthCheckFunc(ctx)
-}
-
-// Close gracefully shuts down the agent.
-func (a *LocalAgent) Close() error {
- // Local agents don't typically need cleanup, but this can be extended
- return nil
-}
diff --git a/agent/remote.go b/agent/remote.go
deleted file mode 100644
index d375dffe..00000000
--- a/agent/remote.go
+++ /dev/null
@@ -1,170 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package agent
-
-import (
- "context"
- "fmt"
- "io"
- "sync"
- "time"
-
- "google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
- "google.golang.org/grpc/metadata"
-
- "github.com/google/gar/proto"
-)
-
-// RemoteAgent is a gRPC client that implements the Agent interface.
-// It communicates with remote agent services over gRPC.
-type RemoteAgent struct {
- address string
- conn *grpc.ClientConn
- client proto.AgentServiceClient
- mu sync.Mutex
- reconnect bool
- maxRetries int
- dialOpts []grpc.DialOption
-}
-
-// RemoteAgentConfig configures a remote agent client.
-type RemoteAgentConfig struct {
- Address string // gRPC server address (e.g., "localhost:50051")
- Reconnect bool // Whether to automatically reconnect on failures
- MaxRetries int // Maximum number of retry attempts (0 = infinite)
- DialOpts []grpc.DialOption // gRPC dial options for customizing the connection
-}
-
-// NewRemoteAgent creates a new remote agent client.
-func NewRemoteAgent(config RemoteAgentConfig) (*RemoteAgent, error) {
- if config.Address == "" {
- return nil, fmt.Errorf("agent address cannot be empty")
- }
-
- agent := &RemoteAgent{
- address: config.Address,
- reconnect: config.Reconnect,
- maxRetries: config.MaxRetries,
- dialOpts: config.DialOpts,
- }
-
- if err := agent.connect(); err != nil {
- return nil, fmt.Errorf("failed to connect to remote agent: %w", err)
- }
-
- return agent, nil
-}
-
-// connect establishes a gRPC connection to the remote agent.
-func (a *RemoteAgent) connect() error {
- a.mu.Lock()
- defer a.mu.Unlock()
-
- if a.conn != nil {
- a.conn.Close()
- }
-
- // Use provided dial options, or default to insecure credentials
- opts := a.dialOpts
- if len(opts) == 0 {
- opts = []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
- }
-
- conn, err := grpc.NewClient(a.address, opts...)
- if err != nil {
- return fmt.Errorf("failed to dial: %w", err)
- }
-
- a.conn = conn
- a.client = proto.NewAgentServiceClient(conn)
- return nil
-}
-
-// Process handles processing of input content with the remote agent.
-func (a *RemoteAgent) Process(ctx context.Context, sessionID string, inputs []*proto.Content, handler OutputHandler) error {
- // Add session_id to gRPC metadata
- md := metadata.Pairs("session-id", sessionID)
- ctx = metadata.NewOutgoingContext(ctx, md)
-
- stream, err := a.client.Process(ctx)
- if err != nil {
- return fmt.Errorf("failed to create stream: %w", err)
- }
-
- // Send all inputs to the remote agent
- for _, content := range inputs {
- if err := stream.Send(content); err != nil {
- return fmt.Errorf("failed to send content: %w", err)
- }
- }
-
- // Close the send direction to signal we're done sending
- if err := stream.CloseSend(); err != nil {
- return fmt.Errorf("failed to close send: %w", err)
- }
-
- // Receive outputs and call handler for each
- for {
- content, err := stream.Recv()
- if err == io.EOF {
- // Stream completed successfully
- return nil
- }
- if err != nil {
- return fmt.Errorf("failed to receive content: %w", err)
- }
-
- // Call the handler with the received content
- if err := handler(content); err != nil {
- return fmt.Errorf("handler error: %w", err)
- }
-
- // Check for context cancellation
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
- }
-}
-
-// HealthCheck checks if the remote agent is healthy.
-func (a *RemoteAgent) HealthCheck(ctx context.Context) error {
- ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
- defer cancel()
-
- resp, err := a.client.HealthCheck(ctx, &proto.HealthCheckRequest{})
- if err != nil {
- return fmt.Errorf("health check failed: %w", err)
- }
-
- if !resp.Healthy {
- return fmt.Errorf("agent unhealthy: %s", resp.Message)
- }
-
- return nil
-}
-
-// Close gracefully shuts down the remote agent connection.
-func (a *RemoteAgent) Close() error {
- a.mu.Lock()
- defer a.mu.Unlock()
-
- if a.conn != nil {
- return a.conn.Close()
- }
- return nil
-}
diff --git a/ax.yaml b/ax.yaml
new file mode 100644
index 00000000..56a980a1
--- /dev/null
+++ b/ax.yaml
@@ -0,0 +1,34 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Sample LOCAL configuration for the AX *harness* build (compiled with -tags=harness).
+# Usage: ax serve --config ax.yaml
+# ax exec --config ax.yaml --input "hello" # uses harnesses.default
+version: v1alpha
+
+server:
+ address: ":8494"
+
+eventlog:
+ sqlite:
+ filename: "eventlog/log.sqlite"
+
+harnesses:
+ antigravity:
+ default: true
+ antigravity-interactions: {}
+
+telemetry:
+ otlp:
+ enabled: true
diff --git a/cmd/ax/Dockerfile b/cmd/ax/Dockerfile
new file mode 100644
index 00000000..16a1b901
--- /dev/null
+++ b/cmd/ax/Dockerfile
@@ -0,0 +1,101 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Multi-target AX image build:
+# --target antigravity : python:3.13-slim + the Antigravity Python sidecar
+# (SDK, localharness, agent). Used by the antigravity
+# harness actor (`ax harness antigravity`).
+# --target ax : debian:stable-slim + the Go `ax` binary. Used by
+# the ax-server (`ax serve`) and the Go interactions
+# harness actor (`ax harness antigravity-interactions`).
+#
+# Build context: repository root. Images target the cluster's linux/amd64 nodes,
+# so build with `--platform linux/amd64`.
+# For a standalone build:
+# docker build --platform linux/amd64 --target antigravity -f cmd/ax/Dockerfile -t . && docker push
+# docker build --platform linux/amd64 --target ax -f cmd/ax/Dockerfile -t . && docker push
+
+# --- Stage 1: build the ax Go binary (with the harness build tag) -------------
+FROM --platform=$BUILDPLATFORM golang:1.26 AS build
+ARG TARGETOS=linux
+ARG TARGETARCH=amd64
+WORKDIR /src
+COPY go.mod go.sum ./
+RUN go mod download
+COPY . .
+RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
+ go build -o /out/ax ./cmd/ax
+
+# --- antigravity image -------
+FROM python:3.13-slim AS antigravity
+WORKDIR /workspace
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ curl \
+ less \
+ procps \
+ wget \
+ && rm -rf /var/lib/apt/lists/*
+
+# Antigravity runtime deps, installed from PyPI at build time.
+COPY python/antigravity/requirements.txt /tmp/requirements.txt
+RUN pip install --no-cache-dir -r /tmp/requirements.txt
+
+# Python sidecar (with generated proto stubs), the agent it serves, and the ax
+# binary built in stage 1.
+COPY python/ /ax-app/python
+
+# You can copy skills or use skills managers (e.g. npx skills)
+# to download them into the image.
+# Example: COPY examples/skills/ /ax/skills/
+
+ENV SKILLS_DIR=/ax/skills
+COPY --from=build /out/ax /ax-app/ax
+
+# `from python.proto import ...` needs /ax-app on the path; the generated stubs do
+# `from proto import ...`, which needs /ax-app/python.
+ENV PYTHONPATH=/ax-app:/ax-app/python
+
+# Flush stdout/stderr immediately so server logs surface in container logs.
+ENV PYTHONUNBUFFERED=1
+
+EXPOSE 80
+
+# Default command for local docker runs. Substrate ignores the image CMD and runs
+# the ActorTemplate `command` instead. `ax harness` forks the Python sidecar,
+# which serves the HarnessService on :80.
+CMD ["/ax-app/ax", "harness", \
+ "--host", "0.0.0.0", "--port", "80"]
+
+# --- ax image (Go-only) for the interactions harness and ax-server -----------
+FROM debian:stable-slim AS ax
+WORKDIR /workspace
+
+RUN apt-get update \
+ && apt-get install -y --no-install-recommends \
+ ca-certificates \
+ curl \
+ less \
+ procps \
+ wget \
+ && rm -rf /var/lib/apt/lists/*
+
+COPY --from=build /out/ax /ax-app/ax
+
+EXPOSE 80
+
+CMD ["/ax-app/ax", "harness", "antigravity-interactions", \
+ "--host", "0.0.0.0", "--port", "80"]
diff --git a/cmd/ax/dashboard.go b/cmd/ax/dashboard.go
new file mode 100644
index 00000000..e518f74b
--- /dev/null
+++ b/cmd/ax/dashboard.go
@@ -0,0 +1,475 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "context"
+ "database/sql"
+ _ "embed"
+ "encoding/json"
+ "fmt"
+ "log/slog"
+ "net"
+ "net/http"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "time"
+
+ "github.com/google/ax/cmd/ax/internal/cliutil"
+ "github.com/google/ax/internal/controller/eventlog"
+ "github.com/google/ax/proto"
+ "github.com/spf13/cobra"
+ _ "modernc.org/sqlite"
+)
+
+//go:embed web/index.html
+var dashboardHTML string
+
+var (
+ dashboardAddr string
+ dashboardConfigFile string
+)
+
+var dashboardCmd = &cobra.Command{
+ Use: "dashboard",
+ Short: "Start the AX Dashboard",
+ Long: `Start a local HTTP server to display AX conversations and executions dashboard.`,
+ RunE: runDashboard,
+}
+
+func init() {
+ dashboardCmd.Flags().StringVar(&dashboardAddr, "addr", "localhost:8080", "Server address to listen on")
+ dashboardCmd.Flags().StringVar(&dashboardConfigFile, "config", "ax.yaml", "Path to YAML configuration file")
+}
+
+type ConversationResponse struct {
+ ID string `json:"id"`
+ Agent string `json:"agent"`
+ Status string `json:"status"`
+ LastSeq int32 `json:"last_seq"`
+ Duration string `json:"duration"`
+}
+
+func runDashboard(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+ slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
+
+ cfg, err := newConfig(cmd, dashboardConfigFile)
+ if err != nil {
+ return err
+ }
+
+ dbPath := cfg.EventLog.SQLiteConfig.Filename
+ slog.InfoContext(ctx, "Opening event log database", slog.String("path", dbPath))
+
+ if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
+ return fmt.Errorf("failed to create database directory: %w", err)
+ }
+
+ db, err := sql.Open("sqlite", dbPath)
+ if err != nil {
+ return fmt.Errorf("failed to open sqlite database: %w", err)
+ }
+ defer db.Close()
+
+ // Verify database connection
+ if err := db.PingContext(ctx); err != nil {
+ return fmt.Errorf("failed to ping database: %w", err)
+ }
+
+ // Create tables if they don't exist (to avoid crashes on fresh setup)
+ if _, err := db.Exec(`
+ CREATE TABLE IF NOT EXISTS conversation_log (
+ conversation_id TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ payload TEXT NOT NULL,
+ PRIMARY KEY (conversation_id, seq)
+ )`); err != nil {
+ return fmt.Errorf("failed to initialize conversation_log table: %w", err)
+ }
+
+ if _, err := db.Exec(`
+ CREATE TABLE IF NOT EXISTS execution_log (
+ exec_id TEXT NOT NULL,
+ payload TEXT NOT NULL,
+ timestamp DATETIME NOT NULL
+ )`); err != nil {
+ return fmt.Errorf("failed to initialize execution_log table: %w", err)
+ }
+
+ if _, err := db.Exec(`CREATE INDEX IF NOT EXISTS idx_execution_log_exec_id ON execution_log(exec_id)`); err != nil {
+ return fmt.Errorf("failed to create index on execution_log: %w", err)
+ }
+
+ // Setup API handlers
+ mux := http.NewServeMux()
+ mux.HandleFunc("/api/conversations", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ convs, err := fetchConversations(r.Context(), db)
+ if err != nil {
+ slog.ErrorContext(r.Context(), "Failed to fetch conversations", slog.Any("error", err))
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(convs); err != nil {
+ slog.ErrorContext(r.Context(), "Failed to encode conversations response", slog.Any("error", err))
+ }
+ })
+
+ mux.HandleFunc("/api/trace", func(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ convID := r.URL.Query().Get("conversation")
+ if convID == "" {
+ http.Error(w, "Missing conversation ID", http.StatusBadRequest)
+ return
+ }
+
+ data, err := loadTraceData(r.Context(), cfg, convID)
+ if err != nil {
+ slog.ErrorContext(r.Context(), "Failed to load trace data", slog.String("conversation_id", convID), slog.Any("error", err))
+ http.Error(w, "Internal server error", http.StatusInternalServerError)
+ return
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(data); err != nil {
+ slog.ErrorContext(r.Context(), "Failed to encode trace data response", slog.Any("error", err))
+ }
+ })
+
+ mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ fmt.Fprint(w, dashboardHTML)
+ })
+
+ listener, err := net.Listen("tcp", dashboardAddr)
+ if err != nil {
+ return fmt.Errorf("failed to bind server to %s: %w", dashboardAddr, err)
+ }
+ defer listener.Close()
+
+ addr := listener.Addr().String()
+ host, port, err := net.SplitHostPort(addr)
+ if err == nil && (host == "::" || host == "0.0.0.0" || host == "" || host == "[::]") {
+ addr = fmt.Sprintf("localhost:%s", port)
+ }
+ url := fmt.Sprintf("http://%s", addr)
+
+ slog.InfoContext(ctx, "AX Dashboard started", slog.String("url", url))
+
+ go openBrowser(url)
+
+ server := &http.Server{
+ Handler: mux,
+ }
+
+ return server.Serve(listener)
+}
+
+func fetchConversations(ctx context.Context, db *sql.DB) ([]ConversationResponse, error) {
+ query := `
+SELECT
+ c.conversation_id,
+ c.last_seq,
+ c.state,
+ e.agent_id,
+ e.start_time,
+ e.end_time
+FROM (
+ SELECT conversation_id, seq AS last_seq,
+ json_extract(payload, '$.exec_id') AS exec_id,
+ json_extract(payload, '$.state') AS state
+ FROM conversation_log
+ WHERE (conversation_id, seq) IN (
+ SELECT conversation_id, MAX(seq)
+ FROM conversation_log
+ GROUP BY conversation_id
+ )
+) c
+LEFT JOIN (
+ SELECT
+ exec_id,
+ json_extract(payload, '$.agent_id') AS agent_id,
+ MIN(timestamp) AS start_time,
+ MAX(timestamp) AS end_time
+ FROM execution_log
+ GROUP BY exec_id
+) e ON c.exec_id = e.exec_id;
+`
+ rows, err := db.QueryContext(ctx, query)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ convs := []ConversationResponse{}
+ for rows.Next() {
+ var id string
+ var lastSeq int32
+ var state string
+ var agentID sql.NullString
+ var startTimeStr, endTimeStr sql.NullString
+
+ err := rows.Scan(&id, &lastSeq, &state, &agentID, &startTimeStr, &endTimeStr)
+ if err != nil {
+ return nil, err
+ }
+
+ agent := "unknown"
+ if agentID.Valid && agentID.String != "" {
+ agent = agentID.String
+ // Strip special prefix if it starts with "__"
+ if len(agent) > 2 && agent[:2] == "__" {
+ agent = agent[2:]
+ }
+ }
+
+ durationStr := "N/A"
+ if startTimeStr.Valid && endTimeStr.Valid {
+ startTime, err1 := parseSQLiteTime(startTimeStr.String)
+ endTime, err2 := parseSQLiteTime(endTimeStr.String)
+ if err1 == nil && err2 == nil {
+ duration := endTime.Sub(startTime)
+ durationStr = fmt.Sprintf("%.1fs", duration.Seconds())
+ } else {
+ slog.WarnContext(ctx, "Failed to parse sqlite timestamps", slog.String("start", startTimeStr.String), slog.String("end", endTimeStr.String), slog.Any("err1", err1), slog.Any("err2", err2))
+ }
+ }
+
+ status := state
+ if len(status) > 6 && status[:6] == "STATE_" {
+ status = status[6:]
+ }
+ if status == "PENDING" {
+ status = "RUNNING"
+ }
+
+ convs = append(convs, ConversationResponse{
+ ID: id,
+ Agent: agent,
+ Status: status,
+ LastSeq: lastSeq,
+ Duration: durationStr,
+ })
+ }
+
+ return convs, nil
+}
+
+func parseSQLiteTime(s string) (time.Time, error) {
+ layouts := []string{
+ time.RFC3339Nano,
+ time.RFC3339,
+ "2006-01-02 15:04:05.999999999-07:00",
+ "2006-01-02 15:04:05.999999999",
+ "2006-01-02 15:04:05.999999999 -0700 MST",
+ "2006-01-02 15:04:05",
+ }
+ var err error
+ var t time.Time
+ for _, layout := range layouts {
+ t, err = time.Parse(layout, s)
+ if err == nil {
+ return t, nil
+ }
+ }
+ return time.Time{}, err
+}
+
+func openBrowser(url string) {
+ var err error
+ switch runtime.GOOS {
+ case "linux":
+ err = exec.Command("xdg-open", url).Start()
+ case "windows":
+ err = exec.Command("rundll32", "url.dll,FileProtocolHandler", url).Start()
+ case "darwin":
+ err = exec.Command("open", url).Start()
+ }
+ if err != nil {
+ fmt.Printf("Failed to open browser: %v\n", err)
+ }
+}
+
+type Text struct {
+ Text string `json:"text"`
+}
+
+type Approval struct {
+ Approved bool `json:"approved"`
+}
+
+type Confirmation struct {
+ ID string `json:"id"`
+ Question string `json:"question,omitempty"`
+ Approval *Approval `json:"approval,omitempty"`
+}
+
+type Content struct {
+ Role string `json:"role"`
+ Text *Text `json:"text,omitempty"`
+ Confirmation *Confirmation `json:"confirmation,omitempty"`
+}
+
+type ExecutionEvent struct {
+ ExecID string `json:"exec_id"`
+ AgentID string `json:"agent_id"`
+ Inputs []Content `json:"inputs"`
+ Outputs []Content `json:"outputs"`
+ State string `json:"state"`
+ Timestamp time.Time `json:"timestamp"`
+}
+
+type ExecTrace struct {
+ ExecID string `json:"exec_id"`
+ AgentID string `json:"agent_id"`
+ Events []ExecutionEvent `json:"events"`
+}
+
+type TraceData struct {
+ ConversationID string `json:"conversation_id"`
+ RootExecID string `json:"root_exec_id"`
+ Execs []ExecTrace `json:"execs"`
+}
+
+func loadTraceData(ctx context.Context, cfg *cliutil.Config, convID string) (*TraceData, error) {
+ events, rootExecID, execIDs, err := fetch(ctx, cfg, convID)
+ if err != nil {
+ return nil, err
+ }
+
+ // TODO(jbd): Trace view incorrectly displays graph executions. We are not
+ // changing the EventLog interface to fix this because the executor is being
+ // removed soon in favor of a linear execution model. We will adopt a different
+ // style of visualization once that's done.
+ return &TraceData{
+ ConversationID: convID,
+ RootExecID: rootExecID,
+ Execs: buildExecTraces(execIDs, events),
+ }, nil
+}
+
+func fetch(ctx context.Context, cfg *cliutil.Config, convID string) ([]*proto.ConversationEvent, string, []string, error) {
+ evLog, err := eventlog.OpenSQLiteEventLog(cfg.EventLog.SQLiteConfig.Filename)
+ if err != nil {
+ return nil, "", nil, fmt.Errorf("could not open sqlite eventlog: %w", err)
+ }
+ defer evLog.Close()
+
+ convEvents, err := evLog.Events(ctx, convID)
+ if err != nil {
+ return nil, "", nil, fmt.Errorf("failed to query conversation events: %w", err)
+ }
+
+ var execIDs []string
+ seen := make(map[string]bool)
+ for _, ev := range convEvents {
+ if ev.ExecId != "" && !seen[ev.ExecId] {
+ execIDs = append(execIDs, ev.ExecId)
+ seen[ev.ExecId] = true
+ }
+ }
+
+ if len(execIDs) == 0 {
+ return nil, "", nil, fmt.Errorf("no executions found for conversation: %s", convID)
+ }
+
+ // Use the first execID as the rootExecID as requested by user
+ rootExecID := execIDs[0]
+
+ return convEvents, rootExecID, execIDs, nil
+}
+
+func buildExecTraces(execIDs []string, events []*proto.ConversationEvent) []ExecTrace {
+ execsMap := make(map[string][]ExecutionEvent)
+ harnessIDs := make(map[string]string)
+
+ for _, protoEv := range events {
+ exID := protoEv.ExecId
+ if protoEv.HarnessId != "" {
+ harnessIDs[exID] = protoEv.HarnessId
+ }
+ ev := extractExecutionEvent(exID, protoEv)
+ execsMap[exID] = append(execsMap[exID], ev)
+ }
+
+ var execs []ExecTrace
+ for _, execID := range execIDs {
+ if evs, ok := execsMap[execID]; ok {
+ agentID := harnessIDs[execID]
+ execs = append(execs, ExecTrace{
+ ExecID: execID,
+ AgentID: agentID,
+ Events: evs,
+ })
+ }
+ }
+
+ return execs
+}
+
+func extractMsgs(protoContents []*proto.Message) []Content {
+ var results []Content
+ for _, c := range protoContents {
+ content := Content{Role: c.Role}
+ msgContent := c.GetContent()
+ if msgContent == nil {
+ continue
+ }
+ if textC := msgContent.GetText(); textC != nil {
+ content.Text = &Text{Text: textC.Text}
+ } else if conf := msgContent.GetConfirmation(); conf != nil {
+ content.Confirmation = &Confirmation{
+ ID: conf.Id,
+ Question: conf.Question,
+ }
+ if app := conf.GetApproval(); app != nil {
+ content.Confirmation.Approval = &Approval{Approved: app.Approved}
+ } else if dec := conf.GetDecline(); dec != nil {
+ content.Confirmation.Approval = &Approval{Approved: !dec.Declined}
+ }
+ }
+ results = append(results, content)
+ }
+ return results
+}
+
+func extractExecutionEvent(execID string, protoEv *proto.ConversationEvent) ExecutionEvent {
+ ev := ExecutionEvent{
+ ExecID: execID,
+ AgentID: protoEv.HarnessId,
+ }
+
+ ev.State = fmt.Sprint(protoEv.State)
+ if protoEv.HarnessId != "" {
+ ev.Inputs = extractMsgs(protoEv.Messages)
+ } else {
+ ev.Outputs = extractMsgs(protoEv.Messages)
+ }
+
+ return ev
+}
diff --git a/cmd/ax/dashboard_test.go b/cmd/ax/dashboard_test.go
new file mode 100644
index 00000000..df412296
--- /dev/null
+++ b/cmd/ax/dashboard_test.go
@@ -0,0 +1,139 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "context"
+ "database/sql"
+ "testing"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+func TestFetchConversations(t *testing.T) {
+ // Create an in-memory SQLite database for testing
+ db, err := sql.Open("sqlite", ":memory:")
+ if err != nil {
+ t.Fatalf("failed to open in-memory db: %v", err)
+ }
+ defer db.Close()
+
+ // Initialize the schema
+ schema := `
+ CREATE TABLE conversation_log (
+ conversation_id TEXT,
+ seq INTEGER,
+ timestamp DATETIME,
+ payload TEXT
+ );
+
+ CREATE TABLE execution_log (
+ exec_id TEXT,
+ timestamp DATETIME,
+ payload TEXT
+ );
+ `
+ if _, err := db.Exec(schema); err != nil {
+ t.Fatalf("failed to create schema: %v", err)
+ }
+
+ // Insert test data
+ // Conversation 1: Only conversation_log (V2 execution without execution_log)
+ _, err = db.Exec(`
+ INSERT INTO conversation_log (conversation_id, seq, timestamp, payload)
+ VALUES ('conv-1', 1, ?, '{"exec_id": "exec-1", "state": "STATE_PENDING"}')
+ `, time.Now().Format(time.RFC3339))
+ if err != nil {
+ t.Fatalf("failed to insert conv-1: %v", err)
+ }
+
+ // Conversation 2: Both conversation_log and execution_log (V1 execution)
+ now := time.Now()
+ start := now.Add(-5 * time.Second)
+ end := now
+
+ _, err = db.Exec(`
+ INSERT INTO conversation_log (conversation_id, seq, timestamp, payload)
+ VALUES ('conv-2', 5, ?, '{"exec_id": "exec-2", "state": "STATE_COMPLETED"}')
+ `, end.Format(time.RFC3339))
+ if err != nil {
+ t.Fatalf("failed to insert conv-2: %v", err)
+ }
+
+ _, err = db.Exec(`
+ INSERT INTO execution_log (exec_id, timestamp, payload)
+ VALUES
+ ('exec-2', ?, '{"agent_id": "my-agent"}'),
+ ('exec-2', ?, '{"agent_id": "my-agent"}')
+ `, start.Format(time.RFC3339), end.Format(time.RFC3339))
+ if err != nil {
+ t.Fatalf("failed to insert exec-2: %v", err)
+ }
+
+ // Fetch the conversations
+ ctx := context.Background()
+ convs, err := fetchConversations(ctx, db)
+ if err != nil {
+ t.Fatalf("fetchConversations failed: %v", err)
+ }
+
+ if len(convs) != 2 {
+ t.Fatalf("expected 2 conversations, got %d", len(convs))
+ }
+
+ // Create a map for easy lookup
+ convMap := make(map[string]ConversationResponse)
+ for _, c := range convs {
+ convMap[c.ID] = c
+ }
+
+ // Check conv-1
+ c1, ok := convMap["conv-1"]
+ if !ok {
+ t.Fatalf("conv-1 not found")
+ }
+ if c1.Status != "RUNNING" { // STATE_PENDING -> RUNNING
+ t.Errorf("conv-1 expected status RUNNING, got %q", c1.Status)
+ }
+ if c1.Agent != "unknown" {
+ t.Errorf("conv-1 expected agent unknown, got %q", c1.Agent)
+ }
+ if c1.Duration != "N/A" {
+ t.Errorf("conv-1 expected duration N/A, got %q", c1.Duration)
+ }
+ if c1.LastSeq != 1 {
+ t.Errorf("conv-1 expected last_seq 1, got %d", c1.LastSeq)
+ }
+
+ // Check conv-2
+ c2, ok := convMap["conv-2"]
+ if !ok {
+ t.Fatalf("conv-2 not found")
+ }
+ if c2.Status != "COMPLETED" { // STATE_COMPLETED -> COMPLETED
+ t.Errorf("conv-2 expected status COMPLETED, got %q", c2.Status)
+ }
+ if c2.Agent != "my-agent" {
+ t.Errorf("conv-2 expected agent my-agent, got %q", c2.Agent)
+ }
+ // Duration should be roughly 5.0s
+ if c2.Duration != "5.0s" {
+ t.Errorf("conv-2 expected duration 5.0s, got %q", c2.Duration)
+ }
+ if c2.LastSeq != 5 {
+ t.Errorf("conv-2 expected last_seq 5, got %d", c2.LastSeq)
+ }
+}
diff --git a/cmd/ax/exec.go b/cmd/ax/exec.go
new file mode 100644
index 00000000..947b6b18
--- /dev/null
+++ b/cmd/ax/exec.go
@@ -0,0 +1,476 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "io"
+ "os"
+ "os/signal"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+
+ "github.com/google/ax/cmd/ax/internal"
+ "github.com/google/ax/cmd/ax/internal/cliutil"
+ "github.com/google/ax/proto"
+ "github.com/google/uuid"
+ "github.com/spf13/cobra"
+)
+
+var (
+ execConversationID string
+ execHarnessID string
+ execHarnessConfig string
+ execHarnessConfigJSON string
+ execInput string
+ execServerAddr string
+ execConfigFile string
+ execResume bool // allow resuming an execution without inputs
+ execLastSeq int32
+)
+
+var execCmd = &cobra.Command{
+ Use: "exec",
+ Short: "Execute a conversation or resume an existing one",
+ Long: `Execute a new conversation or resume an existing one.
+If no conversation ID is provided, a new UUID will be generated.`,
+ SilenceUsage: true,
+ RunE: runExec,
+}
+
+func init() {
+ execCmd.Flags().StringVar(&execConversationID, "conversation", "", "Conversation ID (optional, generates UUID if not provided)")
+ execCmd.Flags().StringVar(&execHarnessID, "harness", "", "Harness ID (optional, default harness is used if not specified)")
+ execCmd.Flags().StringVar(&execHarnessConfig, "harness-config", "", "Path to a JSON file with per-request harness configuration")
+ execCmd.Flags().StringVar(&execHarnessConfigJSON, "harness-config-json", "", "Per-request harness configuration as an inline JSON string (mutually exclusive with --harness-config)")
+ execCmd.Flags().StringVar(&execInput, "input", "", "Input message to send (optional)")
+ execCmd.Flags().StringVar(&execServerAddr, "server", "", "gRPC controller server address (if specified, connects to remote server; otherwise runs with a local built-in AX server)")
+ execCmd.Flags().StringVar(&execConfigFile, "config", "ax.yaml", "Path to YAML configuration file (only used with a local built-in AX server)")
+ execCmd.Flags().BoolVar(&execResume, "resume", false, "Resume a conversation without inputs")
+ execCmd.Flags().Int32Var(&execLastSeq, "last-seq", 0, "Last sequence number seen by the client")
+ execCmd.MarkFlagsMutuallyExclusive("input", "resume")
+ execCmd.MarkFlagsMutuallyExclusive("harness-config", "harness-config-json")
+}
+
+// TODO(jbd): Add multimodal input flags, e.g. --input-image.
+
+var (
+ // The concrete type is *controller.Controller
+ execController cliutil.Controller
+ interruptHandler = NewInterruptHandler()
+)
+
+func runExec(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+
+ if execConversationID == "" {
+ execConversationID = uuid.NewString()
+ }
+
+ // Setup signal handling for graceful shutdown
+ ctx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ sigChan := make(chan os.Signal, 2) // Buffer to not miss signals
+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
+
+ go func() {
+ for {
+ sig := <-sigChan
+ if sig == syscall.SIGTERM {
+ fmt.Println("\nReceived SIGTERM, exiting...")
+ interruptHandler.exit()
+ }
+
+ if !interruptHandler.TriggerCancel() {
+ if interruptHandler.HandleInterrupt() {
+ fmt.Println("\nExiting...")
+ interruptHandler.exit()
+ }
+ }
+ }
+ }()
+
+ if execServerAddr == "" {
+ cfg, err := newConfig(cmd, execConfigFile)
+ if err != nil {
+ return err
+ }
+ // Validate configuration (matches `ax serve`).
+ if err := cfg.Validate(); err != nil {
+ return fmt.Errorf("invalid configuration: %w", err)
+ }
+ c, err := cliutil.NewControllerFromConfig(ctx, cfg)
+ if err != nil {
+ return fmt.Errorf("error creating controller: %w", err)
+ }
+ execController = c
+ }
+
+ var harnessConfig []byte
+ if execHarnessConfig != "" {
+ b, err := os.ReadFile(execHarnessConfig)
+ if err != nil {
+ return fmt.Errorf("failed to read harness config %q: %w", execHarnessConfig, err)
+ }
+ harnessConfig = b
+ } else if execHarnessConfigJSON != "" {
+ harnessConfig = []byte(execHarnessConfigJSON)
+ }
+
+ return execLoop(ctx, execConversationID, execHarnessID, harnessConfig, execInput, execLastSeq)
+}
+
+func execLoop(ctx context.Context, id string, harnessID string, harnessConfig []byte, input string, lastSeq int32) error {
+ d := internal.NewDisplay(id, os.Stdout)
+ d.DisplayHeader()
+
+ var inputs []*proto.Message
+ if !execResume {
+ var quit bool
+ var err error
+ input, harnessConfig, quit, err = promptUser(d, input, harnessConfig)
+ if err != nil {
+ return err
+ }
+ if quit {
+ return nil
+ }
+ inputs = []*proto.Message{
+ {
+ Role: "user",
+ Content: &proto.Content{
+ Type: &proto.Content_Text{
+ Text: &proto.TextContent{
+ Text: input,
+ },
+ },
+ },
+ },
+ }
+ }
+
+ for {
+ reqCtx, cancel := context.WithCancel(ctx)
+ interruptHandler.SetActiveCancel(cancel)
+
+ conf, err := runAutoExec(reqCtx, d, &proto.ExecRequest{
+ ConversationId: id,
+ HarnessId: harnessID,
+ HarnessConfig: harnessConfig,
+ Inputs: inputs,
+ LastSeq: lastSeq,
+ })
+ lastSeq = 0 // disable resuming from sequence, user sees the seq on the screen
+
+ interruptHandler.ClearActiveCancel()
+ cancel()
+
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ fmt.Println("Request canceled.")
+ inputs = nil
+ continue
+ }
+ return err
+ }
+
+ if conf != nil {
+ for {
+ approved, err := d.PromptForApproval(conf.Question)
+ if err != nil {
+ if errors.Is(err, internal.ErrUserAborted) {
+ if interruptHandler.HandleInterrupt() {
+ return nil
+ }
+ continue
+ }
+ return err
+ }
+ var decision []*proto.Message
+ if approved {
+ decision = []*proto.Message{{
+ Role: "user",
+ Content: &proto.Content{
+ Type: &proto.Content_Confirmation{
+ Confirmation: &proto.ConfirmationContent{
+ Id: conf.Id,
+ Decision: &proto.ConfirmationContent_Approval{
+ Approval: &proto.ApprovalDecision{Approved: true},
+ },
+ },
+ },
+ },
+ }}
+ } else {
+ decision = []*proto.Message{{
+ Role: "user",
+ Content: &proto.Content{
+ Type: &proto.Content_Confirmation{
+ Confirmation: &proto.ConfirmationContent{
+ Id: conf.Id,
+ Decision: &proto.ConfirmationContent_Decline{
+ Decline: &proto.DeclineDecision{Declined: true},
+ },
+ },
+ },
+ },
+ }}
+ }
+
+ reqCtx, cancel := context.WithCancel(ctx)
+ interruptHandler.SetActiveCancel(cancel)
+
+ conf, err = runAutoExec(reqCtx, d, &proto.ExecRequest{
+ ConversationId: id,
+ HarnessId: harnessID,
+ HarnessConfig: harnessConfig,
+ Inputs: decision,
+ })
+
+ interruptHandler.ClearActiveCancel()
+ cancel()
+
+ if err != nil {
+ if errors.Is(err, context.Canceled) {
+ fmt.Println("Request canceled.")
+ break
+ }
+ return err
+ }
+ if conf == nil {
+ break
+ }
+ }
+ }
+
+ // Per-request config: clear the config after each turn.
+ harnessConfig = nil
+
+ var quit bool
+ input, harnessConfig, quit, err = promptUser(d, "", harnessConfig)
+ if err != nil {
+ return err
+ }
+ if quit {
+ return nil
+ }
+
+ inputs = []*proto.Message{
+ {
+ Role: "user",
+ Content: &proto.Content{
+ Type: &proto.Content_Text{
+ Text: &proto.TextContent{
+ Text: input,
+ },
+ },
+ },
+ },
+ }
+ }
+}
+
+func runAutoExec(ctx context.Context, d *internal.Display, req *proto.ExecRequest) (*proto.ConfirmationContent, error) {
+ fn := runExecHeadless
+ if execServerAddr != "" {
+ fn = runExecServer
+ }
+ return fn(ctx, d, req)
+}
+
+func runExecHeadless(ctx context.Context, d *internal.Display, req *proto.ExecRequest) (*proto.ConfirmationContent, error) {
+ var confirmation *proto.ConfirmationContent
+ var lastSeq int32
+ outputHandler := cliutil.ExecHandler(func(resp *proto.ExecResponse) error {
+ for _, m := range resp.Outputs {
+ if conf := m.GetContent().GetConfirmation(); conf != nil {
+ confirmation = conf
+ }
+ }
+ lastSeq = resp.Seq
+ displayContents(d, resp.Outputs)
+ return nil
+ })
+ if err := execController.Exec(ctx, req, outputHandler); err != nil {
+ return nil, fmt.Errorf("error executing with local server: %w", err)
+ }
+
+ if confirmation == nil {
+ d.FinishOutput(fmt.Sprintf("seq=%d", lastSeq))
+ }
+ return confirmation, nil
+}
+
+func runExecServer(ctx context.Context, d *internal.Display, req *proto.ExecRequest) (*proto.ConfirmationContent, error) {
+ conn, err := connect(execServerAddr)
+ if err != nil {
+ return nil, err
+ }
+ defer conn.Close()
+
+ client := proto.NewExecutionServiceClient(conn)
+ stream, err := client.Exec(ctx, req)
+ if err != nil {
+ return nil, fmt.Errorf("error executing: %w", err)
+ }
+
+ var confirmation *proto.ConfirmationContent
+ var lastSeq int32
+ for {
+ resp, err := stream.Recv()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return nil, fmt.Errorf("error receiving response: %w", err)
+ }
+ lastSeq = resp.Seq
+ if resp.Outputs != nil {
+ for _, m := range resp.Outputs {
+ if conf := m.GetContent().GetConfirmation(); conf != nil {
+ confirmation = conf
+ }
+ }
+ displayContents(d, resp.Outputs)
+ }
+ }
+ if confirmation == nil {
+ d.FinishOutput(fmt.Sprintf("seq=%d", lastSeq))
+ }
+ return confirmation, nil
+}
+
+func displayContents(d *internal.Display, contents []*proto.Message) {
+ for _, output := range contents {
+ if content := output.GetContent(); content != nil {
+ d.Display(content)
+ }
+ }
+}
+
+// promptUser loops until the user provides a non-empty input string.
+// The "/config" command opens the harness config menu.
+// It returns:
+// - string: the valid user input
+// - []byte: the (possibly updated) harness config
+// - bool: true if the user entered a quit command
+// - error: any error that occurred during prompting
+func promptUser(d *internal.Display, input string, harnessConfig []byte) (string, []byte, bool, error) {
+ for {
+ for strings.TrimSpace(input) == "" {
+ var err error
+ input, err = d.PromptForInput()
+ if err != nil {
+ if errors.Is(err, internal.ErrUserAborted) {
+ if interruptHandler.HandleInterrupt() {
+ return "", harnessConfig, true, nil
+ }
+ input = "" // Continue loop to prompt again
+ continue
+ }
+ return "", harnessConfig, false, err
+ }
+ }
+
+ trimmed := strings.TrimSpace(input)
+ if trimmed == "/config" {
+ cfg, err := runConfigMenu(d, harnessConfig)
+ if err != nil {
+ return "", harnessConfig, false, err
+ }
+ harnessConfig = cfg
+ input = "" // Re-prompt after handling the config.
+ continue
+ }
+
+ d.DisplayInput(input)
+ if strings.ToLower(trimmed) == "q" {
+ d.ShowResumption(execConversationID, execServerAddr)
+ return "", harnessConfig, true, nil
+ }
+ return input, harnessConfig, false, nil
+ }
+}
+
+// InterruptHandler encapsulates the cancellation and signal handling state.
+type InterruptHandler struct {
+ mu sync.Mutex
+ activeCancel context.CancelFunc
+ interruptCount int32
+}
+
+// NewInterruptHandler creates a new InterruptHandler.
+func NewInterruptHandler() *InterruptHandler {
+ return &InterruptHandler{}
+}
+
+// SetActiveCancel sets the active cancel function.
+func (h *InterruptHandler) SetActiveCancel(cancel context.CancelFunc) {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.activeCancel = cancel
+}
+
+// ClearActiveCancel clears the active cancel function.
+func (h *InterruptHandler) ClearActiveCancel() {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.activeCancel = nil
+}
+
+// HandleInterrupt increments the interrupt count and returns true if the process should exit.
+// It also starts a timer to reset the count.
+func (h *InterruptHandler) HandleInterrupt() bool {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.interruptCount++
+ if h.interruptCount == 1 {
+ fmt.Println("\nPress Ctrl+C again to exit.")
+ go func() {
+ time.Sleep(2 * time.Second)
+ h.mu.Lock()
+ h.interruptCount = 0
+ h.mu.Unlock()
+ }()
+ return false
+ }
+ return true
+}
+
+// TriggerCancel triggers cancellation if there is an active cancel function.
+// It returns true if it triggered cancellation, or false if there was no active cancellation.
+func (h *InterruptHandler) TriggerCancel() bool {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ if h.activeCancel != nil {
+ fmt.Println("\nCanceling current request...")
+ h.activeCancel()
+ return true
+ }
+ return false
+}
+
+// exit gracefully shuts down the controller (if any) and terminates the process.
+func (h *InterruptHandler) exit() {
+ if execController != nil {
+ execController.Close()
+ }
+ os.Exit(1)
+}
diff --git a/cmd/ax/harness.go b/cmd/ax/harness.go
new file mode 100644
index 00000000..c028007b
--- /dev/null
+++ b/cmd/ax/harness.go
@@ -0,0 +1,215 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package main: the `ax harness` command. It runs one harness in-process,
+// selected by the optional [harness-id] argument: "antigravity" (default)
+// or "antigravity-interactions"
+package main
+
+import (
+ "context"
+ "encoding/base64"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "strconv"
+ "syscall"
+ "time"
+
+ "github.com/google/ax/internal/config"
+ "github.com/google/ax/internal/harness/antigravity"
+ "github.com/google/ax/internal/harness/antigravityinteractions"
+ "github.com/google/ax/internal/pythonsidecar"
+ "github.com/spf13/cobra"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+ healthpb "google.golang.org/grpc/health/grpc_health_v1"
+)
+
+var (
+ harnessPort int
+ harnessHost string
+)
+
+// harnessReadyzPort is the port for the HTTP /readyz readiness endpoint.
+const harnessReadyzPort = 8081
+
+var harnessCmd = &cobra.Command{
+ Use: "harness [harness-id]",
+ Short: "Run the harness gRPC server",
+ Args: cobra.MaximumNArgs(1),
+ Hidden: true,
+ RunE: runHarness,
+}
+
+func init() {
+ harnessCmd.Flags().IntVar(&harnessPort, "port", 50053, "Port for the HarnessService to listen on")
+ harnessCmd.Flags().StringVar(&harnessHost, "host", "127.0.0.1", "Host interface for the HarnessService to bind")
+ rootCmd.AddCommand(harnessCmd)
+}
+
+// setHarnessWorkDir changes the process working directory to AX_HARNESS_WORKDIR
+// when it is set. The forked Python sidecar inherits it, which scopes the agent's
+// default workspace (os.getcwd()) away from its own source tree.
+func setHarnessWorkDir() error {
+ dir := os.Getenv("AX_HARNESS_WORKDIR")
+ if dir == "" {
+ return nil
+ }
+ if err := os.Chdir(dir); err != nil {
+ return fmt.Errorf("set harness working directory %q: %w", dir, err)
+ }
+ log.Printf("harness working directory set to %s", dir)
+ return nil
+}
+
+// runHarness runs the harness selected by the optional [harness-id] argument:
+// "antigravity" (default) or "antigravity-interactions".
+func runHarness(cmd *cobra.Command, args []string) error {
+ harnessID := config.AntigravityHarnessID
+ if len(args) > 0 {
+ harnessID = args[0]
+ }
+ switch harnessID {
+ case config.AntigravityInteractionsHarnessID:
+ return runAntigravityInteractionsHarness(cmd.Context())
+ case config.AntigravityHarnessID:
+ return runAntigravityHarness(cmd)
+ default:
+ return fmt.Errorf("unknown harness %q (want %q or %q)",
+ harnessID, config.AntigravityHarnessID, config.AntigravityInteractionsHarnessID)
+ }
+}
+
+// runAntigravityHarness forks the Antigravity Python sidecar server, which serves
+// the HarnessService (and gRPC health) on the configured port. ax harness
+// supervises the child: it forwards termination signals and exits with its status.
+func runAntigravityHarness(cmd *cobra.Command) error {
+ if err := setHarnessWorkDir(); err != nil {
+ return err
+ }
+
+ stateDir, err := antigravity.DefaultStateDir()
+ if err != nil {
+ return fmt.Errorf("failed to resolve antigravity state dir: %w", err)
+ }
+
+ cfg := pythonsidecar.Config{
+ Module: "python.antigravity.harness_server",
+ Args: []string{
+ "--host", harnessHost,
+ "--port", strconv.Itoa(harnessPort),
+ "--state-dir", stateDir,
+ },
+ Stdout: os.Stdout,
+ Stderr: os.Stderr,
+ ReadyFunc: pythonsidecar.TCPReady(net.JoinHostPort("127.0.0.1", strconv.Itoa(harnessPort))),
+ }
+
+ sidecar := pythonsidecar.New(cfg)
+ if err := sidecar.Start(cmd.Context(), ""); err != nil {
+ return fmt.Errorf("failed to start antigravity harness server: %w", err)
+ }
+ log.Printf("forked antigravity harness server (pid %d) on %s:%d", sidecar.Pid(), harnessHost, harnessPort)
+
+ // Serve the /readyz endpoint that substrate's readiness probe polls (during
+ // golden snapshotting and per-actor Run/Restore).
+ go serveReadyz(harnessReadyzPort, harnessPort)
+
+ // Forward termination signals to the child so substrate can stop the actor.
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
+ go func() {
+ <-sigChan
+ _ = sidecar.Stop()
+ }()
+
+ if err := sidecar.Wait(); err != nil {
+ return fmt.Errorf("antigravity harness server exited: %w", err)
+ }
+ return nil
+}
+
+// runAntigravityInteractionsHarness runs the Go Antigravity Interactions harness
+// server (HarnessService + gRPC health + HTTP /readyz) on the configured ports.
+func runAntigravityInteractionsHarness(ctx context.Context) error {
+ if err := setHarnessWorkDir(); err != nil {
+ return err
+ }
+
+ // Read harness config from ax.yaml handed to the actor via AX_CONFIG_CONTENT.
+ // Fall back to built-in defaults.
+ var hc config.AntigravityInteractionsHarnessConfig
+ if raw := os.Getenv("AX_CONFIG_CONTENT"); raw != "" {
+ if data, err := base64.StdEncoding.DecodeString(raw); err != nil {
+ log.Printf("AX_CONFIG_CONTENT: base64 decode failed, using defaults: %v", err)
+ } else if cfg, err := config.LoadFromBytes(data); err != nil {
+ log.Printf("AX_CONFIG_CONTENT: parse failed, using defaults: %v", err)
+ } else {
+ hc = cfg.Harnesses.AntigravityInteractions
+ }
+ }
+ agent := hc.Agent
+ if agent == "" {
+ agent = antigravityinteractions.DefaultAgent
+ }
+ // AX owns the resume-cursor path (a harness implementation detail), so it's
+ // derived internally rather than read from ax.yaml.
+ stateDir, err := antigravityinteractions.DefaultStateDir()
+ if err != nil {
+ return err
+ }
+
+ cfg := antigravityinteractions.AntigravityInteractionsConfig{
+ Agent: agent,
+ StateDir: stateDir,
+ }
+ return antigravityinteractions.Serve(ctx, cfg, harnessHost, harnessPort, harnessReadyzPort)
+}
+
+// serveReadyz serves the HTTP /readyz endpoint on readyzPort that substrate's
+// readiness probe polls (during golden snapshotting and per-actor Run/Restore).
+// Each request forwards to the forked harness's gRPC health Check.
+func serveReadyz(readyzPort, grpcPort int) {
+ conn, err := grpc.NewClient(
+ net.JoinHostPort("127.0.0.1", strconv.Itoa(grpcPort)),
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ )
+ if err != nil {
+ log.Printf("readyz: failed to create gRPC health client: %v", err)
+ return
+ }
+ healthClient := healthpb.NewHealthClient(conn)
+
+ mux := http.NewServeMux()
+ mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
+ ctx, cancel := context.WithTimeout(r.Context(), time.Second)
+ defer cancel()
+ resp, err := healthClient.Check(ctx, &healthpb.HealthCheckRequest{})
+ if err != nil || resp.GetStatus() != healthpb.HealthCheckResponse_SERVING {
+ http.Error(w, "not ready", http.StatusServiceUnavailable)
+ return
+ }
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok\n"))
+ })
+
+ addr := net.JoinHostPort(harnessHost, strconv.Itoa(readyzPort))
+ if err := http.ListenAndServe(addr, mux); err != nil {
+ log.Printf("readyz server on %s exited: %v", addr, err)
+ }
+}
diff --git a/cmd/ax/harness_test.go b/cmd/ax/harness_test.go
new file mode 100644
index 00000000..599f5abb
--- /dev/null
+++ b/cmd/ax/harness_test.go
@@ -0,0 +1,128 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "fmt"
+ "net"
+ "net/http"
+ "os"
+ "path/filepath"
+ "testing"
+ "time"
+
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/health"
+ healthpb "google.golang.org/grpc/health/grpc_health_v1"
+)
+
+func TestSetHarnessWorkDir(t *testing.T) {
+ // os.Chdir is global process state; save and restore it around the test.
+ orig, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ t.Cleanup(func() { _ = os.Chdir(orig) })
+
+ t.Run("unset leaves the working directory unchanged", func(t *testing.T) {
+ t.Setenv("AX_HARNESS_WORKDIR", "")
+ before, _ := os.Getwd()
+ if err := setHarnessWorkDir(); err != nil {
+ t.Fatalf("setHarnessWorkDir: %v", err)
+ }
+ after, _ := os.Getwd()
+ if before != after {
+ t.Errorf("working directory changed: %q -> %q", before, after)
+ }
+ })
+
+ t.Run("set changes the working directory", func(t *testing.T) {
+ dir := t.TempDir()
+ t.Setenv("AX_HARNESS_WORKDIR", dir)
+ if err := setHarnessWorkDir(); err != nil {
+ t.Fatalf("setHarnessWorkDir: %v", err)
+ }
+ got, err := os.Getwd()
+ if err != nil {
+ t.Fatalf("getwd: %v", err)
+ }
+ want, _ := filepath.EvalSymlinks(dir)
+ gotResolved, _ := filepath.EvalSymlinks(got)
+ if gotResolved != want {
+ t.Errorf("working directory = %q, want %q", gotResolved, want)
+ }
+ })
+
+ t.Run("missing directory returns an error", func(t *testing.T) {
+ t.Setenv("AX_HARNESS_WORKDIR", filepath.Join(t.TempDir(), "does-not-exist"))
+ if err := setHarnessWorkDir(); err == nil {
+ t.Error("expected an error for a missing directory, got nil")
+ }
+ })
+}
+
+func TestServeReadyz(t *testing.T) {
+ // Start a real gRPC server exposing the standard health service; this stands
+ // in for the Antigravity Python harness that serveReadyz probes.
+ grpcListener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen for gRPC: %v", err)
+ }
+ healthServer := health.NewServer()
+ grpcServer := grpc.NewServer()
+ healthpb.RegisterHealthServer(grpcServer, healthServer)
+ go func() { _ = grpcServer.Serve(grpcListener) }()
+ t.Cleanup(grpcServer.Stop)
+ grpcPort := grpcListener.Addr().(*net.TCPAddr).Port
+
+ // Reserve an ephemeral port for /readyz, then hand it to serveReadyz to bind.
+ readyzListener, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("listen for readyz: %v", err)
+ }
+ readyzPort := readyzListener.Addr().(*net.TCPAddr).Port
+ _ = readyzListener.Close()
+
+ // Start with the harness not serving, then run serveReadyz against it.
+ healthServer.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING)
+ go serveReadyz(readyzPort, grpcPort)
+ url := fmt.Sprintf("http://127.0.0.1:%d/readyz", readyzPort)
+
+ // /readyz re-derives readiness from gRPC health on every request: 503 until
+ // the harness is SERVING, 200 once it is, and back to 503 if it stops serving.
+ waitReadyz(t, url, http.StatusServiceUnavailable)
+ healthServer.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
+ waitReadyz(t, url, http.StatusOK)
+ healthServer.SetServingStatus("", healthpb.HealthCheckResponse_NOT_SERVING)
+ waitReadyz(t, url, http.StatusServiceUnavailable)
+}
+
+func waitReadyz(t *testing.T, url string, want int) {
+ t.Helper()
+ deadline := time.Now().Add(5 * time.Second)
+ got := 0
+ for time.Now().Before(deadline) {
+ resp, err := http.Get(url)
+ if err == nil {
+ got = resp.StatusCode
+ _ = resp.Body.Close()
+ if got == want {
+ return
+ }
+ }
+ time.Sleep(10 * time.Millisecond)
+ }
+ t.Fatalf("GET %s = %d, want %d", url, got, want)
+}
diff --git a/cmd/ax/harnessclient.go b/cmd/ax/harnessclient.go
new file mode 100644
index 00000000..dc0f778e
--- /dev/null
+++ b/cmd/ax/harnessclient.go
@@ -0,0 +1,130 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package main implements a simple client for the fake HarnessService.
+// It is intended for testing purposes only and should be replaced with
+// the actual ax client implementation.
+// TODO(wjjclaud): Update or replace this file with ax client implementation.
+package main
+
+import (
+ "bufio"
+ "context"
+ "fmt"
+ "io"
+ "log"
+ "os"
+
+ "github.com/google/ax/proto"
+ "github.com/google/uuid"
+ "github.com/spf13/cobra"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+)
+
+var (
+ harnessServerAddr string
+ harnessClientID string
+)
+
+var harnessClientCmd = &cobra.Command{
+ Use: "harnessclient",
+ Short: "Run the harness client to connect to the server",
+ Hidden: true,
+ RunE: runHarnessClient,
+}
+
+func init() {
+ harnessClientCmd.Flags().StringVar(&harnessServerAddr, "server", "localhost:50053", "The server address for the gRPC HarnessService.")
+ harnessClientCmd.Flags().StringVar(&harnessClientID, "harness", "testharness", "The harness id to send on the request envelope.")
+ rootCmd.AddCommand(harnessClientCmd)
+}
+
+func runHarnessClient(cmd *cobra.Command, args []string) error {
+ ctx := context.Background()
+
+ log.Printf("Connecting to HarnessService at %s...", harnessServerAddr)
+ conn, err := grpc.NewClient(harnessServerAddr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ if err != nil {
+ return fmt.Errorf("failed to connect to server: %v", err)
+ }
+ defer conn.Close()
+
+ client := proto.NewHarnessServiceClient(conn)
+
+ fmt.Print("Client > ")
+ scanner := bufio.NewScanner(os.Stdin)
+ scanner.Scan()
+ input := scanner.Text()
+
+ stream, err := client.Connect(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to open connection stream: %v", err)
+ }
+
+ // A single HarnessRequest{start} initiates the turn.
+ start := &proto.HarnessRequest{
+ ConversationId: uuid.NewString(),
+ HarnessId: harnessClientID,
+ Type: &proto.HarnessRequest_Start{
+ Start: &proto.HarnessStart{
+ Messages: []*proto.Message{
+ {
+ Role: "user",
+ Content: &proto.Content{
+ Type: &proto.Content_Text{Text: &proto.TextContent{Text: input}},
+ },
+ },
+ },
+ },
+ },
+ }
+ if err := stream.Send(start); err != nil {
+ return fmt.Errorf("failed to send start: %v", err)
+ }
+ if err := stream.CloseSend(); err != nil {
+ return fmt.Errorf("failed to close send side: %v", err)
+ }
+
+ // Drain HarnessResponse frames until HarnessEnd / EOF.
+ for {
+ resp, err := stream.Recv()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("failed to receive response: %v", err)
+ }
+ switch payload := resp.Type.(type) {
+ case *proto.HarnessResponse_Outputs:
+ for i, m := range payload.Outputs.Messages {
+ var text string
+ if tb, ok := m.Content.Type.(*proto.Content_Text); ok {
+ text = tb.Text.Text
+ }
+ fmt.Printf("Server > message[%d] (%s): %s\n", i, m.Role, text)
+ }
+ case *proto.HarnessResponse_End:
+ if errDetail := payload.End.GetError(); errDetail != nil {
+ fmt.Printf("Server > [end] state=%s error=(code=%d description=%q)\n",
+ payload.End.GetState(), errDetail.GetCode(), errDetail.GetDescription())
+ } else {
+ fmt.Printf("Server > [end] state=%s\n", payload.End.GetState())
+ }
+ }
+ }
+
+ log.Println("Stream closed successfully by server.")
+ return nil
+}
diff --git a/cmd/ax/harnessconfig.go b/cmd/ax/harnessconfig.go
new file mode 100644
index 00000000..24492ab6
--- /dev/null
+++ b/cmd/ax/harnessconfig.go
@@ -0,0 +1,138 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "strings"
+
+ "github.com/google/ax/cmd/ax/internal"
+)
+
+// runConfigMenu shows the /config menu and returns the (possibly updated) config.
+// An updated config is sent on subsequent requests.
+func runConfigMenu(d *internal.Display, harnessConfig []byte) ([]byte, error) {
+ for {
+ action, err := d.PromptForConfigAction()
+ if err != nil {
+ if errors.Is(err, internal.ErrUserAborted) {
+ return harnessConfig, nil // Esc/Ctrl+C on the menu cancels /config.
+ }
+ return harnessConfig, err
+ }
+
+ switch action {
+ case "edit":
+ cfg, done, err := editHarnessConfig(d, harnessConfig)
+ if err != nil {
+ return harnessConfig, err
+ }
+ if done {
+ return cfg, nil
+ }
+ case "load":
+ cfg, done, err := loadHarnessConfig(d)
+ if err != nil {
+ return harnessConfig, err
+ }
+ if done {
+ return cfg, nil
+ }
+ default: // "cancel" or anything else
+ return harnessConfig, nil
+ }
+ }
+}
+
+// editHarnessConfig opens the JSON editor pre-filled with the current config. It
+// returns the updated config with done=true if the config was updated, or
+// done=false (config ignored) if the user cancelled back to the menu. Invalid
+// JSON is reported and the editor re-opens with the user's draft so they can fix
+// it.
+func editHarnessConfig(d *internal.Display, harnessConfig []byte) ([]byte, bool, error) {
+ draft := prettyHarnessConfig(harnessConfig)
+ for {
+ edited, err := d.PromptForConfigEdit(draft)
+ if err != nil {
+ if errors.Is(err, internal.ErrUserAborted) {
+ return nil, false, nil // Back to the menu.
+ }
+ return nil, false, err
+ }
+ normalized, err := normalizeHarnessConfigJSON(edited)
+ if err != nil {
+ d.ShowNotice(fmt.Sprintf("Invalid config: %v", err))
+ draft = edited // Preserve the user's input so they can fix it.
+ continue
+ }
+ return normalized, true, nil
+ }
+}
+
+// loadHarnessConfig lets the user pick a JSON file and loads it. It returns the
+// loaded config with done=true, or done=false (config ignored) if the user
+// cancelled back to the menu or the file could not be used.
+func loadHarnessConfig(d *internal.Display) ([]byte, bool, error) {
+ path, err := d.PromptForConfigFile()
+ if err != nil {
+ if errors.Is(err, internal.ErrUserAborted) {
+ return nil, false, nil // Back to the menu.
+ }
+ return nil, false, err
+ }
+ b, err := os.ReadFile(strings.TrimSpace(path))
+ if err != nil {
+ d.ShowNotice(fmt.Sprintf("Failed to read file: %v", err))
+ return nil, false, nil
+ }
+ normalized, err := normalizeHarnessConfigJSON(string(b))
+ if err != nil {
+ d.ShowNotice(fmt.Sprintf("Invalid config: %v", err))
+ return nil, false, nil
+ }
+ return normalized, true, nil
+}
+
+// normalizeHarnessConfigJSON trims and validates the given JSON config, returning
+// the bytes to send on the wire. Empty input clears the config (returns nil). The
+// config must be a JSON object.
+func normalizeHarnessConfigJSON(s string) ([]byte, error) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return nil, nil
+ }
+ var obj map[string]any
+ if err := json.Unmarshal([]byte(s), &obj); err != nil {
+ return nil, err
+ }
+ return []byte(s), nil
+}
+
+// prettyHarnessConfig returns an indented JSON rendering of the config bytes for
+// display, falling back to the raw bytes if they cannot be parsed.
+func prettyHarnessConfig(b []byte) string {
+ if len(b) == 0 {
+ return ""
+ }
+ var buf bytes.Buffer
+ if err := json.Indent(&buf, b, "", " "); err != nil {
+ return string(b)
+ }
+ return buf.String()
+}
diff --git a/cmd/ax/harnessconfig_test.go b/cmd/ax/harnessconfig_test.go
new file mode 100644
index 00000000..869759bf
--- /dev/null
+++ b/cmd/ax/harnessconfig_test.go
@@ -0,0 +1,81 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestNormalizeHarnessConfigJSON(t *testing.T) {
+ tests := []struct {
+ name string
+ in string
+ want string
+ wantErr bool
+ }{
+ {name: "empty", in: "", want: ""},
+ {name: "whitespace clears", in: " \n\t ", want: ""},
+ {name: "valid object", in: `{"model":"gemini"}`, want: `{"model":"gemini"}`},
+ {name: "trims surrounding whitespace", in: " {\"model\":\"gemini\"}\n", want: `{"model":"gemini"}`},
+ {name: "nested object", in: `{"a":{"b":1},"c":[1,2]}`, want: `{"a":{"b":1},"c":[1,2]}`},
+ {name: "invalid json", in: `{bad}`, wantErr: true},
+ {name: "non-object array", in: `[1,2,3]`, wantErr: true},
+ {name: "non-object scalar", in: `42`, wantErr: true},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, err := normalizeHarnessConfigJSON(tt.in)
+ if tt.wantErr {
+ if err == nil {
+ t.Fatalf("expected error, got nil (result %q)", got)
+ }
+ return
+ }
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if string(got) != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ if tt.want == "" && got != nil {
+ t.Errorf("expected nil for cleared config, got %q", got)
+ }
+ })
+ }
+}
+
+func TestPrettyHarnessConfig(t *testing.T) {
+ if got := prettyHarnessConfig(nil); got != "" {
+ t.Errorf("nil: got %q, want empty string", got)
+ }
+ if got := prettyHarnessConfig([]byte{}); got != "" {
+ t.Errorf("empty: got %q, want empty string", got)
+ }
+
+ // Invalid JSON falls back to the raw bytes.
+ if got := prettyHarnessConfig([]byte("not json")); got != "not json" {
+ t.Errorf("invalid: got %q, want raw passthrough", got)
+ }
+
+ // Valid JSON is rendered multi-line and indented.
+ got := prettyHarnessConfig([]byte(`{"model":"gemini"}`))
+ if !strings.Contains(got, "model") || !strings.Contains(got, "gemini") {
+ t.Errorf("valid: got %q, want it to contain the key and value", got)
+ }
+ if !strings.Contains(got, "\n") {
+ t.Errorf("valid: got %q, want indented multi-line output", got)
+ }
+}
diff --git a/cmd/ax/internal/cliutil/cliutil.go b/cmd/ax/internal/cliutil/cliutil.go
new file mode 100644
index 00000000..a5637d34
--- /dev/null
+++ b/cmd/ax/internal/cliutil/cliutil.go
@@ -0,0 +1,182 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cliutil
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/google/ax/internal/config"
+ "github.com/google/ax/internal/controller"
+ "github.com/google/ax/internal/controller/eventlog"
+ "github.com/google/ax/internal/harness"
+ "github.com/google/ax/internal/harness/antigravity"
+ "github.com/google/ax/internal/harness/antigravityinteractions"
+ "github.com/google/ax/internal/harness/substrate"
+ "github.com/google/ax/internal/skills/geminienterprise"
+)
+
+// Controller is the active controller type for this build.
+type Controller = *controller.Controller
+
+// ExecHandler is the handler type accepted by Controller.Exec.
+type ExecHandler = controller.ExecHandler
+
+// Config is the configuration type for this build.
+type Config = config.Config
+
+// LoadFromFile loads configuration from a YAML file.
+func LoadFromFile(path string) (*Config, error) {
+ return config.LoadFromFile(path)
+}
+
+// DefaultConfig returns a configuration with default values set.
+func DefaultConfig() *Config {
+ return config.DefaultConfig()
+}
+
+// NewControllerFromConfig creates a controller.Controller instance based on the provided configuration.
+func NewControllerFromConfig(ctx context.Context, cfg *Config) (*controller.Controller, error) {
+ reg := controller.NewRegistry()
+
+ // AX_SUBSTRATE selects how built-in harnesses run: locally (unset) or as
+ // substrate actors ("1").
+ substrateMode := os.Getenv("AX_SUBSTRATE") == "1"
+
+ // Validate config before local-mode antigravity.New forks the Python
+ // sidecar, so a config error surfaces without spawning a subprocess.
+ if len(cfg.Harnesses.Substrate) > 0 && !substrateMode {
+ return nil, fmt.Errorf("custom substrate harnesses require AX_SUBSTRATE=1")
+ }
+
+ var defaultHarnessID string
+ var err error
+
+ // Materialize registry skills once, up front (skills config is top-level and
+ // harness-agnostic; each actor runs a single harness that consumes the
+ // materialized folder). Unconditional when configured. Fail-safe: a registry
+ // error degrades capability but never blocks harness creation. The
+ // interactions harness (no SKILLS_DIR concept) is told where the materialized
+ // skills are via a pointer appended to its system instruction. Only the local
+ // path materializes; substrate/pod does not yet read ax.yaml.
+ //
+ // TODO(joycel): wire the Antigravity SDK harness too. It discovers skills via
+ // SKILLS_DIR, so its SKILLS_DIR needs to be pointed at the materialized
+ // target_dir; currently only the interactions harness is fully wired.
+ var skillsPointer string
+ if !substrateMode {
+ skillsPointer = antigravityinteractions.SkillsSystemInstruction(geminienterprise.Materialize(ctx, cfg.Skills))
+ }
+
+ // Built-in Antigravity harness.
+ var antigravityHarness harness.Harness
+ if !substrateMode {
+ address := cfg.Harnesses.Antigravity.Endpoint
+ if address == "" {
+ address = "127.0.0.1:50053"
+ }
+ // Local mode: the harness owns the Python sidecar. AX owns the
+ // trajectory storage path (a harness implementation detail), so it's
+ // derived internally rather than exposed in ax.yaml.
+ stateDir, sErr := antigravity.DefaultStateDir()
+ if sErr != nil {
+ return nil, fmt.Errorf("antigravity harness: %w", sErr)
+ }
+ antigravityHarness, err = antigravity.New(ctx, address, stateDir, true)
+ if err != nil {
+ return nil, fmt.Errorf("antigravity harness: %w", err)
+ }
+ } else {
+ antigravityHarness, err = substrate.New(config.AntigravityHarnessID, "", "", config.AntigravityHarnessTemplate, 80)
+ if err != nil {
+ return nil, fmt.Errorf("antigravity harness: %w", err)
+ }
+ }
+ if err := reg.RegisterHarness(config.AntigravityHarnessID, antigravityHarness); err != nil {
+ return nil, fmt.Errorf("register antigravity harness: %w", err)
+ }
+ if cfg.Harnesses.Antigravity.Default {
+ defaultHarnessID = config.AntigravityHarnessID
+ }
+
+ // Built-in Antigravity Interactions harness.
+ var antigravityInteractionsHarness harness.Harness
+ if !substrateMode {
+ aiCfg := cfg.Harnesses.AntigravityInteractions
+ agent := aiCfg.Agent
+ if agent == "" {
+ agent = antigravityinteractions.DefaultAgent
+ }
+ // AX owns the resume-cursor path (a harness implementation detail), so
+ // it's derived internally rather than exposed in ax.yaml.
+ stateDir, sErr := antigravityinteractions.DefaultStateDir()
+ if sErr != nil {
+ return nil, fmt.Errorf("antigravity-interactions harness: %w", sErr)
+ }
+ // skillsPointer was built once, up front, from the top-level skills
+ // config (see above). Append it to any configured system instruction.
+ antigravityInteractionsHarness, err = antigravityinteractions.New(antigravityinteractions.AntigravityInteractionsConfig{
+ Agent: agent,
+ SystemInstruction: antigravityinteractions.JoinSystemInstruction(aiCfg.SystemInstruction, skillsPointer),
+ StateDir: stateDir,
+ })
+ } else {
+ antigravityInteractionsHarness, err = substrate.New(config.AntigravityInteractionsHarnessID, "", "", config.AntigravityInteractionsTemplate, 80)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("antigravity-interactions harness: %w", err)
+ }
+ if err := reg.RegisterHarness(config.AntigravityInteractionsHarnessID, antigravityInteractionsHarness); err != nil {
+ return nil, fmt.Errorf("register antigravity-interactions harness: %w", err)
+ }
+ if cfg.Harnesses.AntigravityInteractions.Default {
+ defaultHarnessID = config.AntigravityInteractionsHarnessID
+ }
+
+ for _, sc := range cfg.Harnesses.Substrate {
+ h, err := sc.NewHarness("")
+ if err != nil {
+ return nil, fmt.Errorf("substrate harness %q: %w", sc.ID, err)
+ }
+ if err := reg.RegisterHarness(sc.ID, h); err != nil {
+ return nil, fmt.Errorf("register substrate harness %q: %w", sc.ID, err)
+ }
+ if sc.Default {
+ defaultHarnessID = sc.ID
+ }
+ }
+
+ // Set the default harness.
+ if defaultHarnessID != "" {
+ if err := reg.SetDefaultHarness(defaultHarnessID); err != nil {
+ return nil, fmt.Errorf("set default harness %q: %w", defaultHarnessID, err)
+ }
+ }
+
+ return controller.New(ctx, controller.Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) {
+ if cfg.EventLog.PostgresConfig.DSN != "" {
+ dsn := os.ExpandEnv(cfg.EventLog.PostgresConfig.DSN)
+ if dsn == "" {
+ return nil, fmt.Errorf("eventlog: postgres dsn %q expanded to empty", cfg.EventLog.PostgresConfig.DSN)
+ }
+ return eventlog.OpenPostgresEventLog(dsn)
+ }
+ return eventlog.OpenSQLiteEventLog(cfg.EventLog.SQLiteConfig.Filename)
+ },
+ })
+}
diff --git a/cmd/ax/internal/cliutil/cliutil_test.go b/cmd/ax/internal/cliutil/cliutil_test.go
new file mode 100644
index 00000000..31fe2e72
--- /dev/null
+++ b/cmd/ax/internal/cliutil/cliutil_test.go
@@ -0,0 +1,129 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package cliutil
+
+import (
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/google/ax/internal/config"
+)
+
+func TestNewControllerFromConfig_BuiltinSubstrate(t *testing.T) {
+ t.Setenv("AX_SUBSTRATE", "1")
+
+ cfg := &config.Config{
+ EventLog: config.EventLogConfig{
+ SQLiteConfig: config.SQLiteConfig{
+ Filename: filepath.Join(t.TempDir(), "log.sqlite"),
+ },
+ },
+ Harnesses: config.HarnessesConfig{
+ Antigravity: config.AntigravityHarnessConfig{
+ Default: true,
+ },
+ },
+ }
+
+ c, err := NewControllerFromConfig(context.Background(), cfg)
+ if err != nil {
+ t.Fatalf("NewControllerFromConfig: %v", err)
+ }
+ if c == nil {
+ t.Fatal("expected non-nil controller")
+ }
+ c.Close()
+}
+
+func TestNewControllerFromConfig_InteractionsSubstrate(t *testing.T) {
+ t.Setenv("AX_SUBSTRATE", "1")
+
+ cfg := &config.Config{
+ EventLog: config.EventLogConfig{
+ SQLiteConfig: config.SQLiteConfig{
+ Filename: filepath.Join(t.TempDir(), "log.sqlite"),
+ },
+ },
+ Harnesses: config.HarnessesConfig{
+ Antigravity: config.AntigravityHarnessConfig{Default: true},
+ },
+ }
+
+ c, err := NewControllerFromConfig(context.Background(), cfg)
+ if err != nil {
+ t.Fatalf("NewControllerFromConfig: %v", err)
+ }
+ if c == nil {
+ t.Fatal("expected non-nil controller")
+ }
+ defer c.Close()
+
+ if _, err := c.Registry().Harness(config.AntigravityInteractionsHarnessID); err != nil {
+ t.Errorf("interactions harness not registered in substrate mode: %v", err)
+ }
+}
+
+func TestNewControllerFromConfig_CustomHarnessRequiresSubstrateMode(t *testing.T) {
+ t.Setenv("AX_SUBSTRATE", "")
+
+ cfg := &config.Config{
+ EventLog: config.EventLogConfig{
+ SQLiteConfig: config.SQLiteConfig{
+ Filename: filepath.Join(t.TempDir(), "log.sqlite"),
+ },
+ },
+ Harnesses: config.HarnessesConfig{
+ Substrate: []config.SubstrateHarnessConfig{
+ {ID: "custom", Namespace: "team-ns", Template: "custom-template"},
+ },
+ },
+ }
+
+ _, err := NewControllerFromConfig(context.Background(), cfg)
+ if err == nil {
+ t.Fatal("expected error for custom substrate harness without AX_SUBSTRATE=1, got nil")
+ }
+ if !strings.Contains(err.Error(), "AX_SUBSTRATE=1") {
+ t.Errorf("expected error to mention AX_SUBSTRATE=1, got: %v", err)
+ }
+}
+
+func TestNewControllerFromConfig_CustomHarnessInSubstrateMode(t *testing.T) {
+ t.Setenv("AX_SUBSTRATE", "1")
+
+ cfg := &config.Config{
+ EventLog: config.EventLogConfig{
+ SQLiteConfig: config.SQLiteConfig{
+ Filename: filepath.Join(t.TempDir(), "log.sqlite"),
+ },
+ },
+ Harnesses: config.HarnessesConfig{
+ Substrate: []config.SubstrateHarnessConfig{
+ {ID: "custom", Namespace: "team-ns", Template: "custom-template"},
+ },
+ },
+ }
+
+ c, err := NewControllerFromConfig(context.Background(), cfg)
+ if err != nil {
+ t.Fatalf("NewControllerFromConfig: %v", err)
+ }
+ if c == nil {
+ t.Fatal("expected non-nil controller")
+ }
+ c.Close()
+}
diff --git a/cmd/ax/internal/display.go b/cmd/ax/internal/display.go
new file mode 100644
index 00000000..376e25bf
--- /dev/null
+++ b/cmd/ax/internal/display.go
@@ -0,0 +1,329 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package internal
+
+import (
+ "fmt"
+ "io"
+ "os"
+
+ "charm.land/bubbles/v2/key"
+ tea "charm.land/bubbletea/v2"
+ "charm.land/huh/v2"
+ "charm.land/lipgloss/v2"
+ "github.com/google/ax/proto"
+)
+
+const (
+ boxWidth = 100
+)
+
+var (
+ isDark = lipgloss.HasDarkBackground(os.Stdin, os.Stdout)
+ lightDark = lipgloss.LightDark(isDark)
+
+ purple = lightDark(lipgloss.Color("#5A56E0"), lipgloss.Color("#7571F9"))
+ comment = lightDark(lipgloss.Color("#a0a0a0"), lipgloss.Color("#6d6d6d"))
+)
+
+// ErrUserAborted is returned when the user aborts a prompt.
+var ErrUserAborted = huh.ErrUserAborted
+
+type displayState int
+
+const (
+ stateNone displayState = iota
+ stateText
+ stateThought
+)
+
+type Display struct {
+ id string
+ w io.Writer // Target output writer, e.g., os.Stdout or a test buffer
+
+ userStyle lipgloss.Style
+ checkpointStyle lipgloss.Style
+ idStyle lipgloss.Style
+ resumeStyle lipgloss.Style
+
+ state displayState // Tracks the last printed chunk type to correctly format transition newlines
+}
+
+func NewDisplay(id string, w io.Writer) *Display {
+ if w == nil {
+ w = os.Stdout
+ }
+ return &Display{
+ id: id,
+ w: w,
+ userStyle: lipgloss.NewStyle().Foreground(purple),
+ checkpointStyle: lipgloss.NewStyle().Foreground(comment),
+ idStyle: lipgloss.NewStyle().Foreground(comment),
+ resumeStyle: lipgloss.NewStyle().Foreground(comment),
+ state: stateNone,
+ }
+}
+
+// DisplayInput displays the user input.
+func (d *Display) DisplayInput(text string) {
+ if d.state != stateNone {
+ fmt.Fprintln(d.w)
+ }
+ d.state = stateNone
+ fmt.Fprintf(d.w, "%s %s\n",
+ d.userStyle.Render("⏺"),
+ text,
+ )
+ fmt.Fprintln(d.w)
+}
+
+// Display prints a content block according to its type.
+func (d *Display) Display(content *proto.Content) {
+ if content == nil {
+ return
+ }
+ switch o := content.Type.(type) {
+ case *proto.Content_Text:
+ if d.state == stateThought {
+ fmt.Fprintln(d.w) // end the thinking line
+ }
+ d.state = stateText
+ fmt.Fprint(d.w, o.Text.Text)
+
+ case *proto.Content_Confirmation:
+ // Let the confirmation prompt handle displaying the question.
+
+ case *proto.Content_ToolCall:
+ // Tool calls aren't rendered, but they mark a boundary between
+ // contiguous text/thought blocks. Terminate the current line so the
+ // next response starts fresh instead of running into the previous one
+ // (e.g. "...configured.I will list...").
+ if d.state != stateNone {
+ fmt.Fprintln(d.w)
+ d.state = stateNone
+ }
+
+ case *proto.Content_ToolResult:
+ // Only print if the tool returned an error, otherwise skip
+ tr := o.ToolResult
+ if fr := tr.GetFunctionResult(); fr != nil {
+ if fr.GetResponse() != nil {
+ respMap := fr.GetResponse().AsMap()
+ if errStr, ok := respMap["error"]; ok {
+ d.displaySystem(fmt.Sprintf("[TOOL ERROR for %s]\n%v", fr.Name, errStr))
+ }
+ }
+ }
+
+ case *proto.Content_Thought:
+ for _, summary := range o.Thought.GetSummary() {
+ if textContent := summary.GetText(); textContent != nil {
+ if d.state != stateThought {
+ if d.state == stateText {
+ fmt.Fprintln(d.w)
+ }
+ fmt.Fprint(d.w, "Thinking: ")
+ }
+ d.state = stateThought
+ fmt.Fprint(d.w, textContent.Text)
+ }
+ }
+
+ case *proto.Content_Image, *proto.Content_Audio, *proto.Content_Video, *proto.Content_Document:
+ d.displaySystem(fmt.Sprintf("unsupported output type for display: %T", o))
+
+ default:
+ d.displaySystem(fmt.Sprintf("unknown output type: %v", o))
+ }
+}
+
+// displaySystem prints a system/error message on a new line.
+func (d *Display) displaySystem(text string) {
+ if d.state != stateNone {
+ fmt.Fprintln(d.w)
+ }
+ d.state = stateNone
+ fmt.Fprintln(d.w, text)
+}
+
+// ShowNotice prints an informational message (e.g. the current harness config
+// or a validation error) on its own line.
+func (d *Display) ShowNotice(text string) {
+ d.displaySystem(text)
+}
+
+// FinishOutput completes the streaming output and shows info if provided
+func (d *Display) FinishOutput(info string) {
+ if d.state != stateNone {
+ fmt.Fprintln(d.w)
+ }
+ d.state = stateNone
+ if info != "" {
+ fmt.Fprintln(d.w, d.checkpointStyle.Render(info))
+ }
+ fmt.Fprintln(d.w)
+}
+
+func (d *Display) DisplayHeader() {
+ fmt.Fprintln(d.w, d.idStyle.Render("Conversation: "+d.id))
+ fmt.Fprintln(d.w)
+}
+
+// PromptForApproval shows an accept/reject dialog
+// Returns true if accepted, false if rejected, and an error if cancelled (Ctrl+C)
+func (d *Display) PromptForApproval(question string) (bool, error) {
+ var accepted bool
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewConfirm().
+ Title(question).
+ Affirmative("Accept").
+ Negative("Reject").
+ Value(&accepted),
+ ),
+ ).WithWidth(boxWidth)
+
+ if err := form.Run(); err != nil {
+ return false, err
+ }
+ return accepted, nil
+}
+
+// PromptForInput shows the input box and returns the user input
+// Returns the input string and an error if the user cancelled (Ctrl+C)
+func (d *Display) PromptForInput() (string, error) {
+ var userInput string
+
+ // Rebind tab to complete the suggestion and enter to submit.
+ keymap := huh.NewDefaultKeyMap()
+ keymap.Input.AcceptSuggestion = key.NewBinding(key.WithKeys("tab"), key.WithHelp("tab", "complete"))
+ keymap.Input.Next = key.NewBinding(key.WithKeys("enter"), key.WithHelp("enter", "submit"))
+
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewInput().
+ Placeholder("Enter prompt... (type `q` to quit)").
+ Suggestions([]string{"/config"}).
+ Value(&userInput),
+ ),
+ ).WithWidth(boxWidth).WithShowHelp(false).WithKeyMap(keymap)
+
+ if err := form.Run(); err != nil {
+ return "", err
+ }
+ return userInput, nil
+}
+
+// configKeyMap returns a huh keymap for the /config menu that cancels on esc in
+// addition to the default ctrl+c, and shows a consistent "esc close" hint in
+// every field's help footer.
+func configKeyMap() *huh.KeyMap {
+ km := huh.NewDefaultKeyMap()
+ km.Quit = key.NewBinding(key.WithKeys("ctrl+c", "esc"), key.WithHelp("esc", "close"))
+
+ // Surface the esc hint through a slot that stays enabled.
+ closeHint := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close"))
+ km.Select.Filter = closeHint
+ km.Text.Editor = closeHint
+ return km
+}
+
+// clearMenuOnCancel converts the interrupt huh emits on cancel (esc/ctrl+c) into
+// a graceful quit.
+var clearMenuOnCancel = tea.WithFilter(func(_ tea.Model, msg tea.Msg) tea.Msg {
+ if _, ok := msg.(tea.InterruptMsg); ok {
+ return tea.QuitMsg{}
+ }
+ return msg
+})
+
+// PromptForConfigAction shows the /config menu and returns the chosen action,
+// one of "edit", "load", or "cancel". It returns an error if the user cancelled
+// (Ctrl+C or Esc).
+func (d *Display) PromptForConfigAction() (string, error) {
+ var action string
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewSelect[string]().
+ Title("Harness config").
+ Options(
+ huh.NewOption("View/edit config", "edit"),
+ huh.NewOption("Load from file", "load"),
+ huh.NewOption("Cancel", "cancel"),
+ ).
+ Value(&action),
+ ),
+ ).WithWidth(boxWidth).WithKeyMap(configKeyMap()).WithProgramOptions(clearMenuOnCancel)
+
+ if err := form.Run(); err != nil {
+ return "", err
+ }
+ return action, nil
+}
+
+// PromptForConfigEdit shows a multi-line editor pre-filled with current and
+// returns the edited text. It returns an error if the user cancelled (Ctrl+C or
+// Esc).
+func (d *Display) PromptForConfigEdit(current string) (string, error) {
+ text := current
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewText().
+ Title("Harness config (JSON, leave empty to clear)").
+ Lines(10).
+ ExternalEditor(true).
+ Value(&text),
+ ),
+ ).WithWidth(boxWidth).WithKeyMap(configKeyMap()).WithProgramOptions(clearMenuOnCancel)
+
+ if err := form.Run(); err != nil {
+ return "", err
+ }
+ return text, nil
+}
+
+// PromptForConfigFile shows a file browser for selecting a JSON config file and
+// returns the chosen path. It returns an error if the user cancelled (Ctrl+C or
+// Esc).
+func (d *Display) PromptForConfigFile() (string, error) {
+ var path string
+ form := huh.NewForm(
+ huh.NewGroup(
+ huh.NewFilePicker().
+ Title("Load harness config").
+ CurrentDirectory(".").
+ AllowedTypes([]string{".json"}).
+ FileAllowed(true).
+ DirAllowed(false).
+ Picking(true).
+ Height(10).
+ Value(&path),
+ ),
+ ).WithWidth(boxWidth).WithKeyMap(configKeyMap()).WithProgramOptions(clearMenuOnCancel)
+
+ if err := form.Run(); err != nil {
+ return "", err
+ }
+ return path, nil
+}
+
+func (d *Display) ShowResumption(id string, server string) {
+ fmt.Fprintln(d.w, d.resumeStyle.Render("To resume the conversation,"))
+ if server != "" {
+ fmt.Fprintln(d.w, d.resumeStyle.Render(fmt.Sprintf("ax exec --conversation %s --server %s", id, server)))
+ } else {
+ fmt.Fprintln(d.w, d.resumeStyle.Render(fmt.Sprintf("ax exec --conversation %s", id)))
+ }
+}
diff --git a/cmd/ax/internal/display_test.go b/cmd/ax/internal/display_test.go
new file mode 100644
index 00000000..edd4b462
--- /dev/null
+++ b/cmd/ax/internal/display_test.go
@@ -0,0 +1,196 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package internal
+
+import (
+ "bytes"
+ "testing"
+
+ "github.com/google/ax/proto"
+)
+
+func TestDisplay_Streaming(t *testing.T) {
+ textContent := func(txt string) *proto.Content {
+ return &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: txt}}}
+ }
+ thoughtContent := func(txt string) *proto.Content {
+ return &proto.Content{Type: &proto.Content_Thought{Thought: &proto.ThoughtContent{
+ Summary: []*proto.ThoughtSummaryContent{
+ {Type: &proto.ThoughtSummaryContent_Text{Text: &proto.TextContent{Text: txt}}},
+ },
+ }}}
+ }
+ toolCallContent := func() *proto.Content {
+ return &proto.Content{Type: &proto.Content_ToolCall{ToolCall: &proto.ToolCallContent{}}}
+ }
+
+ t.Run("consecutive text chunks are concatenated", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(textContent("Hello "))
+ d.Display(textContent("world"))
+ d.Display(textContent("!"))
+
+ got := buf.String()
+ want := "Hello world!"
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("tool call separates consecutive text blocks", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(textContent("...configured."))
+ d.Display(toolCallContent())
+ d.Display(textContent("I will list the contents."))
+
+ got := buf.String()
+ want := "...configured.\nI will list the contents."
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("repeated tool calls do not add extra newlines", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(textContent("Done."))
+ d.Display(toolCallContent())
+ d.Display(toolCallContent())
+ d.Display(textContent("Next."))
+
+ got := buf.String()
+ want := "Done.\nNext."
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("consecutive thought chunks are concatenated with prefix", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(thoughtContent("thinking "))
+ d.Display(thoughtContent("deeply"))
+
+ got := buf.String()
+ want := "Thinking: thinking deeply"
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("transition from thought to text adds newline", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(thoughtContent("thinking"))
+ d.Display(textContent("Hello"))
+
+ got := buf.String()
+ want := "Thinking: thinking\nHello"
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("transition from text to thought adds newline", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(textContent("Hello"))
+ d.Display(thoughtContent("thinking"))
+
+ got := buf.String()
+ want := "Hello\nThinking: thinking"
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("FinishOutput empty resets state and adds newlines", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(textContent("Hello"))
+ d.FinishOutput("")
+
+ got := buf.String()
+ want := "Hello\n\n"
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("FinishOutput with info prints info and resets state", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(textContent("Hello"))
+ d.FinishOutput("seq=1")
+
+ got := buf.String()
+ if !bytes.HasPrefix([]byte(got), []byte("Hello\n")) {
+ t.Errorf("expected Hello to end with newline, got %q", got)
+ }
+ if !bytes.Contains([]byte(got), []byte("seq=1")) {
+ t.Errorf("expected output to contain seq=1, got %q", got)
+ }
+ })
+
+ t.Run("displaySystem resets state and prints newline", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(textContent("Hello"))
+ d.displaySystem("system message")
+
+ got := buf.String()
+ want := "Hello\nsystem message\n"
+ if got != want {
+ t.Errorf("got %q, want %q", got, want)
+ }
+ })
+
+ t.Run("DisplayInput resets state and adds separation newlines", func(t *testing.T) {
+ t.Parallel()
+ var buf bytes.Buffer
+ d := NewDisplay("test-id", &buf)
+
+ d.Display(textContent("Hello"))
+ d.DisplayInput("prompt")
+
+ got := buf.String()
+ if !bytes.HasPrefix([]byte(got), []byte("Hello\n")) {
+ t.Errorf("expected Hello to end with newline, got %q", got)
+ }
+ if !bytes.Contains([]byte(got), []byte("prompt")) {
+ t.Errorf("expected output to contain prompt, got %q", got)
+ }
+ })
+}
diff --git a/cmd/ax/main.go b/cmd/ax/main.go
new file mode 100644
index 00000000..ee65c4a2
--- /dev/null
+++ b/cmd/ax/main.go
@@ -0,0 +1,79 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// AX is a server for managing agent orchestrator tasks.
+// It provides commands to execute tasks, resume from checkpoints,
+// register agents, and run the controller server.
+package main
+
+import (
+ "errors"
+ "fmt"
+ "os"
+
+ "github.com/google/ax/cmd/ax/internal/cliutil"
+ "github.com/spf13/cobra"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+)
+
+func main() {
+ if err := rootCmd.Execute(); err != nil {
+ fmt.Fprintf(os.Stderr, "Error: %v\n", err)
+ os.Exit(1)
+ }
+}
+
+var rootCmd = &cobra.Command{
+ Use: "ax",
+ Short: "AX - Agent eXecutor",
+ Long: `ax provides a server and CLI tools for managing agent orchestrator tasks.
+It provides commands to execute tasks, resume from checkpoints,
+and run the controller server.`,
+}
+
+func init() {
+ rootCmd.AddCommand(execCmd)
+ rootCmd.AddCommand(serveCmd)
+
+ rootCmd.AddCommand(dashboardCmd)
+}
+
+func connect(server string) (*grpc.ClientConn, error) {
+ conn, err := grpc.NewClient(server,
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to connect to server: %w", err)
+ }
+ return conn, nil
+}
+
+const currentVersion = "v1alpha"
+
+func newConfig(cmd *cobra.Command, configFile string) (*cliutil.Config, error) {
+ cfg, err := cliutil.LoadFromFile(configFile)
+ if errors.Is(err, os.ErrNotExist) && !cmd.Flags().Changed("config") {
+ cfg := cliutil.DefaultConfig()
+ cfg.Version = currentVersion
+ return cfg, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("error loading config file '%s': %w", configFile, err)
+ }
+ if cfg.Version != currentVersion {
+ return nil, fmt.Errorf("unsupported config version %q, must be %q", cfg.Version, currentVersion)
+ }
+ return cfg, nil
+}
diff --git a/cmd/ax/serve.go b/cmd/ax/serve.go
new file mode 100644
index 00000000..d0b407e2
--- /dev/null
+++ b/cmd/ax/serve.go
@@ -0,0 +1,113 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "context"
+ "fmt"
+ "log/slog"
+ "os"
+ "os/signal"
+ "syscall"
+
+ "github.com/google/ax/cmd/ax/internal/cliutil"
+ "github.com/google/ax/internal/server"
+ "github.com/google/ax/internal/telemetry"
+ "github.com/spf13/cobra"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+)
+
+var (
+ serveConfigFile string
+)
+
+var serveCmd = &cobra.Command{
+ Use: "serve",
+ Short: "Run controller as a gRPC server",
+ Long: `Run the AX controller as a gRPC server.
+Loads configuration from a YAML file (default: ax.yaml).`,
+ RunE: runServe,
+}
+
+func init() {
+ serveCmd.Flags().StringVar(&serveConfigFile, "config", "ax.yaml", "Path to YAML configuration file")
+}
+
+func runServe(cmd *cobra.Command, args []string) error {
+ ctx := cmd.Context()
+
+ // Initialize structured logging (JSON to stdout)
+ slog.SetDefault(slog.New(slog.NewJSONHandler(os.Stdout, nil)))
+
+ cfg, err := newConfig(cmd, serveConfigFile)
+ if err != nil {
+ return err
+ }
+
+ // Validate configuration
+ if err := cfg.Validate(); err != nil {
+ return fmt.Errorf("invalid configuration: %w", err)
+ }
+
+ // Initialize OpenTelemetry SDK
+ if cfg.Telemetry.OTLP.Enabled {
+ var opts []otlptracegrpc.Option
+ endpoint := cfg.Telemetry.OTLP.Endpoint
+ if endpoint == "" {
+ endpoint = defaultEndpoint
+ }
+ opts = append(opts, otlptracegrpc.WithEndpoint(endpoint))
+ if gcpOpts, ok := gcpTelemetryOpts(endpoint); ok {
+ opts = append(opts, gcpOpts...)
+ } else {
+ opts = append(opts, otlptracegrpc.WithInsecure())
+ }
+
+ shutdown, err := telemetry.SetTraceProvider(ctx, "ax-server", opts...)
+ if err != nil {
+ return fmt.Errorf("failed to initialize telemetry: %w", err)
+ }
+ defer func() {
+ if err := shutdown(context.Background()); err != nil {
+ slog.Error("failed to shutdown telemetry", "error", err)
+ }
+ }()
+ }
+
+ c, err := cliutil.NewControllerFromConfig(ctx, cfg)
+ if err != nil {
+ return fmt.Errorf("error creating controller: %w", err)
+ }
+ defer c.Close()
+
+ // Create server
+ srv := server.New(c)
+
+ // Setup signal handling for graceful shutdown
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
+
+ go func() {
+ <-sigChan
+ slog.InfoContext(ctx, "Received interrupt, shutting down...")
+ srv.GracefulStop()
+ }()
+ slog.InfoContext(ctx, "Starting AX server", slog.String("address", cfg.Server.Address))
+ if err := srv.Serve(cfg.Server.Address); err != nil {
+ return fmt.Errorf("error serving: %w", err)
+ }
+
+ return nil
+}
diff --git a/cmd/ax/serve_gcp.go b/cmd/ax/serve_gcp.go
new file mode 100644
index 00000000..63e559b3
--- /dev/null
+++ b/cmd/ax/serve_gcp.go
@@ -0,0 +1,32 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package main
+
+import (
+ "github.com/google/ax/internal/telemetry"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+)
+
+const defaultEndpoint = "telemetry.googleapis.com"
+
+// gcpTelemetryOpts returns the OTLP options for Google Cloud Trace if the endpoint
+// matches the Google Cloud Trace endpoint. It returns the options and a boolean
+// indicating if the endpoint was matched.
+func gcpTelemetryOpts(endpoint string) ([]otlptracegrpc.Option, bool) {
+ if endpoint == defaultEndpoint || endpoint == defaultEndpoint+":443" {
+ return []otlptracegrpc.Option{telemetry.WithGCPCredentials()}, true
+ }
+ return nil, false
+}
diff --git a/cmd/ax/web/index.html b/cmd/ax/web/index.html
new file mode 100644
index 00000000..fc49046a
--- /dev/null
+++ b/cmd/ax/web/index.html
@@ -0,0 +1,1242 @@
+
+
+
+
+
+
+
+AX Dashboard
+
+
+
+
+
+
+
+
+
+
+ ⬡
+
AX Dashboard
+
+
+
+ Connected
+
+
+
+
+
+
+
+
+
+
+
Total
+
0
+
+
Σ
+
+
+
+
Active
+
0
+
+
⚡
+
+
+
+
Completed
+
0
+
+
✓
+
+
+
+
Failed
+
0
+
+
✗
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Conversation ID
+
Agent
+
Status
+
Last Seq
+
Duration
+
Actions
+
+
+
+
+
+
+
+
🔍
+
No conversations found matching criteria.
+
+
+
+
+
+
+
+
+
+
Execution Trace
+
+
+
+
+
+
+
Execution Timeline
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/cmd/gar/inspect.go b/cmd/gar/inspect.go
deleted file mode 100644
index 8068f05f..00000000
--- a/cmd/gar/inspect.go
+++ /dev/null
@@ -1,74 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package main
-
-import (
- "context"
- "fmt"
- "time"
-
- "github.com/google/gar/proto"
- "github.com/spf13/cobra"
-)
-
-var (
- inspectSessionID string
- inspectServerAddr string
-)
-
-var inspectCmd = &cobra.Command{
- Use: "inspect",
- Short: "Inspect a session",
- Long: `Inspect a session to view its current state, step count, and other details.`,
- RunE: runInspect,
-}
-
-func init() {
- inspectCmd.Flags().StringVar(&inspectSessionID, "session-id", "", "Session ID (required)")
- inspectCmd.Flags().StringVar(&inspectServerAddr, "server", "localhost:8494", "gRPC controller server address (default: localhost:8494)")
- inspectCmd.MarkFlagRequired("session-id")
-}
-
-func runInspect(cmd *cobra.Command, args []string) error {
- ctx := context.Background()
- conn, err := connect(inspectServerAddr)
- if err != nil {
- return err
- }
- defer conn.Close()
-
- client := proto.NewGARServiceClient(conn)
-
- // Get session details
- resp, err := client.GetSession(ctx, &proto.GetSessionRequest{
- SessionId: inspectSessionID,
- })
- if err != nil {
- return fmt.Errorf("error getting session: %w", err)
- }
-
- session := resp.Session
-
- // Print session details
- fmt.Println("\nSession Details:")
- fmt.Printf(" ID: %s\n", inspectSessionID)
- fmt.Printf(" State: %s\n", session.State)
- fmt.Printf(" Created At: %s\n", session.CreatedAt.AsTime().Format(time.RFC3339))
- fmt.Printf(" Updated At: %s\n", session.UpdatedAt.AsTime().Format(time.RFC3339))
- fmt.Printf(" Checkpoints: %d\n", session.CheckpointCount)
- fmt.Printf(" Active Agents: %v\n", session.ActiveAgents)
-
- return nil
-}
diff --git a/cmd/gar/main.go b/cmd/gar/main.go
deleted file mode 100644
index 90ad3d51..00000000
--- a/cmd/gar/main.go
+++ /dev/null
@@ -1,58 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Gar is a CLI tool for managing agent orchestrator sessions.
-// It provides commands to trigger sessions, resume from checkpoints,
-// inspect session state, register agents, and run the controller server.
-package main
-
-import (
- "fmt"
- "os"
-
- "github.com/spf13/cobra"
- "google.golang.org/grpc"
- "google.golang.org/grpc/credentials/insecure"
-)
-
-func main() {
- if err := rootCmd.Execute(); err != nil {
- fmt.Fprintf(os.Stderr, "Error: %v\n", err)
- os.Exit(1)
- }
-}
-
-var rootCmd = &cobra.Command{
- Use: "gar",
- Short: "GAR - Google Agent Runtime CLI",
- Long: `Gar is a CLI tool for managing agent orchestrator sessions.
-It provides commands to trigger sessions, resume from checkpoints,
-inspect session state, register agents, and run the controller server.`,
-}
-
-func init() {
- // Add subcommands
- rootCmd.AddCommand(triggerCmd)
- rootCmd.AddCommand(inspectCmd)
- rootCmd.AddCommand(registerCmd)
- rootCmd.AddCommand(serveCmd)
-}
-
-func connect(server string) (*grpc.ClientConn, error) {
- conn, err := grpc.NewClient(server, grpc.WithTransportCredentials(insecure.NewCredentials()))
- if err != nil {
- return nil, fmt.Errorf("failed to connect to server: %w", err)
- }
- return conn, nil
-}
diff --git a/cmd/gar/register.go b/cmd/gar/register.go
deleted file mode 100644
index 600c70f5..00000000
--- a/cmd/gar/register.go
+++ /dev/null
@@ -1,73 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package main
-
-import (
- "context"
- "fmt"
-
- "github.com/google/gar/proto"
- "github.com/spf13/cobra"
-)
-
-var (
- registerAgentID string
- registerAgentName string
- registerAgentDesc string
- registerAgentAddr string
- registerServerAddr string
-)
-
-var registerCmd = &cobra.Command{
- Use: "register",
- Short: "Register a remote agent",
- Long: `Register a remote agent with the controller so it can be used in sessions.`,
- RunE: runRegister,
-}
-
-func init() {
- registerCmd.Flags().StringVar(®isterAgentID, "agent-id", "", "Agent ID (required)")
- registerCmd.Flags().StringVar(®isterAgentName, "agent-name", "", "Agent name (required)")
- registerCmd.Flags().StringVar(®isterAgentDesc, "agent-description", "", "Agent description (required)")
- registerCmd.Flags().StringVar(®isterAgentAddr, "agent-addr", "", "Agent address (e.g., localhost:50051) (required)")
- registerCmd.Flags().StringVar(®isterServerAddr, "server", "localhost:8494", "gRPC controller server address (default: localhost:8494)")
- registerCmd.MarkFlagRequired("agent-id")
- registerCmd.MarkFlagRequired("agent-name")
- registerCmd.MarkFlagRequired("agent-description")
- registerCmd.MarkFlagRequired("agent-addr")
-}
-
-func runRegister(cmd *cobra.Command, args []string) error {
- ctx := context.Background()
- conn, err := connect(inspectServerAddr)
- if err != nil {
- return err
- }
- defer conn.Close()
-
- client := proto.NewGARServiceClient(conn)
-
- // Register remote agent
- _, err = client.RegisterAgent(ctx, &proto.RegisterAgentRequest{
- AgentId: registerAgentID,
- Name: registerAgentName,
- Description: registerAgentDesc,
- Address: registerAgentAddr,
- })
- if err != nil {
- return fmt.Errorf("error registering agent: %w", err)
- }
- return nil
-}
diff --git a/cmd/gar/serve.go b/cmd/gar/serve.go
deleted file mode 100644
index 3eb82ec2..00000000
--- a/cmd/gar/serve.go
+++ /dev/null
@@ -1,121 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package main
-
-import (
- "context"
- "fmt"
- "os"
- "os/signal"
- "syscall"
-
- "github.com/google/gar/internal/config"
- "github.com/google/gar/internal/controller"
- "github.com/google/gar/internal/eventlog"
- "github.com/google/gar/internal/server"
- "github.com/spf13/cobra"
-)
-
-var (
- serveConfigFile string
-)
-
-var serveCmd = &cobra.Command{
- Use: "serve",
- Short: "Run controller as a gRPC server",
- Long: `Run the GAR controller as a gRPC server.
-Loads configuration from a YAML file (default: gar.yaml).`,
- RunE: runServe,
-}
-
-func init() {
- serveCmd.Flags().StringVar(&serveConfigFile, "config", "gar.yaml", "Path to YAML configuration file")
-}
-
-func runServe(cmd *cobra.Command, args []string) error {
- ctx := context.Background()
-
- // Load configuration from YAML file
- cfg, err := config.LoadFromFile(serveConfigFile)
- if err != nil {
- return fmt.Errorf("error loading config file '%s': %w\nTip: Create a config file with 'gar serve --help' to see an example", serveConfigFile, err)
- }
-
- // Validate configuration
- if err := cfg.Validate(); err != nil {
- return fmt.Errorf("invalid configuration: %w", err)
- }
-
- fmt.Printf("Starting GAR server at %s...\n", cfg.Server.Address)
- c, err := newControllerFromConfig(ctx, cfg)
- if err != nil {
- return fmt.Errorf("error creating controller: %w", err)
- }
- defer c.Close()
-
- // Create server
- srv := server.New(c)
-
- // Setup signal handling for graceful shutdown
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
-
- go func() {
- <-sigChan
- fmt.Println("\nReceived interrupt, shutting down...")
- os.Exit(0)
- }()
-
- // Start serving
- if err := srv.Serve(cfg.Server.Address); err != nil {
- return fmt.Errorf("error serving: %w", err)
- }
-
- return nil
-}
-
-func newControllerFromConfig(ctx context.Context, cfg *config.Config) (*controller.Controller, error) {
- // Create event log factory
- eventLogFactory := func(sessionID string) (eventlog.EventLog, error) {
- return eventlog.NewFileEventLog(eventlog.FileConfig{
- SessionID: sessionID,
- Dir: cfg.EventLog.Dir,
- })
- }
-
- // Build controller config
- // The controller will create a default Gemini planner if PlanFunc is nil
- // Gemini config can be customized via environment variables (GEMINI_API_KEY, GAR_GEMINI_MODEL)
- controllerConfig := controller.Config{
- EventLogFactory: eventLogFactory,
- MaxSteps: cfg.MaxSteps,
- HealthCheckInterval: cfg.HealthCheckInterval,
- }
-
- // Create controller
- c, err := controller.New(ctx, controllerConfig)
- if err != nil {
- return nil, err
- }
-
- // Register remote agents from config
- for _, agentCfg := range cfg.RemoteAgents {
- if err := c.Registry().RegisterRemote(agentCfg); err != nil {
- return nil, fmt.Errorf("failed to register remote agent %s: %w", agentCfg.ID, err)
- }
- }
-
- return c, nil
-}
diff --git a/cmd/gar/trigger.go b/cmd/gar/trigger.go
deleted file mode 100644
index 64244b91..00000000
--- a/cmd/gar/trigger.go
+++ /dev/null
@@ -1,119 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package main
-
-import (
- "context"
- "fmt"
- "io"
- "os"
- "os/signal"
- "syscall"
-
- "github.com/google/gar/proto"
- "github.com/google/uuid"
- "github.com/spf13/cobra"
-)
-
-var (
- triggerSessionID string
- triggerInput string
- triggerCheckpoint string
- triggerServerAddr string
-)
-
-var triggerCmd = &cobra.Command{
- Use: "trigger",
- Short: "Trigger a new session or resume an existing one",
- Long: `Trigger a new agentic session or resume an existing one.
-If no session ID is provided, a new UUID will be generated.
-Use --checkpoint to resume from a specific checkpoint.`,
- RunE: runTrigger,
-}
-
-func init() {
- triggerCmd.Flags().StringVar(&triggerSessionID, "session-id", "", "Session ID (optional, generates UUID if not provided)")
- triggerCmd.Flags().StringVar(&triggerInput, "input", "", "Input message to send (required)")
- triggerCmd.Flags().StringVar(&triggerCheckpoint, "checkpoint", "", "Resume from specific checkpoint UUID (empty for latest)")
- triggerCmd.Flags().StringVar(&triggerServerAddr, "server", "localhost:8494", "gRPC controller server address (default: localhost:8494)")
- triggerCmd.MarkFlagRequired("input")
-}
-
-// TODO(jbd): Add multimodal input flags, e.g. --input-image.
-
-func runTrigger(cmd *cobra.Command, args []string) error {
- ctx := context.Background()
-
- // Generate UUID if no session ID provided
- if triggerSessionID == "" {
- triggerSessionID = uuid.New().String()
- fmt.Printf("Generated session ID: %s\n", triggerSessionID)
- }
-
- // Create input content
- inputs := []*proto.Content{
- {
- Role: "user",
- Type: "text",
- Mimetype: "text/plain",
- Data: triggerInput,
- },
- }
-
- // Setup signal handling for graceful shutdown
- ctx, cancel := context.WithCancel(ctx)
- defer cancel()
-
- sigChan := make(chan os.Signal, 1)
- signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
-
- go func() {
- <-sigChan
- fmt.Println("\nReceived interrupt, shutting down...")
- cancel()
- }()
-
- conn, err := connect(inspectServerAddr)
- if err != nil {
- return err
- }
- defer conn.Close()
-
- client := proto.NewGARServiceClient(conn)
- stream, err := client.TriggerSession(ctx, &proto.TriggerSessionRequest{
- SessionId: triggerSessionID,
- Inputs: inputs,
- CheckpointId: triggerCheckpoint,
- })
- if err != nil {
- return fmt.Errorf("error triggering session: %w", err)
- }
-
- // Receive and print all responses
- for {
- resp, err := stream.Recv()
- if err == io.EOF {
- break
- }
- if err != nil {
- return fmt.Errorf("error receiving response: %w", err)
- }
-
- if resp.Output != nil {
- fmt.Printf("[%s] %s\n", resp.State, resp.Output.Data)
- }
- }
- return nil
-}
diff --git a/e2e b/e2e
new file mode 100755
index 00000000..c1226113
Binary files /dev/null and b/e2e differ
diff --git a/examples/local_agent/main.go b/examples/local_agent/main.go
deleted file mode 100644
index b86a73e2..00000000
--- a/examples/local_agent/main.go
+++ /dev/null
@@ -1,133 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package main
-
-import (
- "context"
- "flag"
- "fmt"
- "log"
- "strings"
- "time"
-
- "github.com/google/gar/agent"
- "github.com/google/gar/internal/config"
- "github.com/google/gar/internal/controller"
- "github.com/google/gar/proto"
- "github.com/google/uuid"
-)
-
-var (
- sessionID string
- input string
-)
-
-func main() {
- ctx := context.Background()
- flag.StringVar(&input, "input", "Hello, uppercase this message!", "Input message to be processed by the agent")
- flag.StringVar(&sessionID, "session_id", "", "Optional session ID")
- flag.Parse()
-
- // Create a local echo agent
- echoAgent, err := createEchoAgent()
- if err != nil {
- log.Fatalf("Error creating agent: %v\n", err)
- }
-
- c, err := controller.New(ctx, controller.Config{
- MaxSteps: 10,
- HealthCheckInterval: 30 * time.Second,
- })
- if err != nil {
- log.Fatalf("Error creating controller: %v\n", err)
- }
- defer c.Close()
-
- if err := c.Registry().RegisterLocal(config.LocalAgentConfig{
- ID: "local-echo-agent",
- Name: "Echo Agent",
- Description: "Simple echo agent that uppercases input",
- Metadata: map[string]string{
- "version": "1.0",
- },
- Agent: echoAgent,
- }); err != nil {
- log.Fatalf("Error registering agent: %v\n", err)
- }
-
- inputs := []*proto.Content{
- {
- Role: "user",
- Type: "text",
- Mimetype: "text/plain",
- Data: input,
- },
- }
-
- // Trigger a session. Alternatively, controller can be used
- // with the server package to expose a gRPC server.
- if sessionID == "" {
- sessionID = uuid.New().String()
- }
- log.Printf("Session ID: %s\n", sessionID)
-
- handler := agent.OutputHandler(func(content *proto.Content) error {
- fmt.Printf("Output received: %s\n", content.Data)
- return nil
- })
- if err := c.TriggerSession(ctx, sessionID, inputs, handler); err != nil {
- log.Fatalf("Error triggering session: %v\n", err)
- }
-}
-
-// createEchoAgent creates a simple echo agent that repeats input with a prefix.
-func createEchoAgent() (*agent.LocalAgent, error) {
- processFunc := func(ctx context.Context, sessionID string, inputs []*proto.Content, handler agent.OutputHandler) error {
- // Process each input and call handler with response
- for _, content := range inputs {
- // Echo the content back with a prefix
- response := &proto.Content{
- Role: "assistant",
- Type: content.Type,
- Mimetype: content.Mimetype,
- Data: strings.ToUpper(content.Data),
- }
-
- // Call handler with the response
- if err := handler(response); err != nil {
- return err
- }
-
- // Check for context cancellation
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
- }
-
- return nil
- }
-
- healthCheckFunc := func(ctx context.Context) error {
- // Always healthy
- return nil
- }
-
- return agent.NewLocalAgent(agent.LocalAgentConfig{
- ProcessFunc: processFunc,
- HealthCheckFunc: healthCheckFunc,
- })
-}
diff --git a/examples/remote_agent/main.go b/examples/remote_agent/main.go
deleted file mode 100644
index 87eb5c93..00000000
--- a/examples/remote_agent/main.go
+++ /dev/null
@@ -1,91 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package main
-
-import (
- "context"
- "fmt"
- "io"
- "log"
- "net"
- "strings"
-
- "google.golang.org/grpc"
-
- "github.com/google/gar/proto"
-)
-
-const port = ":50051"
-
-// server implements the AgentService gRPC server.
-type server struct {
- proto.UnimplementedAgentServiceServer
-}
-
-// Process implements bidirectional streaming for content processing.
-// This RPC is called by the gar controller when a session triggers this agent.
-func (s *server) Process(stream proto.AgentService_ProcessServer) error {
- for {
- // Receive input content from gar controller
- content, err := stream.Recv()
- if err == io.EOF {
- return nil
- }
- if err != nil {
- return err
- }
-
- // Process the content (simple uppercase echo)
- response := &proto.Content{
- Role: "assistant",
- Type: content.Type,
- Mimetype: content.Mimetype,
- Data: fmt.Sprintf("Remote Echo: %s", strings.ToUpper(content.Data)),
- }
-
- // Send response back to gar controller
- if err := stream.Send(response); err != nil {
- return err
- }
-
- log.Printf("Sent to controller: %s", response.Data)
- }
-}
-
-// HealthCheck checks if the agent is healthy.
-func (s *server) HealthCheck(ctx context.Context, req *proto.HealthCheckRequest) (*proto.HealthCheckResponse, error) {
- log.Println("Health check requested")
-
- return &proto.HealthCheckResponse{
- Healthy: true,
- Message: "Agent is healthy",
- }, nil
-}
-
-func main() {
- fmt.Printf("Listening on port: %s\n", port)
- lis, err := net.Listen("tcp", port)
- if err != nil {
- log.Fatalf("Failed to listen: %v", err)
- }
-
- grpcServer := grpc.NewServer()
- proto.RegisterAgentServiceServer(grpcServer, &server{})
-
- fmt.Println("\nAgent server is running...")
- if err := grpcServer.Serve(lis); err != nil {
- log.Fatalf("Failed to serve: %v", err)
- }
-}
diff --git a/examples/skills/README.md b/examples/skills/README.md
new file mode 100644
index 00000000..72769049
--- /dev/null
+++ b/examples/skills/README.md
@@ -0,0 +1,37 @@
+# Skills
+
+AX has built-in support for [Agent Skills](https://agentskills.io).
+
+## Configuration
+
+To enable skills with a custom directory, you can set the `skills_dir` in your `ax.yaml` file:
+
+```yaml
+planner:
+ gemini:
+ model: "gemini-3.5-flash"
+ timeout: "60s"
+ skills_dir: "./examples/skills"
+```
+
+If you omit `skills_dir` in your `ax.yaml` file, AX will check the `SKILLS_DIR` environment variable first, and finally fall back to `~/.agents/skills`.
+
+## Example: Using the `emoji` Skill
+
+AX contains a sample `emoji` skill in `examples/skills/emoji`. If you run a task that matches the description of the skill, the planner will automatically activate it and execute its scripts!
+
+Run the following command in your terminal:
+
+```bash
+ax exec --input "Give me an emoji for happy"
+```
+
+The planner will:
+1. Discover the `emoji` skill.
+2. Determine that your query matches its description.
+3. Call `activate_skill` to load the instructions.
+4. Execute the `emoji` skill via text generation or script execution based on its instructions.
+
+### Custom Skills
+
+Refer to the [Agent Skills](https://agentskills.io) documentation for more information on how to create custom skills.
\ No newline at end of file
diff --git a/examples/skills/emoji/SKILL.md b/examples/skills/emoji/SKILL.md
new file mode 100644
index 00000000..4a502462
--- /dev/null
+++ b/examples/skills/emoji/SKILL.md
@@ -0,0 +1,9 @@
+---
+name: emoji
+description: Returns a corresponding emoji for a given word (e.g. happy, magic etc).
+---
+
+# Instructions
+When the user asks for an emoji for a specific word or concept, use this skill.
+1. Call `run_skill_script` to execute `emoji.sh`, passing the word as the first argument.
+2. Return the output emoji directly to the user.
diff --git a/examples/skills/emoji/scripts/emoji.sh b/examples/skills/emoji/scripts/emoji.sh
new file mode 100755
index 00000000..ff8f5cb7
--- /dev/null
+++ b/examples/skills/emoji/scripts/emoji.sh
@@ -0,0 +1,34 @@
+#!/bin/sh
+
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Return an ASCII emoticon based on the input word
+# Convert input to lowercase using tr for case-insensitive matching
+word=$(echo "$1" | tr '[:upper:]' '[:lower:]')
+
+case "$word" in
+ "happy"|"smile"|"joy") echo ":-)" ;;
+ "sad"|"cry") echo ":-(" ;;
+ "shrug"|"dunno"|"whatever") echo "¯\_(ツ)_/¯" ;;
+ "flip"|"angry"|"rage") echo "(╯°□°)╯︵ ┻━┻" ;;
+ "table"|"putback") echo "┬─┬ノ( º _ ºノ)" ;;
+ "magic"|"sparkle") echo "(ノ◕ヮ◕)ノ*:・゚✧" ;;
+ "sunglasses"|"cool") echo "(•_•) / ( •_•)>⌐■-■ / (⌐■_■)" ;;
+ "sundar"|"ceo") echo "👓(⌐■_■) 👍" ;;
+ "dance"|"party") echo "♪~ ᕕ(ᐛ)ᕗ" ;;
+ "wink") echo ";-)" ;;
+ "surprise"|"gasp") echo "(O_O)" ;;
+ *) echo "¯\_(ツ)_/¯ (unknown word)" ;;
+esac
diff --git a/examples/skills/lowercase/SKILL.md b/examples/skills/lowercase/SKILL.md
new file mode 100644
index 00000000..47a2c77b
--- /dev/null
+++ b/examples/skills/lowercase/SKILL.md
@@ -0,0 +1,9 @@
+---
+name: lowercase
+description: Converts the provided input string to lowercase.
+---
+
+# Instructions
+When the user asks to convert a text to lowercase, or if you need to lowercase a string for processing, use this skill.
+1. Call `run_skill_script` to execute `lowercase.sh`, passing the text as the first argument.
+2. Return the lowered text directly to the user.
diff --git a/examples/skills/lowercase/scripts/lowercase.sh b/examples/skills/lowercase/scripts/lowercase.sh
new file mode 100755
index 00000000..f28b33d8
--- /dev/null
+++ b/examples/skills/lowercase/scripts/lowercase.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+# Converts argument to lowercase
+echo "$1" | tr '[:upper:]' '[:lower:]'
diff --git a/gar.yaml b/gar.yaml
deleted file mode 100644
index d26a05fa..00000000
--- a/gar.yaml
+++ /dev/null
@@ -1,67 +0,0 @@
-# Copyright 2026 Google LLC
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-# Server configuration
-server:
- # Address to listen on (host:port)
- address: ":8494"
-
-# Event log configuration
-eventlog:
- # Directory where event logs are stored
- dir: "eventlog"
-
-# Maximum number of steps per trigger
-max_steps: 50
-
-# Health check interval for agents (e.g., "30s", "1m")
-health_check_interval: 30s
-
-# Gemini planner configuration (optional)
-# The Gemini planner uses Google's Gemini models for intelligent agent selection.
-# API key must be set via GEMINI_API_KEY environment variable (required).
-# Model name can be set via GAR_GEMINI_MODEL environment variable (optional).
-#
-# gemini_planner:
-# Set GEMINI_API_KEY for authentication.
-#
-# # Model name (e.g., "gemini-2.5-flash")
-# model: "gemini-flash-latest"
-#
-# # Temperature for generation (0.0 to 1.0)
-# temperature: 0.7
-#
-# # Maximum output tokens
-# max_tokens: 8192
-#
-# # Request timeout (e.g., "30s", "1m")
-# timeout: 60s
-#
-# # Number of recent messages to include in context
-# context_window: 30
-#
-# # Custom system prompt for the planner (optional)
-# system_prompt: ""
-
-# Remote agents configuration (optional)
-# List of remote agents to register on startup.
-# Remote agents run as separate gRPC servers.
-#
-# remote_agents:
-# - id: "python-agent"
-# name: "Python Agent"
-# description: "Python-based agent for text processing"
-# address: "localhost:50051"
-# metadata:
-# version: "1.0"
diff --git a/go.mod b/go.mod
index bf426b27..73c022fa 100644
--- a/go.mod
+++ b/go.mod
@@ -1,34 +1,73 @@
-module github.com/google/gar
+module github.com/google/ax
-go 1.24.0
+go 1.26.3
require (
+ charm.land/bubbles/v2 v2.0.0
+ charm.land/bubbletea/v2 v2.0.2
+ charm.land/huh/v2 v2.0.3
+ charm.land/lipgloss/v2 v2.0.3
+ cloud.google.com/go/compute/metadata v0.9.0
+ github.com/agent-substrate/substrate v0.0.0-20260706222328-3cb7433bd8a8
github.com/google/uuid v1.6.0
+ github.com/jackc/pgx/v5 v5.10.0
github.com/spf13/cobra v1.10.2
- google.golang.org/genai v1.43.0
- google.golang.org/grpc v1.78.0
- google.golang.org/protobuf v1.36.11
+ go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0
+ go.opentelemetry.io/otel v1.44.0
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0
+ go.opentelemetry.io/otel/sdk v1.44.0
+ go.opentelemetry.io/otel/trace v1.44.0
+ golang.org/x/oauth2 v0.36.0
+ google.golang.org/grpc v1.81.1
+ google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af
gopkg.in/yaml.v3 v3.0.1
+ modernc.org/sqlite v1.47.0
)
require (
- cloud.google.com/go v0.116.0 // indirect
- cloud.google.com/go/auth v0.9.3 // indirect
- cloud.google.com/go/compute/metadata v0.9.0 // indirect
- github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
- github.com/google/go-cmp v0.7.0 // indirect
- github.com/google/s2a-go v0.1.8 // indirect
- github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
- github.com/gorilla/websocket v1.5.3 // indirect
+ github.com/atotto/clipboard v0.1.4 // indirect
+ github.com/catppuccin/go v0.3.0 // indirect
+ github.com/cenkalti/backoff/v5 v5.0.3 // indirect
+ github.com/cespare/xxhash/v2 v2.3.0 // indirect
+ github.com/charmbracelet/colorprofile v0.4.3 // indirect
+ github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect
+ github.com/charmbracelet/x/ansi v0.11.7 // indirect
+ github.com/charmbracelet/x/exp/ordered v0.1.0 // indirect
+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
+ github.com/charmbracelet/x/term v0.2.2 // indirect
+ github.com/charmbracelet/x/termios v0.1.1 // indirect
+ github.com/charmbracelet/x/windows v0.2.2 // indirect
+ github.com/clipperhouse/displaywidth v0.11.0 // indirect
+ github.com/clipperhouse/uax29/v2 v2.7.0 // indirect
+ github.com/dustin/go-humanize v1.0.1 // indirect
+ github.com/go-logr/logr v1.4.3 // indirect
+ github.com/go-logr/stdr v1.2.2 // indirect
+ github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
- github.com/kr/pretty v0.3.1 // indirect
- github.com/rogpeppe/go-internal v1.14.1 // indirect
+ github.com/jackc/pgpassfile v1.0.0 // indirect
+ github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
+ github.com/jackc/puddle/v2 v2.2.2 // indirect
+ github.com/lucasb-eyer/go-colorful v1.4.0 // indirect
+ github.com/mattn/go-isatty v0.0.20 // indirect
+ github.com/mattn/go-runewidth v0.0.23 // indirect
+ github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
+ github.com/muesli/cancelreader v0.2.2 // indirect
+ github.com/ncruces/go-strftime v1.0.0 // indirect
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
+ github.com/rivo/uniseg v0.4.7 // indirect
github.com/spf13/pflag v1.0.10 // indirect
- go.opencensus.io v0.24.0 // indirect
- golang.org/x/crypto v0.46.0 // indirect
- golang.org/x/net v0.48.0 // indirect
- golang.org/x/sys v0.40.0 // indirect
- golang.org/x/text v0.32.0 // indirect
- google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b // indirect
- gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
+ go.opentelemetry.io/auto/sdk v1.2.1 // indirect
+ go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect
+ go.opentelemetry.io/otel/metric v1.44.0 // indirect
+ go.opentelemetry.io/proto/otlp v1.10.0 // indirect
+ golang.org/x/net v0.55.0 // indirect
+ golang.org/x/sync v0.20.0 // indirect
+ golang.org/x/sys v0.45.0 // indirect
+ golang.org/x/text v0.37.0 // indirect
+ google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
+ modernc.org/libc v1.70.0 // indirect
+ modernc.org/mathutil v1.7.1 // indirect
+ modernc.org/memory v1.11.0 // indirect
)
diff --git a/go.sum b/go.sum
index 3f695972..a5bc044b 100644
--- a/go.sum
+++ b/go.sum
@@ -1,72 +1,122 @@
-cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
-cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
-cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
-cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
-cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
+charm.land/bubbles/v2 v2.0.0 h1:tE3eK/pHjmtrDiRdoC9uGNLgpopOd8fjhEe31B/ai5s=
+charm.land/bubbles/v2 v2.0.0/go.mod h1:rCHoleP2XhU8um45NTuOWBPNVHxnkXKTiZqcclL/qOI=
+charm.land/bubbletea/v2 v2.0.2 h1:4CRtRnuZOdFDTWSff9r8QFt/9+z6Emubz3aDMnf/dx0=
+charm.land/bubbletea/v2 v2.0.2/go.mod h1:3LRff2U4WIYXy7MTxfbAQ+AdfM3D8Xuvz2wbsOD9OHQ=
+charm.land/huh/v2 v2.0.3 h1:2cJsMqEPwSywGHvdlKsJyQKPtSJLVnFKyFbsYZTlLkU=
+charm.land/huh/v2 v2.0.3/go.mod h1:93eEveeeqn47MwiC3tf+2atZ2l7Is88rAtmZNZ8x9Wc=
+charm.land/lipgloss/v2 v2.0.3 h1:yM2zJ4Cf5Y51b7RHIwioil4ApI/aypFXXVHSwlM6RzU=
+charm.land/lipgloss/v2 v2.0.3/go.mod h1:7myLU9iG/3xluAWzpY/fSxYYHCgoKTie7laxk6ATwXA=
cloud.google.com/go/compute/metadata v0.9.0 h1:pDUj4QMoPejqq20dK0Pg2N4yG9zIkYGdBtwLoEkH9Zs=
cloud.google.com/go/compute/metadata v0.9.0/go.mod h1:E0bWwX5wTnLPedCKqk3pJmVgCBSM6qQI1yTBdEb3C10=
-github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
-github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
-github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
-github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
+github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
+github.com/MakeNowJust/heredoc v1.0.0/go.mod h1:mG5amYoWBHf8vpLOuehzbGGw0EHxpZZ6lCpQ4fNJ8LE=
+github.com/agent-substrate/substrate v0.0.0-20260706222328-3cb7433bd8a8 h1:tUeBjLs9TJgIWfML2dlZ3UOFG3dacbwrIYkas2ctBgY=
+github.com/agent-substrate/substrate v0.0.0-20260706222328-3cb7433bd8a8/go.mod h1:2cvSnnHPwZRAhkrC2LH1bQd1tQBfL3p+zU6pZiHBUkA=
+github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
+github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
+github.com/aymanbagabas/go-udiff v0.4.1 h1:OEIrQ8maEeDBXQDoGCbbTTXYJMYRCRO1fnodZ12Gv5o=
+github.com/aymanbagabas/go-udiff v0.4.1/go.mod h1:0L9PGwj20lrtmEMeyw4WKJ/TMyDtvAoK9bf2u/mNo3w=
+github.com/catppuccin/go v0.3.0 h1:d+0/YicIq+hSTo5oPuRi5kOpqkVA5tAsU6dNhvRu+aY=
+github.com/catppuccin/go v0.3.0/go.mod h1:8IHJuMGaUUjQM82qBrGNBv7LFq6JI3NnQCF6MOlZjpc=
+github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
+github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
+github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
+github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
+github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex9t5KX76i20Q=
+github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 h1:eyFRbAmexyt43hVfeyBofiGSEmJ7krjLOYt/9CF5NKA=
+github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8/go.mod h1:SQpCTRNBtzJkwku5ye4S3HEuthAlGy2n9VXZnWkEW98=
+github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
+github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
+github.com/charmbracelet/x/conpty v0.1.1 h1:s1bUxjoi7EpqiXysVtC+a8RrvPPNcNvAjfi4jxsAuEs=
+github.com/charmbracelet/x/conpty v0.1.1/go.mod h1:OmtR77VODEFbiTzGE9G1XiRJAga6011PIm4u5fTNZpk=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
+github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86/go.mod h1:2P0UgXMEa6TsToMSuFqKFQR+fZTO9CNGUNokkPatT/0=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
+github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f/go.mod h1:IfZAMTHB6XkZSeXUqriemErjAWCCzT0LwjKFYCZyw0I=
+github.com/charmbracelet/x/exp/ordered v0.1.0 h1:55/qLwjIh0gL0Vni+QAWk7T/qRVP6sBf+2agPBgnOFE=
+github.com/charmbracelet/x/exp/ordered v0.1.0/go.mod h1:5UHwmG+is5THxMyCJHNPCn2/ecI07aKNrW+LcResjJ8=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
+github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
+github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
+github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
+github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
+github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
+github.com/charmbracelet/x/windows v0.2.2 h1:IofanmuvaxnKHuV04sC0eBy/smG6kIKrWG2/jYn2GuM=
+github.com/charmbracelet/x/windows v0.2.2/go.mod h1:/8XtdKZzedat74NQFn0NGlGL4soHB0YQZrETF96h75k=
+github.com/charmbracelet/x/xpty v0.1.3 h1:eGSitii4suhzrISYH50ZfufV3v085BXQwIytcOdFSsw=
+github.com/charmbracelet/x/xpty v0.1.3/go.mod h1:poPYpWuLDBFCKmKLDnhBp51ATa0ooD8FhypRwEFtH3Y=
+github.com/clipperhouse/displaywidth v0.11.0 h1:lBc6kY44VFw+TDx4I8opi/EtL9m20WSEFgwIwO+UVM8=
+github.com/clipperhouse/displaywidth v0.11.0/go.mod h1:bkrFNkf81G8HyVqmKGxsPufD3JhNl3dSqnGhOoSD/o0=
+github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk=
+github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik=
+github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2/go.mod h1:qwXFYgsP6T7XnJtbKlf1HP8AjxZZyzxMmc+Lq5GjlU4=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
-github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
+github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
+github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
-github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
-github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
+github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
+github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
+github.com/envoyproxy/go-control-plane v0.14.0 h1:hbG2kr4RuFj222B6+7T83thSPqLjwBIfQawTkC++2HA=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0 h1:u3riX6BoYRfF4Dr7dwSOroNfdSbEPe9Yyl09/B6wBrQ=
+github.com/envoyproxy/go-control-plane/envoy v1.37.0/go.mod h1:DReE9MMrmecPy+YvQOAOHNYMALuowAnbjjEMkkWOi6A=
+github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds=
+github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0=
+github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
-github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
-github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
-github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
-github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
-github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
-github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
-github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
-github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
-github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
-github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
-github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
-github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
-github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
-github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
-github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
-github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
-github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
-github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
-github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
+github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e h1:FJta/0WsADCe1r9vQjdHbd3KuiLPu7Y9WlyLGwMUNyE=
+github.com/google/pprof v0.0.0-20250602020802-c6617b811d0e/go.mod h1:5hDyRhoBCxViHszMt12TnOpEI4VVi+U8Gm9iphldiMA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
-github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
-github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
-github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk=
+github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs=
+github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
+github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
-github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
+github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
+github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
+github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
+github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0=
+github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
+github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
+github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
-github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
-github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
-github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=
+github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4=
+github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
+github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
+github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
+github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw=
+github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
+github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
+github.com/mitchellh/hashstructure/v2 v2.0.2/go.mod h1:MG3aRVU/N29oo/V/IhBX8GR/zz4kQkprJgF2EVszyDE=
+github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
+github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
+github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
+github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10 h1:GFCKgmp0tecUJ0sJuv4pzYCqS9+RGSn52M3FUwPs+uo=
+github.com/planetscale/vtprotobuf v0.6.1-0.20240319094008-0393e58bdf10/go.mod h1:t/avpk3KcrXxUnYOhZhMXJlSEyie6gQbtLq5NM3loB8=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
-github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
+github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
+github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
+github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
+github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
@@ -76,98 +126,93 @@ github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
-github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
-github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
-github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
-github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
-github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
-go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
-go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
+github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
+github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
+github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
+github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
+github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
-go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
-go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
-go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
-go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
-go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
-go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
-go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
-go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
-go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
-go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0 h1:2yEATaop1/a1I4psnSLgWVPLWwCzkqWakgJy7xTDVy0=
+go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.69.0/go.mod h1:D7J12YRapIekYyPWgGPlA/23pRmpSEZC5xJC/TTLI9U=
+go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
+go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0 h1:qazEJlUOQzhCpzQpFETGby7EdqjI1wsd0W+6Gg1SCTU=
+go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.44.0/go.mod h1:fOD2Yefuxixkx3ahVNf0O/PERb6r4OlbxfATVnYvzCo=
+go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
+go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
+go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
+go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
+go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
+go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
+go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
+go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
+go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
+go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
+go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
-golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
-golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
-golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
-golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
-golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
-golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
-golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
-golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
-golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
-golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
-golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
-golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
-golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
-golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
-golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
-golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
-golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
-golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
-golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
-golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
-golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
-golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
-golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
-golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
-golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
-golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
-gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
-google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
-google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
-google.golang.org/genai v1.43.0 h1:8vhqhzJNZu1U94e2m+KvDq/TUUjSmDrs1aKkvTa8SoM=
-google.golang.org/genai v1.43.0/go.mod h1:A3kkl0nyBjyFlNjgxIwKq70julKbIxpSxqKO5gw/gmk=
-google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
-google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
-google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
-google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
-google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
-google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
-google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
-google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
-google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
-google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
-google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
-google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
-google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
-google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
-google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
-google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
-google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
-google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
-google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
-google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
+golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
+golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
+golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
+golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
+golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
+golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs=
+golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q=
+golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
+golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
+golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
+golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
+golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
+golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
+golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
+golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
+golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
+gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
+gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
+google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
+google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
+google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ=
+google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af h1:+5/Sw3GsDNlEmu7TfklWKPdQ0Ykja5VEmq2i817+jbI=
+google.golang.org/protobuf v1.36.12-0.20260120151049-f2248ac996af/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
-honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
-honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
+modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
+modernc.org/ccgo/v4 v4.32.0 h1:hjG66bI/kqIPX1b2yT6fr/jt+QedtP2fqojG2VrFuVw=
+modernc.org/ccgo/v4 v4.32.0/go.mod h1:6F08EBCx5uQc38kMGl+0Nm0oWczoo1c7cgpzEry7Uc0=
+modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
+modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
+modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
+modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
+modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo=
+modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
+modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
+modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
+modernc.org/libc v1.70.0 h1:U58NawXqXbgpZ/dcdS9kMshu08aiA6b7gusEusqzNkw=
+modernc.org/libc v1.70.0/go.mod h1:OVmxFGP1CI/Z4L3E0Q3Mf1PDE0BucwMkcXjjLntvHJo=
+modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
+modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
+modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
+modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
+modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
+modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
+modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
+modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
+modernc.org/sqlite v1.47.0 h1:R1XyaNpoW4Et9yly+I2EeX7pBza/w+pmYee/0HJDyKk=
+modernc.org/sqlite v1.47.0/go.mod h1:hWjRO6Tj/5Ik8ieqxQybiEOUXy0NJFNp2tpvVpKlvig=
+modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
+modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
+modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
+modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
diff --git a/hack/install-ax.sh b/hack/install-ax.sh
new file mode 100755
index 00000000..78f2b630
--- /dev/null
+++ b/hack/install-ax.sh
@@ -0,0 +1,371 @@
+#!/bin/bash
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -e
+set -u
+set -o pipefail
+
+ROOT=$(git rev-parse --show-toplevel)
+cd "${ROOT}"
+
+if [[ -n "${GOOGLE_CLOUD_PROJECT:-}" ]]; then
+ export AX_IMAGE_REPO="gcr.io/${GOOGLE_CLOUD_PROJECT}"
+ echo "Using AX_IMAGE_REPO: ${AX_IMAGE_REPO}" >&2
+fi
+
+export KO_DEFAULTPLATFORMS="${KO_DEFAULTPLATFORMS:-linux/amd64}"
+
+# ANSI color codes for prettier output
+COLOR_CYAN='\033[1;36m'
+COLOR_RESET='\033[0m'
+
+function log_step() {
+ local step_name="$1"
+ echo -e "${COLOR_CYAN}[step]: ${step_name}${COLOR_RESET}"
+}
+
+# wait_with_spinner runs a blocking command while showing a simple spinner on an
+# interactive terminal, then reports "done"/"failed" and returns the command's
+# exit status.
+wait_with_spinner() {
+ local msg="$1"; shift
+ if [[ ! -t 2 ]]; then
+ "$@"
+ return $?
+ fi
+
+ local out; out="$(mktemp)"
+ "$@" >"${out}" 2>&1 &
+ local pid=$! frames='|/-\' i=0
+ while kill -0 "${pid}" 2>/dev/null; do
+ i=$(( (i + 1) % ${#frames} ))
+ printf '\r%s %s' "${frames:${i}:1}" "${msg}" >&2
+ sleep 0.1
+ done
+
+ local status=0
+ wait "${pid}" || status=$?
+ if [[ "${status}" -eq 0 ]]; then
+ printf '\r\033[K%s... done\n' "${msg}" >&2
+ else
+ printf '\r\033[K%s... failed\n' "${msg}" >&2
+ cat "${out}" >&2
+ fi
+ rm -f "${out}"
+ return "${status}"
+}
+
+function usage() {
+ echo "Usage: $0 [options]"
+ echo ""
+ echo "Options:"
+ echo " --deploy-ax-server Build images and deploy AX server and components"
+ echo " --delete-ax-server Delete AX server and components, preserving the event-log database"
+ echo " --deploy-postgres With --deploy-ax-server: also deploy a bundled Postgres for testing (default: connect to an existing Postgres via AX_EVENTLOG_DSN)"
+ echo " -h, --help Show this help message"
+}
+
+run_kubectl() {
+ kubectl \
+ ${KUBECTL_CONTEXT:+--context=${KUBECTL_CONTEXT}} \
+ "$@"
+}
+
+# detect_container_engine selects the OCI build/push tool when CONTAINER_ENGINE
+# is not set explicitly. It prefers a *working* docker (daemon reachable), then a
+# working podman, so a docker CLI installed without a running daemon does not
+# shadow a working podman. As a last resort it picks whichever CLI exists so the
+# build step can surface an actionable daemon error.
+detect_container_engine() {
+ if [[ -n "${CONTAINER_ENGINE:-}" ]]; then
+ return # Respect an explicit override; do not second-guess it.
+ fi
+ if docker info >/dev/null 2>&1; then
+ CONTAINER_ENGINE=docker
+ elif podman info >/dev/null 2>&1; then
+ CONTAINER_ENGINE=podman
+ elif command -v docker >/dev/null 2>&1; then
+ CONTAINER_ENGINE=docker
+ elif command -v podman >/dev/null 2>&1; then
+ CONTAINER_ENGINE=podman
+ else
+ CONTAINER_ENGINE=docker
+ fi
+}
+
+# _build_and_push_ax builds one target of cmd/ax/Dockerfile into ${repo}:,
+# pushes it, and echoes the digest-pinned reference on stdout. Args:
+# $1 repo - fully-qualified image repo (e.g. gcr.io/proj/ax)
+# $2 target - Dockerfile target stage (ax | antigravity)
+# Requires AX_IMAGE_REPO and a container engine.
+_build_and_push_ax() {
+ local repo="$1" target="$2"
+ if [[ -z "${AX_IMAGE_REPO:-}" ]]; then
+ echo "Error: AX_IMAGE_REPO environment variable must be set" >&2
+ exit 1
+ fi
+ detect_container_engine
+ if ! command -v "${CONTAINER_ENGINE}" >/dev/null 2>&1; then
+ echo "Error: container engine '${CONTAINER_ENGINE}' not found in PATH." >&2
+ echo "Install it or set CONTAINER_ENGINE to an available builder." >&2
+ exit 1
+ fi
+
+ local tag image digest
+ tag="$(git rev-parse --short HEAD)"
+ image="${repo}:${tag}"
+
+ log_step "build ${target} -> ${image}" >&2
+ "${CONTAINER_ENGINE}" build \
+ --platform linux/amd64 \
+ --target "${target}" \
+ -f cmd/ax/Dockerfile \
+ -t "${image}" \
+ . 2>&1 \
+ | awk '{ sub(/^\[[0-9]+\/[0-9]+\] /, ""); print; fflush() }' >&2
+
+ # Push the readable tag, then resolve the pushed manifest digest so the
+ # ActorTemplate can reference the image by digest (snapshot-safe).
+ if [[ "${CONTAINER_ENGINE}" == *podman* ]]; then
+ local digestfile
+ digestfile="$(mktemp)"
+ "${CONTAINER_ENGINE}" push --digestfile="${digestfile}" "${image}" >&2
+ digest="$(cat "${digestfile}")"
+ rm -f "${digestfile}"
+ else
+ "${CONTAINER_ENGINE}" push "${image}" >&2
+ local repo_digest
+ repo_digest="$("${CONTAINER_ENGINE}" image inspect --format '{{index .RepoDigests 0}}' "${image}")"
+ digest="${repo_digest##*@}"
+ fi
+
+ if [[ "${digest}" != sha256:* ]]; then
+ echo "Error: could not resolve a sha256 digest for ${image} (got '${digest}')." >&2
+ exit 1
+ fi
+
+ echo "${repo}@${digest}"
+}
+
+# build_ax_image builds and pushes the Go-only ax image used by the ax-server and the
+# Go interactions harness.
+build_ax_image() {
+ _build_and_push_ax "${AX_IMAGE_REPO}/ax" ax
+}
+
+# build_ax_antigravity_image builds and pushes the antigravity image used by the
+# antigravity harness.
+build_ax_antigravity_image() {
+ _build_and_push_ax "${AX_IMAGE_REPO}/ax-antigravity" antigravity
+}
+
+build_ateom_image() {
+ if [[ -n "${ATEOM_IMAGE:-}" ]]; then
+ echo "${ATEOM_IMAGE}"
+ return
+ fi
+ if [[ -z "${AX_IMAGE_REPO:-}" ]]; then
+ echo "Error: AX_IMAGE_REPO environment variable must be set" >&2
+ exit 1
+ fi
+
+ # Resolve the substrate source for the version AX is pinned to in go.mod.
+ go mod download github.com/agent-substrate/substrate
+ local sub_dir ateom_ref
+ sub_dir="$(go list -m -f '{{.Dir}}' github.com/agent-substrate/substrate)"
+ if [[ -z "${sub_dir}" ]]; then
+ echo "Error: could not locate the substrate module (go list -m)." >&2
+ exit 1
+ fi
+
+ log_step "build_ateom_image (from ${sub_dir})" >&2
+ ateom_ref="$(cd "${sub_dir}" && KO_DOCKER_REPO="${AX_IMAGE_REPO}" GOFLAGS="" ko build --platform=linux/amd64 -B ./cmd/ateom-gvisor)"
+
+ if [[ "${ateom_ref}" != *@sha256:* ]]; then
+ echo "Error: ko did not return a digest-pinned ateom image (got '${ateom_ref}')." >&2
+ exit 1
+ fi
+ echo "${ateom_ref}"
+}
+
+deploy_ax_server() {
+ log_step "deploy_ax_server"
+
+ # Check dependencies
+ if [[ -z "${GEMINI_API_KEY:-}" ]]; then
+ echo "Error: GEMINI_API_KEY environment variable must be set" >&2
+ exit 1
+ fi
+ if [[ -z "${AX_SNAPSHOTS_BUCKET:-}" ]]; then
+ echo "Error: AX_SNAPSHOTS_BUCKET environment variable must be set" >&2
+ exit 1
+ fi
+ # The default (external Postgres) path needs a DSN; fail fast before building.
+ if [[ "${DEPLOY_POSTGRES}" != "true" && -z "${AX_EVENTLOG_DSN:-}" ]]; then
+ echo "Error: AX_EVENTLOG_DSN must be set to your Postgres DSN, or pass --deploy-postgres to deploy a bundled test Postgres." >&2
+ exit 1
+ fi
+
+ echo "Using GCS Bucket: ${AX_SNAPSHOTS_BUCKET}"
+
+ # Build and push the images, capturing their digest-pinned references.
+ local ax_image ax_antigravity_image ateom_image
+ ax_image=$(build_ax_image)
+ ax_antigravity_image=$(build_ax_antigravity_image)
+ ateom_image=$(build_ateom_image)
+
+ # Resolve the event-log Postgres DSN. By default ax-server connects to an
+ # existing Postgres via AX_EVENTLOG_DSN; --deploy-postgres creates a bundled
+ # Postgres in-cluster (for testing) and derives the DSN from it.
+ local pg_dsn pg_password=""
+ if [[ "${DEPLOY_POSTGRES}" == "true" ]]; then
+ # Reuse the existing bundled-Postgres password if present, else POSTGRES_PASSWORD,
+ # else generate one.
+ local existing_pw
+ existing_pw="$(run_kubectl -n ax get secret ax-eventlog-postgres -o go-template='{{.data.password | base64decode}}' 2>/dev/null || true)"
+ if [[ -n "${existing_pw}" ]]; then
+ pg_password="${existing_pw}"
+ else
+ pg_password="${POSTGRES_PASSWORD:-$(openssl rand -hex 16)}"
+ fi
+ pg_dsn="postgres://axuser:${pg_password}@ax-eventlog-postgres.ax.svc:5432/axeventlog?sslmode=disable"
+ else
+ pg_dsn="${AX_EVENTLOG_DSN}"
+ echo "Using existing Postgres via AX_EVENTLOG_DSN." >&2
+ fi
+
+ # Base64 of the single-source config file (manifests/ax.yaml), injected into
+ # each ActorTemplate's AX_CONFIG_CONTENT env so substrate actors read their config
+ # without a mounted ConfigMap.
+ local ax_config_content
+ ax_config_content="$(base64 < manifests/ax.yaml | tr -d '\n')"
+
+ # Common substitutions applied to every rendered manifest.
+ local render_sed=(
+ -e "s|\${GEMINI_API_KEY}|${GEMINI_API_KEY}|g"
+ -e "s|\${AX_SNAPSHOTS_BUCKET}|${AX_SNAPSHOTS_BUCKET}|g"
+ -e "s|\${AX_IMAGE}|${ax_image}|g"
+ -e "s|\${AX_ANTIGRAVITY_IMAGE}|${ax_antigravity_image}|g"
+ -e "s|\${ATEOM_IMAGE}|${ateom_image}|g"
+ -e "s|\${GOOGLE_CLOUD_PROJECT}|${GOOGLE_CLOUD_PROJECT:-}|g"
+ -e "s|\${AX_CONFIG_CONTENT}|${ax_config_content}|g"
+ )
+
+ # Render and apply the core manifest (namespace, harnesses, ax-server).
+ if ! sed "${render_sed[@]}" manifests/ax-deployment.yaml | run_kubectl apply -f -; then
+ echo >&2
+ echo "Error: cluster rejected the manifest. An \"unknown field\" error usually means the" >&2
+ echo "cluster's substrate is incompatible with AX's go.mod pin — see" >&2
+ echo "manifests/README.md (\"Substrate compatibility\")." >&2
+ exit 1
+ fi
+
+ # Create/update the controller ConfigMap from the single-source config file
+ # (manifests/ax.yaml) -- the same file that seeds the actors' AX_CONFIG_CONTENT.
+ # ax-server mounts it at /etc/ax/ax.yaml.
+ run_kubectl -n ax create configmap ax-server-config \
+ --from-file=ax.yaml=manifests/ax.yaml \
+ --dry-run=client -o yaml | run_kubectl apply -f -
+
+ # Create/update the event-log Secret with the DSN (and, for the bundled Postgres,
+ # its password). ax-server reads AX_EVENTLOG_DSN from this Secret's dsn key.
+ local secret_args=(--from-literal=dsn="${pg_dsn}")
+ if [[ "${DEPLOY_POSTGRES}" == "true" ]]; then
+ secret_args+=(--from-literal=password="${pg_password}")
+ fi
+ run_kubectl -n ax create secret generic ax-eventlog-postgres "${secret_args[@]}" \
+ --dry-run=client -o yaml | run_kubectl apply -f -
+
+ # With --deploy-postgres, create the bundled Postgres and wait for it to be
+ # ready before ax-server relies on it.
+ if [[ "${DEPLOY_POSTGRES}" == "true" ]]; then
+ run_kubectl apply -f manifests/ax-postgres.yaml
+ log_step "wait for statefulset/ax-eventlog-postgres to be ready"
+ wait_with_spinner "waiting for postgres (timeout ${AX_WAIT_TIMEOUT:-5m})" \
+ run_kubectl -n ax rollout status statefulset/ax-eventlog-postgres \
+ --timeout="${AX_WAIT_TIMEOUT:-5m}"
+ fi
+
+ # Wait for the antigravity ActorTemplate's golden snapshot to be ready.
+ log_step "wait for actortemplate/ax-harness-antigravity-template to be Ready"
+ wait_with_spinner "waiting for antigravity golden snapshot (timeout ${AX_WAIT_TIMEOUT:-5m})" \
+ run_kubectl wait --for=condition=Ready actortemplate/ax-harness-antigravity-template \
+ -n ax --timeout="${AX_WAIT_TIMEOUT:-5m}"
+
+ # Wait for the interactions ActorTemplate's golden snapshot to be ready.
+ log_step "wait for actortemplate/ax-harness-interactions-template to be Ready"
+ wait_with_spinner "waiting for interactions golden snapshot (timeout ${AX_WAIT_TIMEOUT:-5m})" \
+ run_kubectl wait --for=condition=Ready actortemplate/ax-harness-interactions-template \
+ -n ax --timeout="${AX_WAIT_TIMEOUT:-5m}"
+
+ echo ""
+ echo "Forward the AX server by running the following command (optional)"
+ echo "kubectl port-forward -n ax rs/ax-server 8494:8494"
+ echo ""
+ echo "Then, run: ax exec --server localhost:8494 [--harness antigravity|antigravity-interactions]"
+}
+
+# delete_ax_server removes the AX server and harness resources but preserves the
+# event-log database: it leaves the namespace and the Postgres subsystem
+# (Service/Secret/StatefulSet and its PVC) intact so a later redeploy reuses the
+# existing data.
+delete_ax_server() {
+ log_step "delete_ax_server"
+
+ run_kubectl -n ax delete --ignore-not-found \
+ replicaset/ax-server \
+ configmap/ax-server-config \
+ actortemplate/ax-harness-antigravity-template \
+ actortemplate/ax-harness-interactions-template \
+ workerpool/ax-harness-workerpool
+}
+
+if [ "$#" -eq 0 ]; then
+ usage
+ exit 1
+fi
+
+# Event-log Postgres: by default ax-server connects to an existing Postgres via
+# the AX_EVENTLOG_DSN env var. --deploy-postgres additionally creates a bundled
+# Postgres in-cluster (for testing on Substrate).
+DEPLOY_POSTGRES=false
+
+# If -h or --help appears anywhere in the command line, print the usage and exit.
+for arg in "$@"; do
+ case "$arg" in
+ -h|--help)
+ usage
+ exit 0
+ ;;
+ --deploy-postgres)
+ DEPLOY_POSTGRES=true
+ ;;
+ esac
+done
+
+while [[ "$#" -gt 0 ]]; do
+ case $1 in
+ --deploy-ax-server) deploy_ax_server ;;
+ --delete-ax-server) delete_ax_server ;;
+ --deploy-postgres) ;; # resolved in the pre-scan above
+ *)
+ echo "Error: unknown option: $1" >&2
+ echo ""
+ usage
+ exit 1
+ ;;
+ esac
+ shift
+done
diff --git a/internal/cmd/e2e/main.go b/internal/cmd/e2e/main.go
new file mode 100644
index 00000000..0e48dc6e
--- /dev/null
+++ b/internal/cmd/e2e/main.go
@@ -0,0 +1,133 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package main implements an end-to-end demonstration of the Antigravity harness
+// integration with AX Controller V2.
+//
+// TO RUN THIS E2E DEMONSTRATION:
+//
+// Step 1: Start the Python gRPC Harness Server (in a separate terminal or background):
+//
+// PYTHONPATH=python:. /Users/anjalisridhar/.gemini/jetski/worktrees/harness-interface-3/implement-agy-sdk-streaming-20260528/.venv/bin/python python/antigravity/harness_server.py --port 50053
+//
+// Step 2: Run this Go E2E client:
+//
+// go run internal/cmd/e2e/main.go
+package main
+
+import (
+ "context"
+ "fmt"
+ "log"
+ "net"
+ "os"
+ "time"
+
+ "github.com/google/ax/internal/controller"
+ "github.com/google/ax/internal/controller/eventlog"
+ "github.com/google/ax/internal/controller/eventlog/eventlogtest"
+ "github.com/google/ax/internal/harness/antigravity"
+ "github.com/google/ax/proto"
+)
+
+func main() {
+ ctx := context.Background()
+ fmt.Println("==================================================")
+ fmt.Println("AX Controller V2 - E2E Harness Demonstration")
+ fmt.Println("==================================================")
+
+ // -------------------------------------------------------------------------
+ // Demo 1: Unregistered Harness (Should fail)
+ // -------------------------------------------------------------------------
+ fmt.Println("\n--- Demo 1: Unregistered Harness ---")
+ fmt.Println("Requesting 'unregistered-agent'. Exec should fail since no harness is registered.")
+ runDemo(ctx, "unregistered-agent", func(reg *controller.Registry) {
+ // Do not register any harness
+ })
+
+ // -------------------------------------------------------------------------
+ // Demo 2: Antigravity Execution (Requires google-antigravity & GEMINI_API_KEY)
+ // -------------------------------------------------------------------------
+ fmt.Println("\n--- Demo 2: Antigravity Execution ---")
+ fmt.Println("Registering 'antigravity' with real script. Attempting execution.")
+ if os.Getenv("GEMINI_API_KEY") == "" {
+ fmt.Println("WARNING: GEMINI_API_KEY is not set. Execution will likely fail if dependencies are missing, but we will try anyway.")
+ }
+ runDemo(ctx, "antigravity", func(reg *controller.Registry) {
+ // With the new stateful gRPC-based streaming harness, connectivity checks on the
+ // server address replace the build-time checks for local script file presence.
+ address := "localhost:50053"
+ conn, err := net.DialTimeout("tcp", address, 1*time.Second)
+ if err != nil {
+ log.Fatalf("Antigravity harness server not active at %s: %v", address, err)
+ }
+ conn.Close()
+ fmt.Printf("Connected to Antigravity gRPC harness server at %s\n", address)
+ // TODO: add a companion demo that uses autoStart=true (mirrors ax exec local mode).
+ h, _ := antigravity.New(ctx, address, "", false)
+ reg.RegisterHarness("antigravity", h)
+ })
+}
+
+func runDemo(ctx context.Context, harnessID string, setupRegistry func(reg *controller.Registry)) {
+ reg := controller.NewRegistry()
+ setupRegistry(reg)
+
+ log := &eventlogtest.MemoryEventLog{}
+ c, err := controller.New(ctx, controller.Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) {
+ return log, nil
+ },
+ })
+ if err != nil {
+ fmt.Printf("Error creating controller: %v\n", err)
+ return
+ }
+ defer c.Close()
+
+ handler := controller.ExecHandler(func(resp *proto.ExecResponse) error {
+ for _, out := range resp.Outputs {
+ if textContent := out.GetContent().GetText().GetText(); textContent != "" {
+ fmt.Printf("Agent Output: %s\n", textContent)
+ } else if toolCall := out.GetContent().GetToolCall(); toolCall != nil {
+ fmt.Printf("Agent Triggered Tool Call: %s (ID: %s)\n", toolCall.GetFunctionCall().Name, toolCall.Id)
+ }
+ }
+ return nil
+ })
+
+ inputs := []*proto.Message{
+ {
+ Role: "user",
+ Content: &proto.Content{
+ Type: &proto.Content_Text{
+ Text: &proto.TextContent{Text: "What is the weather in New York?"},
+ },
+ },
+ },
+ }
+
+ err = c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: "e2e-conv",
+ Inputs: inputs,
+ HarnessId: harnessID,
+ }, handler)
+
+ if err != nil {
+ fmt.Printf("Execution Failed (as expected if environment is not ready): %v\n", err)
+ } else {
+ fmt.Println("Execution Succeeded!")
+ }
+}
diff --git a/internal/config/config.go b/internal/config/config.go
index 737e43d0..58ec5008 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -12,26 +12,48 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-// Package config provides configuration structures for GAR server.
+// Package config provides configuration for the controller server path.
package config
import (
"fmt"
"os"
- "time"
+ "path/filepath"
- "github.com/google/gar/agent"
+ "github.com/google/ax/internal/harness"
+ "github.com/google/ax/internal/harness/substrate"
"gopkg.in/yaml.v3"
)
-// Config represents the main configuration for GAR server.
+const (
+ // The substrate namespace reserved for AX's built-in harnesses.
+ defaultNamespace = "ax"
+ // The port for harnesses running as substrate actors. Substrate's
+ // actor networking DNATs inbound workerPodIP:80 to the actor.
+ substrateDefaultPort = 80
+ // Harness IDs reserved for AX's built-in harnesses.
+ AntigravityHarnessID = "antigravity"
+ AntigravityInteractionsHarnessID = "antigravity-interactions"
+ // AntigravityHarnessTemplate is the substrate ActorTemplate that runs the
+ // Antigravity harness.
+ AntigravityHarnessTemplate = "ax-harness-antigravity-template"
+ // AntigravityInteractionsTemplate is the substrate ActorTemplate that runs
+ // the Antigravity Interactions harness.
+ AntigravityInteractionsTemplate = "ax-harness-interactions-template"
+)
+
+// Config represents the main configuration for the AX harness server.
type Config struct {
- Server ServerConfig `yaml:"server"`
- EventLog EventLogConfig `yaml:"eventlog"`
- MaxSteps int `yaml:"max_steps"` // Maximum steps per trigger
- HealthCheckInterval time.Duration `yaml:"health_check_interval"` // Health check interval for agents
- GeminiPlanner GeminiPlannerConfig `yaml:"gemini_planner,omitempty"`
- RemoteAgents []RemoteAgentConfig `yaml:"remote_agents,omitempty"` // List of remote agents to register
+ Version string `yaml:"version"`
+ Server ServerConfig `yaml:"server"`
+ EventLog EventLogConfig `yaml:"eventlog"`
+ Harnesses HarnessesConfig `yaml:"harnesses,omitempty"`
+ // Skills sources skills from the Gemini Enterprise Skill Registry into on-disk folders
+ // before the harness starts. It is harness-agnostic: each actor runs exactly
+ // one harness, which consumes the materialized folder(s). Optional; disabled
+ // when no registry is enabled.
+ Skills SkillsConfig `yaml:"skills,omitempty"`
+ Telemetry TelemetryConfig `yaml:"telemetry,omitempty"`
}
// ServerConfig configures the gRPC server.
@@ -39,44 +61,179 @@ type ServerConfig struct {
Address string `yaml:"address"` // Server address to listen on (e.g., ":8494")
}
+// TelemetryConfig configures telemetry options.
+type TelemetryConfig struct {
+ OTLP OTLPConfig `yaml:"otlp,omitempty"`
+}
+
+// OTLPConfig configures the OTLP exporter.
+type OTLPConfig struct {
+ Enabled bool `yaml:"enabled,omitempty"`
+ Endpoint string `yaml:"endpoint,omitempty"` // OTLP collector endpoint (e.g., "localhost:4317")
+}
+
+// SQLiteConfig configures the SQLite event log file.
+type SQLiteConfig struct {
+ Filename string `yaml:"filename"` // SQLite file for event log storage
+}
+
+// PostgresConfig configures the Postgres event log.
+type PostgresConfig struct {
+ DSN string `yaml:"dsn"` // Postgres connection DSN
+}
+
// EventLogConfig configures the event log storage.
type EventLogConfig struct {
- Dir string `yaml:"dir"` // Directory for event log files
+ SQLiteConfig SQLiteConfig `yaml:"sqlite,omitempty"`
+ PostgresConfig PostgresConfig `yaml:"postgres,omitempty"`
+}
+
+// HarnessesConfig groups harnesses to serve by type. There are two categories:
+// - Built-in harnesses (e.g. Antigravity, AntigravityInteractions) whose
+// implementation and container image are provided by AX.
+// - Custom harnesses on substrate whose implementation and container image are
+// provided by the user via their own ActorTemplate.
+type HarnessesConfig struct {
+ Antigravity AntigravityHarnessConfig `yaml:"antigravity,omitempty"`
+ AntigravityInteractions AntigravityInteractionsHarnessConfig `yaml:"antigravity-interactions,omitempty"`
+ Substrate []SubstrateHarnessConfig `yaml:"substrate,omitempty"`
+}
+
+// AntigravityHarnessConfig registers the built-in Antigravity harness.
+type AntigravityHarnessConfig struct {
+ Default bool `yaml:"default,omitempty"`
+ Endpoint string `yaml:"endpoint,omitempty"` // HarnessService address
+}
+
+// AntigravityInteractionsHarnessConfig registers the built-in Antigravity
+// Interactions harness (over the Vertex GenAI Interactions API).
+type AntigravityInteractionsHarnessConfig struct {
+ Default bool `yaml:"default,omitempty"` // Default harness or not
+ Agent string `yaml:"agent,omitempty"` // Interactions API agent (default: antigravityinteractions.DefaultAgent)
+ // SystemInstruction is a free-form system prompt sent on every turn.
+ SystemInstruction string `yaml:"system_instruction,omitempty"`
}
-// ControllerConfig configures the controller behavior.
-type ControllerConfig struct {
- MaxSteps int `yaml:"max_steps"` // Maximum steps per trigger
- HealthCheckInterval time.Duration `yaml:"health_check_interval"` // Health check interval for agents
+// SkillsConfig configures optional skill sources (top-level, harness-agnostic).
+// Today the only source type is the Gemini Enterprise Skill Registry; it may source from more
+// than one registry (e.g. a shared org-wide registry plus a team-specific one),
+// each with its own project/location, selection, and target directory.
+type SkillsConfig struct {
+ Registries []SkillsRegistryConfig `yaml:"registries,omitempty"`
}
-// GeminiPlannerConfig configures the Gemini-based planner.
-// Note: API key is not configurable here for security reasons.
-// Set GEMINI_API_KEY environment variable instead.
-type GeminiPlannerConfig struct {
- Model string `yaml:"model,omitempty"` // Model name
- Temperature float32 `yaml:"temperature,omitempty"`
- MaxTokens int32 `yaml:"max_tokens,omitempty"`
- Timeout time.Duration `yaml:"timeout,omitempty"`
- ContextWindow int `yaml:"context_window,omitempty"`
- SystemPrompt string `yaml:"system_prompt,omitempty"`
+// Validate checks the (top-level) skills config.
+func (s SkillsConfig) Validate() error {
+ for i := range s.Registries {
+ if err := s.Registries[i].validate(i); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+// validate enforces that, when this registry source is enabled, exactly one
+// selection mode is set (skills, query, or all) — a config-level "oneof". idx is
+// the registry's index within the registries list, for error context.
+func (rc SkillsRegistryConfig) validate(idx int) error {
+ if !rc.Enabled {
+ return nil
+ }
+ if rc.TargetDir == "" {
+ return fmt.Errorf("skills.registries[%d] requires target_dir (skills materialize to //)", idx)
+ }
+ modes := 0
+ if len(rc.Skills) > 0 {
+ modes++
+ }
+ if rc.Query != nil {
+ modes++
+ }
+ if rc.All {
+ modes++
+ }
+ switch {
+ case modes == 0:
+ return fmt.Errorf("skills.registries[%d] requires exactly one selection mode (set one of skills, query, or all)", idx)
+ case modes > 1:
+ return fmt.Errorf("skills.registries[%d] sets multiple selection modes; set exactly one of skills, query, or all", idx)
+ }
+ if rc.Query != nil && rc.Query.Text == "" {
+ return fmt.Errorf("skills.registries[%d].query requires a non-empty text", idx)
+ }
+ return nil
}
-// RemoteAgentConfig configures a remote agent to register on startup.
-type RemoteAgentConfig struct {
- ID string `yaml:"id"` // Unique agent identifier
- Name string `yaml:"name"` // Human-readable name
- Description string `yaml:"description"` // Description of agent capabilities
- Address string `yaml:"address"` // gRPC address (e.g., "localhost:50051")
- Metadata map[string]string `yaml:"metadata,omitempty"` // Optional metadata
+// SkillsRegistryConfig sources agentskills.io skills from the Gemini Skill
+// Registry. When Enabled, exactly one selection mode should be set (Skills,
+// Query, or All); if none is set, all skills are materialized.
+type SkillsRegistryConfig struct {
+ Enabled bool `yaml:"enabled,omitempty"`
+ // Project owns the skills (projects/{Project}/locations/{Location}/skills).
+ // Empty falls back to the GOOGLE_CLOUD_PROJECT environment variable.
+ Project string `yaml:"project,omitempty"`
+ // Location is the registry region, e.g. "us-central1". Empty falls back to
+ // GOOGLE_CLOUD_LOCATION, then a built-in default.
+ Location string `yaml:"location,omitempty"`
+
+ // --- selection (choose one) ---
+
+ // Skills is an explicit allowlist of skills, each optionally pinned to a
+ // revision. Takes precedence over Query and All.
+ Skills []SkillRefConfig `yaml:"skills,omitempty"`
+ // Query is a semantic search selection; its top matches are materialized.
+ // TopK lives inside it because it only has meaning for a query.
+ Query *SkillsQueryConfig `yaml:"query,omitempty"`
+ // All materializes every skill in the project/location (used when neither
+ // Skills nor Query is set; can also be set explicitly).
+ All bool `yaml:"all,omitempty"`
+
+ // TargetDir is the base directory skills are materialized into: each skill
+ // is written to //. Required when Enabled.
+ TargetDir string `yaml:"target_dir,omitempty"`
}
-type LocalAgentConfig struct {
- ID string // Unique agent identifier
- Name string // Human-readable name
- Description string // Description of agent capabilities
- Metadata map[string]string // Optional metadata
- Agent agent.Agent // In-process agent instance (not in YAML)
+// SkillsQueryConfig selects skills by semantic search.
+type SkillsQueryConfig struct {
+ // Text is the semantic search string (required for query selection).
+ Text string `yaml:"text"`
+ // TopK bounds the number of matches (<=0 uses the server default).
+ TopK int `yaml:"top_k,omitempty"`
+}
+
+// SkillRefConfig identifies a skill to materialize, optionally pinned.
+type SkillRefConfig struct {
+ ID string `yaml:"id"`
+ Revision string `yaml:"revision,omitempty"`
+}
+
+// SubstrateHarnessConfig registers a custom harness deployed on substrate
+// from a user-provided container image.
+type SubstrateHarnessConfig struct {
+ ID string `yaml:"id"` // Unique harness identifier
+ Namespace string `yaml:"namespace"` // ActorTemplate namespace (user-owned, not "ax")
+ Template string `yaml:"template"` // ActorTemplate name
+ Port int `yaml:"port,omitempty"` // HarnessService port
+ Default bool `yaml:"default,omitempty"` // Default harness or not
+}
+
+// NewHarness builds the custom harness. Custom harnesses always run as substrate
+// actors from the user's own ActorTemplate.
+func (c SubstrateHarnessConfig) NewHarness(endpoint string) (harness.Harness, error) {
+ port := c.Port
+ if port == 0 {
+ port = substrateDefaultPort
+ }
+ return newSubstrateHarness(c.ID, endpoint, c.Namespace, c.Template, port)
+}
+
+// newSubstrateHarness brings up a harness that is deployed as a substrate actor.
+func newSubstrateHarness(harnessID, endpoint, namespace, template string, port int) (harness.Harness, error) {
+ sh, err := substrate.New(harnessID, endpoint, namespace, template, port)
+ if err != nil {
+ return nil, err
+ }
+ return sh, nil
}
// LoadFromFile loads configuration from a YAML file.
@@ -85,36 +242,35 @@ func LoadFromFile(path string) (*Config, error) {
if err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
+ return LoadFromBytes(data)
+}
+// LoadFromBytes parses configuration from YAML bytes and applies defaults.
+func LoadFromBytes(data []byte) (*Config, error) {
var cfg Config
if err := yaml.Unmarshal(data, &cfg); err != nil {
- return nil, fmt.Errorf("failed to parse config file: %w", err)
+ return nil, fmt.Errorf("failed to parse config: %w", err)
}
- // Set defaults
cfg.setDefaults()
return &cfg, nil
}
+// DefaultConfig returns a configuration with default values set.
+func DefaultConfig() *Config {
+ var cfg Config
+ cfg.setDefaults()
+ return &cfg
+}
+
// setDefaults sets default values for optional fields.
func (c *Config) setDefaults() {
- // Server defaults
if c.Server.Address == "" {
c.Server.Address = ":8494"
}
-
- // EventLog defaults
- if c.EventLog.Dir == "" {
- c.EventLog.Dir = "eventlog"
- }
-
- // Controller defaults
- if c.MaxSteps == 0 {
- c.MaxSteps = 100
- }
- if c.HealthCheckInterval == 0 {
- c.HealthCheckInterval = 30 * time.Second
+ if c.EventLog.SQLiteConfig.Filename == "" {
+ c.EventLog.SQLiteConfig.Filename = "eventlog/log.sqlite"
}
}
@@ -123,14 +279,54 @@ func (c *Config) Validate() error {
if c.Server.Address == "" {
return fmt.Errorf("server.address is required")
}
- if c.EventLog.Dir == "" {
- return fmt.Errorf("eventlog.dir is required")
+ if c.EventLog.PostgresConfig.DSN == "" && c.EventLog.SQLiteConfig.Filename == "" {
+ return fmt.Errorf("eventlog requires either postgres.dsn or sqlite.filename")
+ }
+
+ var defaultCount int
+ if c.Harnesses.Antigravity.Default {
+ defaultCount++
+ }
+ if c.Harnesses.AntigravityInteractions.Default {
+ defaultCount++
}
- if c.MaxSteps <= 0 {
- return fmt.Errorf("max_steps must be positive")
+
+ for _, sc := range c.Harnesses.Substrate {
+ if sc.ID == "" {
+ return fmt.Errorf("substrate harness id is required")
+ }
+ if sc.ID == AntigravityHarnessID || sc.ID == AntigravityInteractionsHarnessID {
+ return fmt.Errorf("substrate harness id %q is reserved for built-in harnesses", sc.ID)
+ }
+ if sc.Namespace == "" {
+ return fmt.Errorf("substrate harness %q: namespace is required", sc.ID)
+ }
+ if sc.Namespace == defaultNamespace {
+ return fmt.Errorf("substrate harness %q: namespace %q is reserved for built-in harnesses", sc.ID, defaultNamespace)
+ }
+ if sc.Template == "" {
+ return fmt.Errorf("substrate harness %q: template is required", sc.ID)
+ }
+ if sc.Default {
+ defaultCount++
+ }
}
- if c.HealthCheckInterval <= 0 {
- return fmt.Errorf("health_check_interval must be positive")
+
+ if defaultCount > 1 {
+ return fmt.Errorf("multiple harnesses marked as default")
}
+
+ if err := c.Skills.Validate(); err != nil {
+ return err
+ }
+
return nil
}
+
+func AXAssetsDir() (string, error) {
+ home, err := os.UserHomeDir()
+ if err != nil {
+ return "", fmt.Errorf("resolving home directory: %w", err)
+ }
+ return filepath.Join(home, ".ax"), nil
+}
diff --git a/internal/config/config_test.go b/internal/config/config_test.go
new file mode 100644
index 00000000..baebb037
--- /dev/null
+++ b/internal/config/config_test.go
@@ -0,0 +1,347 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package config
+
+import (
+ "strings"
+ "testing"
+
+ "gopkg.in/yaml.v3"
+)
+
+func TestSubstrateNewHarness(t *testing.T) {
+ h, err := SubstrateHarnessConfig{ID: "c", Namespace: "team-ns", Template: "custom-template"}.NewHarness("api.ate-system.svc:443")
+ if err != nil {
+ t.Fatalf("NewHarness: %v", err)
+ }
+ if h == nil {
+ t.Fatal("expected non-nil harness")
+ }
+}
+
+// validConfig returns a config that passes Validate, that tests can mutate.
+func validConfig() *Config {
+ c := DefaultConfig()
+ c.Harnesses = HarnessesConfig{
+ Antigravity: AntigravityHarnessConfig{Default: true},
+ Substrate: []SubstrateHarnessConfig{
+ {ID: "custom", Namespace: "team-ns", Template: "custom-template"},
+ },
+ }
+ return c
+}
+
+func TestValidate_ValidConfig(t *testing.T) {
+ if err := validConfig().Validate(); err != nil {
+ t.Fatalf("Validate() = %v, want nil", err)
+ }
+}
+
+func TestValidate_CustomIDRequired(t *testing.T) {
+ c := validConfig()
+ c.Harnesses.Substrate[0].ID = ""
+ err := c.Validate()
+ if err == nil || !strings.Contains(err.Error(), "substrate harness id") {
+ t.Fatalf("Validate() = %v, want substrate id error", err)
+ }
+}
+
+func TestValidate_CustomIDReserved(t *testing.T) {
+ c := validConfig()
+ c.Harnesses.Substrate[0].ID = "antigravity"
+ err := c.Validate()
+ if err == nil || !strings.Contains(err.Error(), "reserved") {
+ t.Fatalf("Validate() = %v, want reserved id error", err)
+ }
+}
+
+func TestValidate_CustomNamespaceRequired(t *testing.T) {
+ c := validConfig()
+ c.Harnesses.Substrate[0].Namespace = ""
+ err := c.Validate()
+ if err == nil || !strings.Contains(err.Error(), "namespace is required") {
+ t.Fatalf("Validate() = %v, want namespace-required error", err)
+ }
+}
+
+func TestValidate_CustomNamespaceReserved(t *testing.T) {
+ c := validConfig()
+ c.Harnesses.Substrate[0].Namespace = defaultNamespace
+ err := c.Validate()
+ if err == nil || !strings.Contains(err.Error(), "reserved") {
+ t.Fatalf("Validate() = %v, want reserved-namespace error", err)
+ }
+}
+
+func TestValidate_CustomTemplateRequired(t *testing.T) {
+ c := validConfig()
+ c.Harnesses.Substrate[0].Template = ""
+ err := c.Validate()
+ if err == nil || !strings.Contains(err.Error(), "template is required") {
+ t.Fatalf("Validate() = %v, want template-required error", err)
+ }
+}
+
+func TestValidate_MultipleDefaults(t *testing.T) {
+ c := validConfig()
+ c.Harnesses.Substrate[0].Default = true
+ err := c.Validate()
+ if err == nil || !strings.Contains(err.Error(), "multiple harnesses marked as default") {
+ t.Fatalf("Validate() = %v, want multiple defaults error", err)
+ }
+}
+
+func TestValidate_InteractionsIDReserved(t *testing.T) {
+ c := validConfig()
+ c.Harnesses.Substrate[0].ID = "antigravity-interactions"
+ err := c.Validate()
+ if err == nil || !strings.Contains(err.Error(), "reserved") {
+ t.Fatalf("Validate() = %v, want reserved id error", err)
+ }
+}
+
+func TestValidate_InteractionsValid(t *testing.T) {
+ c := validConfig()
+ c.Harnesses.AntigravityInteractions = AntigravityInteractionsHarnessConfig{
+ Agent: "projects/p/locations/global/agents/a",
+ }
+ if err := c.Validate(); err != nil {
+ t.Fatalf("Validate() = %v, want nil", err)
+ }
+}
+
+func TestLoadFromFile_Version(t *testing.T) {
+ data := `
+version: "1.2.3"
+server:
+ address: ":8080"
+eventlog:
+ sqlite:
+ filename: "test.db"
+`
+ var cfg Config
+ if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
+ t.Fatalf("Unmarshal failed: %v", err)
+ }
+ if cfg.Version != "1.2.3" {
+ t.Errorf("cfg.Version = %q, want %q", cfg.Version, "1.2.3")
+ }
+}
+
+func TestLoadFromBytes(t *testing.T) {
+ cfg, err := LoadFromBytes([]byte(`
+version: v1alpha
+harnesses:
+ antigravity:
+ default: true
+`))
+ if err != nil {
+ t.Fatalf("LoadFromBytes: %v", err)
+ }
+ if cfg.Version != "v1alpha" {
+ t.Errorf("Version = %q, want %q", cfg.Version, "v1alpha")
+ }
+ if !cfg.Harnesses.Antigravity.Default {
+ t.Error("Harnesses.Antigravity.Default = false, want true")
+ }
+ // setDefaults must run (same as LoadFromFile).
+ if got, want := cfg.Server.Address, ":8494"; got != want {
+ t.Errorf("Server.Address = %q, want default %q", got, want)
+ }
+}
+
+// TestLoadFromBytes_Invalid returns an error on malformed YAML.
+func TestLoadFromBytes_Invalid(t *testing.T) {
+ if _, err := LoadFromBytes([]byte("harnesses: [unterminated")); err == nil {
+ t.Fatal("LoadFromBytes(invalid): got nil error, want error")
+ }
+}
+
+func TestParse_SkillsByID(t *testing.T) {
+ data := `
+skills:
+ registries:
+ - enabled: true
+ project: my-proj
+ location: us-central1
+ target_dir: /tmp/ax-skills
+ skills:
+ - id: emoji
+ - id: lowercase
+ revision: rev-3
+`
+ var cfg Config
+ if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
+ t.Fatalf("Unmarshal failed: %v", err)
+ }
+ if len(cfg.Skills.Registries) != 1 {
+ t.Fatalf("registries = %d, want 1", len(cfg.Skills.Registries))
+ }
+ reg := cfg.Skills.Registries[0]
+ if !reg.Enabled || reg.Project != "my-proj" || reg.Location != "us-central1" || reg.TargetDir != "/tmp/ax-skills" {
+ t.Errorf("registry = %+v, want enabled my-proj/us-central1 /tmp/ax-skills", reg)
+ }
+ if len(reg.Skills) != 2 {
+ t.Fatalf("skills = %d, want 2", len(reg.Skills))
+ }
+ if reg.Skills[0].ID != "emoji" || reg.Skills[0].Revision != "" {
+ t.Errorf("skills[0] = %+v, want {emoji }", reg.Skills[0])
+ }
+ if reg.Skills[1].ID != "lowercase" || reg.Skills[1].Revision != "rev-3" {
+ t.Errorf("skills[1] = %+v, want {lowercase rev-3}", reg.Skills[1])
+ }
+ if reg.Query != nil {
+ t.Errorf("Query = %+v, want nil in by-id mode", reg.Query)
+ }
+}
+
+func TestParse_SkillsByQuery(t *testing.T) {
+ data := `
+skills:
+ registries:
+ - enabled: true
+ project: my-proj
+ target_dir: /tmp/ax-skills
+ query:
+ text: "find gcp skills"
+ top_k: 5
+`
+ var cfg Config
+ if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
+ t.Fatalf("Unmarshal failed: %v", err)
+ }
+ regs := cfg.Skills.Registries
+ if len(regs) != 1 || regs[0].Query == nil {
+ t.Fatalf("registries = %+v, want one with a query block", regs)
+ }
+ if regs[0].Query.Text != "find gcp skills" || regs[0].Query.TopK != 5 {
+ t.Errorf("Query = %+v, want {find gcp skills 5}", *regs[0].Query)
+ }
+}
+
+func TestParse_MultipleRegistries(t *testing.T) {
+ data := `
+skills:
+ registries:
+ - enabled: true
+ project: org-proj
+ target_dir: /tmp/org
+ all: true
+ - enabled: true
+ project: team-proj
+ target_dir: /tmp/team
+ skills:
+ - id: teamskill
+`
+ var cfg Config
+ if err := yaml.Unmarshal([]byte(data), &cfg); err != nil {
+ t.Fatalf("Unmarshal failed: %v", err)
+ }
+ regs := cfg.Skills.Registries
+ if len(regs) != 2 {
+ t.Fatalf("registries = %d, want 2", len(regs))
+ }
+ if regs[0].Project != "org-proj" || regs[0].TargetDir != "/tmp/org" || !regs[0].All {
+ t.Errorf("registries[0] = %+v, want org-proj /tmp/org all", regs[0])
+ }
+ if regs[1].Project != "team-proj" || regs[1].TargetDir != "/tmp/team" || len(regs[1].Skills) != 1 {
+ t.Errorf("registries[1] = %+v, want team-proj /tmp/team [teamskill]", regs[1])
+ }
+ // Each registry has exactly one selection mode + target_dir, so validation passes.
+ if err := cfg.Skills.Validate(); err != nil {
+ t.Errorf("Skills.Validate() = %v, want nil", err)
+ }
+}
+
+func TestValidate_SkillsSelectionOneof(t *testing.T) {
+ // withRegistry returns a valid config carrying a single top-level registry.
+ // It fills TargetDir (a required field) unless the caller already set one, so
+ // selection-mode assertions aren't masked by the target_dir check.
+ withRegistry := func(rc SkillsRegistryConfig) *Config {
+ if rc.Enabled && rc.TargetDir == "" {
+ rc.TargetDir = "/tmp/skills"
+ }
+ c := validConfig()
+ c.Skills.Registries = []SkillsRegistryConfig{rc}
+ return c
+ }
+
+ t.Run("disabled skips validation", func(t *testing.T) {
+ if err := withRegistry(SkillsRegistryConfig{Enabled: false}).Validate(); err != nil {
+ t.Fatalf("Validate() = %v, want nil", err)
+ }
+ })
+
+ t.Run("enabled without target_dir is an error", func(t *testing.T) {
+ c := validConfig()
+ c.Skills.Registries = []SkillsRegistryConfig{
+ {Enabled: true, Project: "p", All: true}, // valid mode, but no target_dir
+ }
+ err := c.Validate()
+ if err == nil || !strings.Contains(err.Error(), "target_dir") {
+ t.Fatalf("Validate() = %v, want target_dir error", err)
+ }
+ })
+
+ t.Run("zero selection modes is an error", func(t *testing.T) {
+ err := withRegistry(SkillsRegistryConfig{Enabled: true, Project: "p"}).Validate()
+ if err == nil || !strings.Contains(err.Error(), "exactly one selection mode") {
+ t.Fatalf("Validate() = %v, want exactly-one error", err)
+ }
+ })
+
+ t.Run("multiple selection modes is an error", func(t *testing.T) {
+ err := withRegistry(SkillsRegistryConfig{
+ Enabled: true, Project: "p",
+ Skills: []SkillRefConfig{{ID: "emoji"}},
+ All: true,
+ }).Validate()
+ if err == nil || !strings.Contains(err.Error(), "multiple selection modes") {
+ t.Fatalf("Validate() = %v, want multiple-modes error", err)
+ }
+ })
+
+ t.Run("exactly one is valid", func(t *testing.T) {
+ err := withRegistry(SkillsRegistryConfig{
+ Enabled: true, Project: "p",
+ Skills: []SkillRefConfig{{ID: "emoji"}},
+ }).Validate()
+ if err != nil {
+ t.Fatalf("Validate() = %v, want nil", err)
+ }
+ })
+
+ t.Run("query with empty text is an error", func(t *testing.T) {
+ err := withRegistry(SkillsRegistryConfig{
+ Enabled: true, Project: "p",
+ Query: &SkillsQueryConfig{Text: ""},
+ }).Validate()
+ if err == nil || !strings.Contains(err.Error(), "non-empty text") {
+ t.Fatalf("Validate() = %v, want empty-text error", err)
+ }
+ })
+
+ t.Run("second registry invalid is caught", func(t *testing.T) {
+ c := validConfig()
+ c.Skills.Registries = []SkillsRegistryConfig{
+ {Enabled: true, Project: "p", TargetDir: "/tmp/a", All: true},
+ {Enabled: true, Project: "p", TargetDir: "/tmp/b"}, // no selection mode
+ }
+ err := c.Validate()
+ if err == nil || !strings.Contains(err.Error(), "registries[1]") {
+ t.Fatalf("Validate() = %v, want error citing registries[1]", err)
+ }
+ })
+}
diff --git a/internal/controller/controller.go b/internal/controller/controller.go
index 2034ac4c..a3775284 100644
--- a/internal/controller/controller.go
+++ b/internal/controller/controller.go
@@ -13,152 +13,180 @@
// limitations under the License.
// Package controller implements the single-writer orchestrator that coordinates
-// agentic loops, manages sessions, and communicates with local and remote agents.
+// agentic loops, manages executions, and communicates with local and remote agents.
package controller
import (
"context"
"fmt"
- "sync"
- "time"
+ "log/slog"
- "github.com/google/gar/agent"
- "github.com/google/gar/internal/eventlog"
- "github.com/google/gar/proto"
+ "github.com/google/ax/internal/controller/eventlog"
+ "github.com/google/ax/proto"
+ "google.golang.org/protobuf/encoding/protojson"
+ "google.golang.org/protobuf/types/known/structpb"
)
+type ExecHandler func(resp *proto.ExecResponse) error
+
// Controller is the main controller that coordinates all components.
// It acts as a single-writer system for managing agentic loops.
type Controller struct {
- inFlightSessionsMu sync.Mutex
- inFlightSessions map[string]struct{}
-
- sessionManager *SessionManager
- registry *Registry
- loopExecutor *LoopExecutor
+ registry *Registry
+ eventLog eventlog.EventLog
}
// Config configures the controller.
type Config struct {
- EventLogFactory eventlog.EventLogFactory
- PlanFunc PlanFunc
+ Registry *Registry
+ EventLogBuilder eventlog.EventLogBuilder
+}
- // TODO(jbd): Add CompactionFunc.
+// New creates a new controller instance.
+func New(ctx context.Context, cfg Config) (*Controller, error) {
+ if cfg.Registry == nil {
+ return nil, fmt.Errorf("registry is required")
+ }
+ if cfg.EventLogBuilder == nil {
+ return nil, fmt.Errorf("event log builder is required")
+ }
+ eventLog, err := cfg.EventLogBuilder()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create event log: %w", err)
+ }
- HealthCheckInterval time.Duration
- MaxSteps int
+ return &Controller{
+ registry: cfg.Registry,
+ eventLog: eventLog,
+ }, nil
}
-// New creates a new controller instance.
-func New(ctx context.Context, config Config) (*Controller, error) {
- if config.MaxSteps == 0 {
- config.MaxSteps = 100
- }
- if config.HealthCheckInterval == 0 {
- config.HealthCheckInterval = 30 * time.Second
- }
- if config.EventLogFactory == nil {
- config.EventLogFactory = func(sessionID string) (eventlog.EventLog, error) {
- return eventlog.NewFileEventLog(eventlog.FileConfig{
- SessionID: sessionID,
- Dir: "eventlog",
- })
- }
+// Exec executes a new agentic loop execution or resumes an existing one.
+// If id is empty, a UUID will be generated.
+// If the execution already exists, it will be resumed with optional new inputs.
+func (d *Controller) Exec(ctx context.Context, req *proto.ExecRequest, handler ExecHandler) error {
+ if req.ConversationId == "" {
+ return fmt.Errorf("conversation_id is required")
}
- // Initialize session manager with file-based event logs
- sessionManager := NewSessionManager(config.EventLogFactory)
+ // TODO(jbd): Resume an incomplete execution if there exists one.
+ // TODO(jbd): Enable bringing a remote harness that implements HarnessService.
+ // TODO(anj): We need to consolidate agents and harness registration.
+ // Adding harness registration support temporarily.
+ l := newLogger(d.eventLog, req.ConversationId, req.HarnessId)
+ state, storedHarnessID, err := l.ResumptionState(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to check resumption state: %w", err)
+ }
- // Initialize agent registry
- registry := NewRegistry(config.HealthCheckInterval)
+ // On resume, use the conversation's recorded harness. Using a different harness
+ // for the same conversation is not allowed.
+ if req.HarnessId != "" && storedHarnessID != "" && req.HarnessId != storedHarnessID {
+ return fmt.Errorf("resumption not allowed: harness ID changed from %s to %s", storedHarnessID, req.HarnessId)
+ }
+ harnessID := req.HarnessId
+ // Use the conversations's stored harness if no harness is specified.
+ if harnessID == "" {
+ harnessID = storedHarnessID
+ }
+ // For new conversations, use the default harness if no harness is specified.
+ if harnessID == "" {
+ harnessID = d.registry.defaultHarness
+ }
+ l.harnessID = harnessID
- // Initialize loop executor
- loopExecutor, err := NewLoopExecutor(ctx, LoopConfig{
- Registry: registry,
- SessionManager: sessionManager,
- MaxSteps: config.MaxSteps,
- PlanFunc: config.PlanFunc,
- })
+ h, err := d.registry.Harness(harnessID)
if err != nil {
- return nil, fmt.Errorf("failed to create loop executor: %w", err)
+ return fmt.Errorf("failed to get harness %q: %w", harnessID, err)
}
- return &Controller{
- inFlightSessions: make(map[string]struct{}),
- sessionManager: sessionManager,
- registry: registry,
- loopExecutor: loopExecutor,
- }, nil
-}
+ hhandler := &harnessHandler{
+ logger: l,
+ execHandler: handler,
+ }
-// TriggerSession triggers a new agentic loop session or resumes an existing one.
-// If sessionID is empty, a UUID will be generated.
-// If the session already exists, it will be resumed with optional new inputs.
-// If checkpointID is provided, resumes from that specific checkpoint instead of the latest.
-func (d *Controller) TriggerSession(ctx context.Context, sessionID string, inputs []*proto.Content, handler agent.OutputHandler) error {
- if sessionID == "" {
- return fmt.Errorf("session_id is required")
- }
-
- d.inFlightSessionsMu.Lock()
- _, ok := d.inFlightSessions[sessionID]
- d.inFlightSessionsMu.Unlock()
-
- if ok {
- return fmt.Errorf("session is already in flight")
- }
- defer func() {
- d.inFlightSessionsMu.Lock()
- delete(d.inFlightSessions, sessionID)
- d.inFlightSessionsMu.Unlock()
- }()
-
- // Check if session already exists
- sess, err := d.sessionManager.LoadSession(ctx, sessionID)
- if err == nil && sess == nil {
- // Session doesn't exist - create new session
- // Checkpoint ID is ignored for new sessions
- sess, err = d.sessionManager.NewSession(sessionID)
+ if state == proto.State_STATE_PENDING {
+ // If the state is pending, first try to resume the
+ // pending execution. If the state is COMPLETED or FAILED, start
+ // a new execution.
+ exec, err := h.Start(ctx, req.ConversationId, req.HarnessConfig)
if err != nil {
- return fmt.Errorf("failed to create session: %w", err)
+ return fmt.Errorf("failed to start harness session: %w", err)
+ }
+ defer exec.Close(ctx)
+
+ if err := exec.Run(ctx, hhandler); err != nil {
+ return fmt.Errorf("harness execution failed: %w", err)
}
}
- if sess.State() == proto.State_STATE_FAILED {
- return fmt.Errorf("session has failed and cannot continue")
+ if len(req.Inputs) == 0 {
+ // No more inputs, just return.
+ return nil
}
- // Write input content to session
- for _, content := range inputs {
- if _, err := sess.WriteContentIn(ctx, content); err != nil {
- return fmt.Errorf("failed to write input content: %w", err)
- }
+ exec, err := h.Start(ctx, req.ConversationId, req.HarnessConfig)
+ if err != nil {
+ return fmt.Errorf("failed to start harness session: %w", err)
}
+ defer exec.Close(ctx)
- if err := d.loopExecutor.Execute(ctx, sess, handler); err != nil {
- if err := sess.SetState(ctx, proto.State_STATE_FAILED); err != nil {
- return fmt.Errorf("failed to set session state: %w", err)
- }
- return fmt.Errorf("loop execution failed: %w", err)
+ if err := exec.Queue(ctx, req.Inputs...); err != nil {
+ return fmt.Errorf("failed to queue inputs: %w", err)
+ }
+ // Log inputs before running harness
+ if _, err := l.LogInputs(ctx, req.Inputs, req.HarnessConfig); err != nil {
+ return fmt.Errorf("failed to log inputs: %w", err)
+ }
+ if err := exec.Run(ctx, hhandler); err != nil {
+ return fmt.Errorf("harness execution failed: %w", err)
}
return nil
}
-func (d *Controller) TriggerForkedSession(ctx context.Context, sessionID string, checkpointID string, inputs []*proto.Content, handler agent.OutputHandler) error {
- if sessionID == "" {
- return fmt.Errorf("session_id is required")
+type harnessHandler struct {
+ logger *logger
+ execHandler ExecHandler
+}
+
+func (a *harnessHandler) OnMessage(ctx context.Context, execID string, msg *proto.Message) error {
+ // Log every response received from the harness
+ // TODO(anj): The harness should send the full input sent to get this particular response.
+ seq, err := a.logger.LogOutputs(ctx, []*proto.Message{msg}, proto.State_STATE_PENDING)
+ if err != nil {
+ slog.WarnContext(ctx, "Failed to log streamed message to event log",
+ slog.String("conversation_id", a.logger.conversationID),
+ slog.Any("error", err),
+ )
+ }
+
+ if a.execHandler == nil {
+ return nil
}
- if checkpointID == "" {
- return fmt.Errorf("checkpoint_id is required")
+ return a.execHandler(&proto.ExecResponse{
+ Outputs: []*proto.Message{msg},
+ Seq: seq,
+ })
+}
+
+func (a *harnessHandler) OnComplete(ctx context.Context, execID string) error {
+ // Mark the execution turn as completed in the conversation log
+ if _, err := a.logger.LogOutputs(ctx, nil, proto.State_STATE_COMPLETED); err != nil {
+ slog.WarnContext(ctx, "Failed to log completion event to event log",
+ slog.String("conversation_id", a.logger.conversationID),
+ slog.Any("error", err),
+ )
}
- // TODO(jbd): Fork a new session by copying all content to
- // the provided checkpoint ID, and execute the loop executor.
- panic("not yet implemented")
+ return nil
}
-// LoadSession loads a session from event log.
-func (d *Controller) LoadSession(ctx context.Context, sessionID string) (*Session, error) {
- return d.sessionManager.LoadSession(ctx, sessionID)
+// Delete deletes all events for a specific conversation ID.
+func (d *Controller) Delete(ctx context.Context, conversationID string) error {
+ if conversationID == "" {
+ return fmt.Errorf("conversation_id is required")
+ }
+
+ return d.eventLog.DeleteAll(ctx, conversationID)
}
// Registry returns the agent registry.
@@ -168,9 +196,85 @@ func (d *Controller) Registry() *Registry {
// Close gracefully shuts down the controller.
func (d *Controller) Close() error {
+ if err := d.eventLog.Close(); err != nil {
+ return fmt.Errorf("failed to close event log: %w", err)
+ }
if err := d.registry.Close(); err != nil {
return fmt.Errorf("failed to close registry: %w", err)
}
- d.sessionManager.CloseAll()
return nil
}
+
+func newLogger(
+ el eventlog.EventLog,
+ conversationID string,
+ harnessID string) *logger {
+ return &logger{
+ el: el,
+ conversationID: conversationID,
+ harnessID: harnessID,
+ }
+}
+
+type logger struct {
+ conversationID string
+ execID string
+ el eventlog.EventLog
+ harnessID string
+}
+
+// ResumptionState returns the conversation's current state and the harness it used.
+func (l *logger) ResumptionState(ctx context.Context) (proto.State, string, error) {
+ events, err := l.el.Events(ctx, l.conversationID)
+ if err != nil {
+ return proto.State_STATE_UNSPECIFIED, "", err
+ }
+
+ var state proto.State
+ var harnessID string
+ for _, ev := range events {
+ if harnessID == "" && ev.HarnessId != "" {
+ harnessID = ev.HarnessId
+ }
+ if l.execID == "" || ev.ExecId == l.execID {
+ if ev.State != proto.State_STATE_UNSPECIFIED {
+ state = ev.State
+ }
+ }
+ }
+ return state, harnessID, nil
+}
+
+func (l *logger) LogInputs(ctx context.Context, inputs []*proto.Message, harnessConfig []byte) (int32, error) {
+ // Parse the harness config into a human-readable struct for logging.
+ var cfg *structpb.Struct
+ if len(harnessConfig) > 0 {
+ cfg = &structpb.Struct{}
+ if err := protojson.Unmarshal(harnessConfig, cfg); err != nil {
+ slog.WarnContext(ctx, "Failed to parse harness config for logging",
+ slog.String("conversation_id", l.conversationID),
+ slog.Any("error", err),
+ )
+ cfg = nil
+ }
+ }
+ ev := &proto.ConversationEvent{
+ ConversationId: l.conversationID,
+ ExecId: l.execID,
+ HarnessId: l.harnessID,
+ HarnessConfig: cfg,
+ Messages: inputs,
+ State: proto.State_STATE_PENDING,
+ }
+ return l.el.Append(ctx, ev)
+}
+
+func (l *logger) LogOutputs(ctx context.Context, outputs []*proto.Message, state proto.State) (int32, error) {
+ ev := &proto.ConversationEvent{
+ ConversationId: l.conversationID,
+ ExecId: l.execID,
+ Messages: outputs,
+ State: state,
+ }
+ return l.el.Append(ctx, ev)
+}
diff --git a/internal/controller/controller_test.go b/internal/controller/controller_test.go
new file mode 100644
index 00000000..24ab7559
--- /dev/null
+++ b/internal/controller/controller_test.go
@@ -0,0 +1,665 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controller
+
+import (
+ "context"
+ "fmt"
+ "strings"
+ "testing"
+
+ "github.com/google/ax/internal/controller/eventlog"
+ "github.com/google/ax/internal/controller/eventlog/eventlogtest"
+ "github.com/google/ax/internal/harness"
+ "github.com/google/ax/proto"
+)
+
+type fakeHarness struct{}
+
+func (f *fakeHarness) Start(ctx context.Context, conversationID string, harnessConfig []byte) (harness.Execution, error) {
+ return &fakeExecution{id: "fake-exec-id"}, nil
+}
+
+type fakeExecution struct {
+ id string
+ queued []*proto.Message
+}
+
+func (f *fakeExecution) ID() string {
+ return f.id
+}
+
+func (f *fakeExecution) Queue(ctx context.Context, msg ...*proto.Message) error {
+ f.queued = append(f.queued, msg...)
+ return nil
+}
+
+func (f *fakeExecution) Run(ctx context.Context, handler harness.Handler) error {
+ msg := &proto.Message{
+ Role: "assistant",
+ Content: &proto.Content{
+ Type: &proto.Content_Text{
+ Text: &proto.TextContent{
+ Text: "Hello world",
+ },
+ },
+ },
+ }
+ if err := handler.OnMessage(ctx, f.id, msg); err != nil {
+ return err
+ }
+ return handler.OnComplete(ctx, f.id)
+}
+
+func (f *fakeExecution) Close(ctx context.Context) error {
+ return nil
+}
+
+func TestController2_ExecHelloWorld(t *testing.T) {
+ ctx := context.Background()
+ cid := "test-conversation-id"
+
+ log := &eventlogtest.MemoryEventLog{}
+ reg := NewRegistry()
+ if err := reg.RegisterHarness("default-harness", &fakeHarness{}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetDefaultHarness("default-harness"); err != nil {
+ t.Fatal(err)
+ }
+
+ c, err := New(ctx, Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) {
+ return log, nil
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+
+ var outputs []*proto.Message
+ handler := ExecHandler(func(resp *proto.ExecResponse) error {
+ outputs = append(outputs, resp.Outputs...)
+ return nil
+ })
+
+ inputs := []*proto.Message{
+ {
+ Role: "user",
+ Content: &proto.Content{
+ Type: &proto.Content_Text{
+ Text: &proto.TextContent{Text: "Trigger prompt"},
+ },
+ },
+ },
+ }
+
+ err = c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ Inputs: inputs,
+ }, handler)
+ if err != nil {
+ t.Fatalf("Controller2.Exec failed: %v", err)
+ }
+
+ if len(outputs) != 1 {
+ t.Fatalf("expected exactly 1 output message, got %d", len(outputs))
+ }
+
+ gotText := outputs[0].GetContent().GetText().GetText()
+ if gotText != "Hello world" {
+ t.Errorf("expected 'Hello world' output text response, got %q", gotText)
+ }
+
+ // Verify that events were logged correctly in Conversation Log
+ events, err := log.Events(ctx, cid)
+ if err != nil {
+ t.Fatalf("failed to retrieve logged events: %v", err)
+ }
+
+ if len(events) != 3 {
+ t.Fatalf("expected 3 logged events, got %d", len(events))
+ }
+
+ // 1. First event should be inputs
+ if len(events[0].Messages) != 1 {
+ t.Errorf("expected 1 message in first event, got %d", len(events[0].Messages))
+ } else {
+ gotInputText := events[0].Messages[0].GetContent().GetText().GetText()
+ if gotInputText != "Trigger prompt" {
+ t.Errorf("expected 'Trigger prompt' in logged input, got %q", gotInputText)
+ }
+ }
+ if events[0].State != proto.State_STATE_PENDING {
+ t.Errorf("expected first event state to be PENDING, got %v", events[0].State)
+ }
+
+ // 2. Second event should be output
+ if len(events[1].Messages) != 1 {
+ t.Errorf("expected 1 message in second event, got %d", len(events[1].Messages))
+ } else {
+ gotOutputText := events[1].Messages[0].GetContent().GetText().GetText()
+ if gotOutputText != "Hello world" {
+ t.Errorf("expected 'Hello world' in logged output, got %q", gotOutputText)
+ }
+ }
+ if events[1].State != proto.State_STATE_PENDING {
+ t.Errorf("expected second event state to be PENDING, got %v", events[1].State)
+ }
+
+ // 3. Third event should be completion
+ if len(events[2].Messages) != 0 {
+ t.Errorf("expected 0 messages in third event, got %d", len(events[2].Messages))
+ }
+ if events[2].State != proto.State_STATE_COMPLETED {
+ t.Errorf("expected third event state to be COMPLETED, got %v", events[2].State)
+ }
+
+}
+
+func TestController2_ExecWithAgentID(t *testing.T) {
+ ctx := context.Background()
+ cid := "test-conversation-id"
+
+ log := &eventlogtest.MemoryEventLog{}
+ reg := NewRegistry()
+ if err := reg.RegisterHarness("my-agent", &fakeHarness{}); err != nil {
+ t.Fatal(err)
+ }
+
+ c, err := New(ctx, Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) {
+ return log, nil
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+
+ var outputs []*proto.Message
+ handler := ExecHandler(func(resp *proto.ExecResponse) error {
+ outputs = append(outputs, resp.Outputs...)
+ return nil
+ })
+
+ inputs := []*proto.Message{
+ {
+ Role: "user",
+ Content: &proto.Content{
+ Type: &proto.Content_Text{
+ Text: &proto.TextContent{Text: "Trigger prompt"},
+ },
+ },
+ },
+ }
+
+ err = c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ HarnessId: "my-agent",
+ Inputs: inputs,
+ }, handler)
+ if err != nil {
+ t.Fatalf("Controller2.Exec failed: %v", err)
+ }
+
+ if len(outputs) != 1 {
+ t.Fatalf("expected exactly 1 output message, got %d", len(outputs))
+ }
+
+ gotText := outputs[0].GetContent().GetText().GetText()
+ if gotText != "Hello world" {
+ t.Errorf("expected 'Hello world' output text response, got %q", gotText)
+ }
+}
+
+func TestController2_ExecHarnessNotFound(t *testing.T) {
+ ctx := context.Background()
+ cid := "test-conversation-id"
+
+ log := &eventlogtest.MemoryEventLog{}
+ reg := NewRegistry() // Empty registry, will force error for any requested agent
+
+ c, err := New(ctx, Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) {
+ return log, nil
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+
+ handler := ExecHandler(func(resp *proto.ExecResponse) error {
+ return nil
+ })
+
+ inputs := []*proto.Message{
+ {
+ Role: "user",
+ Content: &proto.Content{
+ Type: &proto.Content_Text{
+ Text: &proto.TextContent{Text: "Trigger prompt"},
+ },
+ },
+ },
+ }
+
+ err = c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ Inputs: inputs,
+ HarnessId: "antigravity",
+ }, handler)
+ if err == nil {
+ t.Fatal("expected error requesting unregistered agent, got nil")
+ }
+}
+
+type testHarness struct {
+ startCalls int
+ startFunc func(ctx context.Context, conversationID string) (harness.Execution, error)
+}
+
+func (c *testHarness) Start(ctx context.Context, conversationID string, harnessConfig []byte) (harness.Execution, error) {
+ c.startCalls++
+ return c.startFunc(ctx, conversationID)
+}
+
+type testExecution struct {
+ id string
+ queueCalls int
+ runCalls int
+ closeCalls int
+ queued []*proto.Message
+ runFunc func(ctx context.Context, execID string, handler harness.Handler) error
+}
+
+func (c *testExecution) ID() string {
+ return c.id
+}
+
+func (c *testExecution) Queue(ctx context.Context, msg ...*proto.Message) error {
+ c.queueCalls++
+ c.queued = append(c.queued, msg...)
+ return nil
+}
+
+func (c *testExecution) Run(ctx context.Context, handler harness.Handler) error {
+ c.runCalls++
+ if c.runFunc != nil {
+ return c.runFunc(ctx, c.id, handler)
+ }
+ return nil
+}
+
+func (c *testExecution) Close(ctx context.Context) error {
+ c.closeCalls++
+ return nil
+}
+
+func TestController2_ExecResumptionFlow(t *testing.T) {
+ // Subtest 1: New Execution with Inputs
+ t.Run("NewExecutionWithInputs", func(t *testing.T) {
+ ctx := context.Background()
+ cid := "new-conv"
+
+ log := &eventlogtest.MemoryEventLog{}
+ reg := NewRegistry()
+
+ var exec *testExecution
+ h := &testHarness{
+ startFunc: func(ctx context.Context, conversationID string) (harness.Execution, error) {
+ exec = &testExecution{
+ id: "exec-new",
+ runFunc: func(ctx context.Context, execID string, handler harness.Handler) error {
+ return handler.OnComplete(ctx, execID)
+ },
+ }
+ return exec, nil
+ },
+ }
+ if err := reg.RegisterHarness("test-agent", h); err != nil {
+ t.Fatal(err)
+ }
+
+ c, err := New(ctx, Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) { return log, nil },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+
+ err = c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ HarnessId: "test-agent",
+ Inputs: []*proto.Message{
+ {Role: "user", Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: "Hello"}}}},
+ },
+ }, func(resp *proto.ExecResponse) error { return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if h.startCalls != 1 {
+ t.Errorf("expected 1 Start call, got %d", h.startCalls)
+ }
+ if exec.queueCalls != 1 {
+ t.Errorf("expected 1 Queue call, got %d", exec.queueCalls)
+ }
+ if exec.runCalls != 1 {
+ t.Errorf("expected 1 Run call, got %d", exec.runCalls)
+ }
+ })
+
+ // Subtest 2: Pending Execution with NO New Inputs
+ t.Run("PendingExecutionWithoutNewInputs", func(t *testing.T) {
+ ctx := context.Background()
+ cid := "pending-no-inputs"
+
+ log := &eventlogtest.MemoryEventLog{}
+ // Seed the event log with a pending event
+ _, err := log.Append(ctx, &proto.ConversationEvent{
+ ConversationId: cid,
+ HarnessId: "test-agent",
+ State: proto.State_STATE_PENDING,
+ Messages: []*proto.Message{
+ {Role: "user", Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: "Initial"}}}},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ reg := NewRegistry()
+
+ var exec *testExecution
+ h := &testHarness{
+ startFunc: func(ctx context.Context, conversationID string) (harness.Execution, error) {
+ exec = &testExecution{
+ id: "exec-pending",
+ runFunc: func(ctx context.Context, execID string, handler harness.Handler) error {
+ return handler.OnComplete(ctx, execID)
+ },
+ }
+ return exec, nil
+ },
+ }
+ if err := reg.RegisterHarness("test-agent", h); err != nil {
+ t.Fatal(err)
+ }
+
+ c, err := New(ctx, Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) { return log, nil },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+
+ err = c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ HarnessId: "test-agent",
+ Inputs: nil, // NO new inputs
+ }, func(resp *proto.ExecResponse) error { return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ if h.startCalls != 1 {
+ t.Errorf("expected 1 Start call, got %d", h.startCalls)
+ }
+ if exec.queueCalls != 0 {
+ t.Errorf("expected 0 Queue calls, got %d", exec.queueCalls)
+ }
+ if exec.runCalls != 1 {
+ t.Errorf("expected 1 Run call, got %d", exec.runCalls)
+ }
+ })
+
+ // Subtest 3: Pending Execution WITH New Inputs
+ t.Run("PendingExecutionWithNewInputs", func(t *testing.T) {
+ ctx := context.Background()
+ cid := "pending-with-inputs"
+
+ log := &eventlogtest.MemoryEventLog{}
+ // Seed the event log with a pending event
+ _, err := log.Append(ctx, &proto.ConversationEvent{
+ ConversationId: cid,
+ HarnessId: "test-agent",
+ State: proto.State_STATE_PENDING,
+ Messages: []*proto.Message{
+ {Role: "user", Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: "Initial"}}}},
+ },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ reg := NewRegistry()
+
+ var execs []*testExecution
+ h := &testHarness{
+ startFunc: func(ctx context.Context, conversationID string) (harness.Execution, error) {
+ exec := &testExecution{
+ id: fmt.Sprintf("exec-%d", len(execs)+1),
+ runFunc: func(ctx context.Context, execID string, handler harness.Handler) error {
+ return handler.OnComplete(ctx, execID)
+ },
+ }
+ execs = append(execs, exec)
+ return exec, nil
+ },
+ }
+ if err := reg.RegisterHarness("test-agent", h); err != nil {
+ t.Fatal(err)
+ }
+
+ c, err := New(ctx, Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) { return log, nil },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+
+ err = c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ HarnessId: "test-agent",
+ Inputs: []*proto.Message{
+ {Role: "user", Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: "New input"}}}},
+ },
+ }, func(resp *proto.ExecResponse) error { return nil })
+ if err != nil {
+ t.Fatal(err)
+ }
+
+ // It should start the harness twice: once for resumption, once for new inputs.
+ if h.startCalls != 2 {
+ t.Errorf("expected 2 Start calls, got %d", h.startCalls)
+ }
+ if len(execs) != 2 {
+ t.Fatalf("expected 2 execution sessions, got %d", len(execs))
+ }
+
+ // The first session (resumption) should NOT have queued inputs and should run.
+ if execs[0].queueCalls != 0 {
+ t.Errorf("expected first session to have 0 Queue calls, got %d", execs[0].queueCalls)
+ }
+ if execs[0].runCalls != 1 {
+ t.Errorf("expected first session to have 1 Run call, got %d", execs[0].runCalls)
+ }
+
+ // The second session (new inputs) should have queued inputs and run.
+ if execs[1].queueCalls != 1 {
+ t.Errorf("expected second session to have 1 Queue call, got %d", execs[1].queueCalls)
+ }
+ if execs[1].runCalls != 1 {
+ t.Errorf("expected second session to have 1 Run call, got %d", execs[1].runCalls)
+ }
+ })
+}
+
+// A conversation started on one harness must resume on that harness when the
+// harness id is empty, not fall back to the registry default.
+func TestExec_ResumeEmptyHarnessUsesStored(t *testing.T) {
+ ctx := context.Background()
+ cid := "resume-empty"
+ log := &eventlogtest.MemoryEventLog{}
+ reg := NewRegistry()
+
+ def := &testHarness{startFunc: func(context.Context, string) (harness.Execution, error) {
+ return &testExecution{}, nil
+ }}
+ stored := &testHarness{startFunc: func(context.Context, string) (harness.Execution, error) {
+ return &testExecution{}, nil
+ }}
+ if err := reg.RegisterHarness("harness-a", def); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.RegisterHarness("harness-b", stored); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetDefaultHarness("harness-a"); err != nil {
+ t.Fatal(err)
+ }
+
+ c, err := New(ctx, Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) { return log, nil },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+
+ noop := ExecHandler(func(*proto.ExecResponse) error { return nil })
+
+ // Turn 1: explicitly run the NON-default harness.
+ if err := c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ HarnessId: "harness-b",
+ Inputs: []*proto.Message{{Role: "user", Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: "hi"}}}}},
+ }, noop); err != nil {
+ t.Fatalf("turn 1: %v", err)
+ }
+
+ // Turn 2: resume WITHOUT a harness id. Must reuse harness-b, not the default.
+ before := stored.startCalls
+ if err := c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ Inputs: []*proto.Message{{Role: "user", Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: "more"}}}}},
+ }, noop); err != nil {
+ t.Fatalf("turn 2 (resume, empty harness): %v", err)
+ }
+ if stored.startCalls <= before {
+ t.Errorf("stored harness not used on resume (startCalls stayed %d)", stored.startCalls)
+ }
+ if def.startCalls != 0 {
+ t.Errorf("default harness used on resume (startCalls=%d), want the stored harness", def.startCalls)
+ }
+}
+
+// Verifies that an explicit harness change during resume is rejected.
+func TestExec_ResumeExplicitDifferentHarnessRejected(t *testing.T) {
+ ctx := context.Background()
+ cid := "resume-mismatch"
+ log := &eventlogtest.MemoryEventLog{}
+ reg := NewRegistry()
+ if err := reg.RegisterHarness("harness-a", &testHarness{startFunc: func(context.Context, string) (harness.Execution, error) {
+ return &testExecution{}, nil
+ }}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.RegisterHarness("harness-b", &testHarness{startFunc: func(context.Context, string) (harness.Execution, error) {
+ return &testExecution{}, nil
+ }}); err != nil {
+ t.Fatal(err)
+ }
+
+ c, err := New(ctx, Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) { return log, nil },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+
+ noop := ExecHandler(func(*proto.ExecResponse) error { return nil })
+
+ if err := c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ HarnessId: "harness-a",
+ Inputs: []*proto.Message{{Role: "user", Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: "hi"}}}}},
+ }, noop); err != nil {
+ t.Fatalf("turn 1: %v", err)
+ }
+ err = c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ HarnessId: "harness-b",
+ Inputs: []*proto.Message{{Role: "user", Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: "more"}}}}},
+ }, noop)
+ if err == nil || !strings.Contains(err.Error(), "harness ID changed from harness-a to harness-b") {
+ t.Fatalf("resume with a different harness: got %v, want error 'harness ID changed from harness-a to harness-b'", err)
+ }
+}
+
+// Verifies that a new conversation started without a harness id records the
+// default harness's canonical id, not "".
+func TestExec_NewConversationLogsCanonicalDefault(t *testing.T) {
+ ctx := context.Background()
+ cid := "canonical-log"
+ log := &eventlogtest.MemoryEventLog{}
+ reg := NewRegistry()
+ if err := reg.RegisterHarness("harness-a", &testHarness{startFunc: func(context.Context, string) (harness.Execution, error) {
+ return &testExecution{}, nil
+ }}); err != nil {
+ t.Fatal(err)
+ }
+ if err := reg.SetDefaultHarness("harness-a"); err != nil {
+ t.Fatal(err)
+ }
+
+ c, err := New(ctx, Config{
+ Registry: reg,
+ EventLogBuilder: func() (eventlog.EventLog, error) { return log, nil },
+ })
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer c.Close()
+
+ if err := c.Exec(ctx, &proto.ExecRequest{
+ ConversationId: cid,
+ Inputs: []*proto.Message{{Role: "user", Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: "hi"}}}}},
+ }, ExecHandler(func(*proto.ExecResponse) error { return nil })); err != nil {
+ t.Fatalf("exec: %v", err)
+ }
+ _, stored, err := newLogger(log, cid, "").ResumptionState(ctx)
+ if err != nil {
+ t.Fatalf("ResumptionState: %v", err)
+ }
+ if stored != "harness-a" {
+ t.Errorf("logged harness id = %q, want canonical %q (not empty)", stored, "harness-a")
+ }
+}
diff --git a/internal/controller/eventlog/eventlog.go b/internal/controller/eventlog/eventlog.go
new file mode 100644
index 00000000..4e5e1883
--- /dev/null
+++ b/internal/controller/eventlog/eventlog.go
@@ -0,0 +1,46 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package eventlog contains an AX event log.
+package eventlog
+
+import (
+ "context"
+
+ "github.com/google/ax/proto"
+ "google.golang.org/protobuf/encoding/protojson"
+)
+
+// EventLogBuilder builds new event logs.
+type EventLogBuilder func() (EventLog, error)
+
+// EventLog is the persistent, append-only record of all actions taken in an
+// exec. Every entry is an atomic step: replaying the log in order brings
+// the executor back to a consistent state from which execution can resume.
+type EventLog interface {
+ // Append adds a conversation event to the end of the log.
+ Append(ctx context.Context, event *proto.ConversationEvent) (int32, error)
+
+ // Events returns all events for the conversation.
+ Events(ctx context.Context, conversationID string) ([]*proto.ConversationEvent, error)
+
+ // DeleteAll deletes all events for a specific conversation ID.
+ DeleteAll(ctx context.Context, conversationID string) error
+
+ // Close releases the underlying resources and closes the log.
+ Close() error
+}
+
+var marshalOpts = protojson.MarshalOptions{UseProtoNames: true}
+var unmarshalOpts = protojson.UnmarshalOptions{DiscardUnknown: true}
diff --git a/internal/controller/eventlog/eventlogtest/eventlog.go b/internal/controller/eventlog/eventlogtest/eventlog.go
new file mode 100644
index 00000000..f24abe5b
--- /dev/null
+++ b/internal/controller/eventlog/eventlogtest/eventlog.go
@@ -0,0 +1,95 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package eventlogtest
+
+import (
+ "context"
+ "sync"
+
+ "github.com/google/ax/proto"
+)
+
+// MemoryEventLog is an in-memory EventLog useful for testing and short-lived
+// executions. It does not survive process restarts.
+type MemoryEventLog struct {
+ mu sync.Mutex
+ AllEvents []*proto.ConversationEvent
+}
+
+func (m *MemoryEventLog) Append(_ context.Context, event *proto.ConversationEvent) (int32, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ seq := event.Seq
+ if seq == 0 {
+ maxSeq := int32(0)
+ for _, ev := range m.AllEvents {
+ if ev.ConversationId == event.ConversationId && ev.Seq > maxSeq {
+ maxSeq = ev.Seq
+ }
+ }
+ seq = maxSeq + 1
+ event.Seq = seq
+ }
+ m.AllEvents = append(m.AllEvents, event)
+ return seq, nil
+}
+
+func (m *MemoryEventLog) Events(_ context.Context, conversationID string) ([]*proto.ConversationEvent, error) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ out := make([]*proto.ConversationEvent, 0)
+ for _, ev := range m.AllEvents {
+ if ev.ConversationId == conversationID {
+ out = append(out, ev)
+ }
+ }
+ return out, nil
+}
+
+// Drop removes every event for which drop returns true.
+// It is provided for testing and crash-simulation purposes.
+func (m *MemoryEventLog) Drop(drop func(*proto.ConversationEvent) bool) {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ kept := m.AllEvents[:0]
+ for _, ev := range m.AllEvents {
+ if !drop(ev) {
+ kept = append(kept, ev)
+ }
+ }
+ m.AllEvents = kept
+}
+
+func (m *MemoryEventLog) DeleteAll(_ context.Context, conversationID string) error {
+ m.mu.Lock()
+ defer m.mu.Unlock()
+
+ var keptEvents []*proto.ConversationEvent
+ for _, ev := range m.AllEvents {
+ if ev.ConversationId != conversationID {
+ keptEvents = append(keptEvents, ev)
+ }
+ }
+ m.AllEvents = keptEvents
+
+ return nil
+}
+
+func (m *MemoryEventLog) Close() error {
+ return nil
+}
diff --git a/internal/controller/eventlog/postgres.go b/internal/controller/eventlog/postgres.go
new file mode 100644
index 00000000..c99c103d
--- /dev/null
+++ b/internal/controller/eventlog/postgres.go
@@ -0,0 +1,57 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package eventlog
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "time"
+
+ _ "github.com/jackc/pgx/v5/stdlib"
+)
+
+// OpenPostgresEventLog connects to the PostgreSQL database described by dsn and
+// initializes the event log schema. Caller is responsible to ensure it is safe
+// for concurrent use.
+func OpenPostgresEventLog(dsn string) (EventLog, error) {
+ db, err := sql.Open("pgx", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("postgres_eventlog: open: %w", err)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
+ defer cancel()
+
+ if err := db.PingContext(ctx); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("postgres_eventlog: ping: %w", err)
+ }
+
+ // Create tables if they don't exist.
+ if _, err := db.ExecContext(ctx, `
+ CREATE TABLE IF NOT EXISTS conversation_log (
+ conversation_id TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ payload TEXT NOT NULL,
+ PRIMARY KEY (conversation_id, seq)
+ )`); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("postgres_eventlog: create conversation_log table: %w", err)
+ }
+
+
+ return &sqlEventLog{db: db}, nil
+}
diff --git a/internal/controller/eventlog/sql.go b/internal/controller/eventlog/sql.go
new file mode 100644
index 00000000..522b6717
--- /dev/null
+++ b/internal/controller/eventlog/sql.go
@@ -0,0 +1,133 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package eventlog
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+
+ "github.com/google/ax/proto"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/codes"
+ "go.opentelemetry.io/otel/trace"
+)
+
+// sqlEventLog is a database backed EventLog shared by the SQLite and
+// PostgreSQL implementations.
+type sqlEventLog struct {
+ db *sql.DB
+}
+
+// Append serializes the event to JSON and inserts it into the database.
+func (l *sqlEventLog) Append(ctx context.Context, event *proto.ConversationEvent) (seq int32, err error) {
+ ctx, endSpan := l.startSpan(ctx, "Append", event.ConversationId)
+ defer func() { endSpan(err) }()
+
+ tx, err := l.db.BeginTx(ctx, nil)
+ if err != nil {
+ return 0, fmt.Errorf("eventlog: begin tx: %w", err)
+ }
+ defer tx.Rollback()
+
+ seq = event.Seq
+ if seq == 0 {
+ if err := tx.QueryRowContext(ctx, "SELECT COALESCE(MAX(seq), 0) + 1 FROM conversation_log WHERE conversation_id = $1", event.ConversationId).Scan(&seq); err != nil {
+ return 0, fmt.Errorf("eventlog: compute seq: %w", err)
+ }
+ event.Seq = seq
+ }
+
+ payload, err := marshalOpts.Marshal(event)
+ if err != nil {
+ return 0, fmt.Errorf("eventlog: marshal event: %w", err)
+ }
+
+ if _, err := tx.ExecContext(ctx,
+ "INSERT INTO conversation_log (conversation_id, seq, payload) VALUES ($1, $2, $3)",
+ event.ConversationId, event.Seq, string(payload)); err != nil {
+ return 0, fmt.Errorf("eventlog: insert conversation: %w", err)
+ }
+
+ if err := tx.Commit(); err != nil {
+ return 0, fmt.Errorf("eventlog: commit: %w", err)
+ }
+
+ return seq, nil
+}
+
+// Events retrieves all events from the database for a conversation, ordered by seq.
+func (l *sqlEventLog) Events(ctx context.Context, conversationID string) (events []*proto.ConversationEvent, err error) {
+ ctx, endSpan := l.startSpan(ctx, "Events", conversationID)
+ defer func() { endSpan(err) }()
+
+ rows, err := l.db.QueryContext(ctx, "SELECT payload FROM conversation_log WHERE conversation_id = $1 ORDER BY seq", conversationID)
+ if err != nil {
+ return nil, fmt.Errorf("eventlog: query conversation: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var payload string
+ if err := rows.Scan(&payload); err != nil {
+ return nil, fmt.Errorf("eventlog: scan conversation: %w", err)
+ }
+
+ ev := &proto.ConversationEvent{}
+ if err := unmarshalOpts.Unmarshal([]byte(payload), ev); err != nil {
+ return nil, fmt.Errorf("eventlog: unmarshal event: %w", err)
+ }
+ events = append(events, ev)
+ }
+
+ if err := rows.Err(); err != nil {
+ return nil, fmt.Errorf("eventlog: iterate conversation: %w", err)
+ }
+
+ return events, nil
+}
+
+// DeleteAll deletes all events for a specific conversation ID.
+func (l *sqlEventLog) DeleteAll(ctx context.Context, conversationID string) (err error) {
+ ctx, endSpan := l.startSpan(ctx, "DeleteAll", conversationID)
+ defer func() { endSpan(err) }()
+
+ if _, err := l.db.ExecContext(ctx, "DELETE FROM conversation_log WHERE conversation_id = $1", conversationID); err != nil {
+ return fmt.Errorf("eventlog: delete conversation: %w", err)
+ }
+ return nil
+}
+
+// Close releases the database connection.
+func (l *sqlEventLog) Close() error {
+ return l.db.Close()
+}
+
+func (l *sqlEventLog) startSpan(ctx context.Context, name string, conversationID string) (context.Context, func(err error)) {
+ const tracerName = "eventlog.sql"
+ tracer := otel.Tracer(tracerName)
+
+ ctx, span := tracer.Start(ctx, tracerName+"/"+name, trace.WithAttributes(
+ attribute.String("conversation_id", conversationID),
+ ))
+ return ctx, func(err error) {
+ if err != nil {
+ span.RecordError(err)
+ span.SetStatus(codes.Error, err.Error())
+ }
+ span.End()
+ }
+}
diff --git a/internal/controller/eventlog/sql_test.go b/internal/controller/eventlog/sql_test.go
new file mode 100644
index 00000000..86eb4cf3
--- /dev/null
+++ b/internal/controller/eventlog/sql_test.go
@@ -0,0 +1,205 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package eventlog
+
+import (
+ "context"
+ "os"
+ "path/filepath"
+ "testing"
+
+ "github.com/google/ax/proto"
+)
+
+// testEventLog runs the EventLog contract against a backend. newLog returns a
+// fresh, empty event log for each subtest, using the subtest's *testing.T for
+// temp dirs and cleanup.
+//
+// Subtests derive their IDs from t.Name() (which includes the backend and
+// subtest name) and clear them with DeleteAll before and after running, so they
+// are safe against the shared Postgres database and harmless against SQLite's
+// per-test file.
+func testEventLog(t *testing.T, newLog func(t *testing.T) EventLog) {
+ t.Run("AppendAndEvents", func(t *testing.T) {
+ ctx := context.Background()
+ log := newLog(t)
+
+ conv := t.Name() + "-conv-1"
+ task1 := t.Name() + "-task-1"
+ task2 := t.Name() + "-task-2"
+ _ = log.DeleteAll(ctx, conv)
+ t.Cleanup(func() { _ = log.DeleteAll(ctx, conv) })
+
+ // 1. Conversation log.
+ cev1 := &proto.ConversationEvent{ConversationId: conv, Seq: 1, ExecId: task1}
+ cev2 := &proto.ConversationEvent{ConversationId: conv, Seq: 2, ExecId: task2}
+ if _, err := log.Append(ctx, cev1); err != nil {
+ t.Fatalf("failed to append cev1: %v", err)
+ }
+ if _, err := log.Append(ctx, cev2); err != nil {
+ t.Fatalf("failed to append cev2: %v", err)
+ }
+
+ cEvents, err := log.Events(ctx, conv)
+ if err != nil {
+ t.Fatalf("failed to read conversation events: %v", err)
+ }
+ if len(cEvents) != 2 {
+ t.Fatalf("expected 2 conversation events, got %d", len(cEvents))
+ }
+ if cEvents[0].Seq != 1 || cEvents[1].Seq != 2 {
+ t.Errorf("conversation events out of order: %d, %d", cEvents[0].Seq, cEvents[1].Seq)
+ }
+ if cEvents[0].ExecId != task1 || cEvents[1].ExecId != task2 {
+ t.Errorf("conversation events mismatch: %q, %q", cEvents[0].ExecId, cEvents[1].ExecId)
+ }
+
+
+ })
+
+ t.Run("Empty", func(t *testing.T) {
+ ctx := context.Background()
+ log := newLog(t)
+
+ events, err := log.Events(ctx, t.Name()+"-none")
+ if err != nil {
+ t.Fatalf("failed to read events: %v", err)
+ }
+ if len(events) != 0 {
+ t.Fatalf("expected 0 events, got %d", len(events))
+ }
+ })
+
+ t.Run("DeleteAll", func(t *testing.T) {
+ ctx := context.Background()
+ log := newLog(t)
+
+ conv1 := t.Name() + "-conv-1"
+ conv2 := t.Name() + "-conv-2"
+ task1 := t.Name() + "-task-1"
+ task3 := t.Name() + "-task-3"
+ _ = log.DeleteAll(ctx, conv1)
+ _ = log.DeleteAll(ctx, conv2)
+ t.Cleanup(func() {
+ _ = log.DeleteAll(ctx, conv1)
+ _ = log.DeleteAll(ctx, conv2)
+ })
+
+ if _, err := log.Append(ctx, &proto.ConversationEvent{ConversationId: conv1, Seq: 1, ExecId: task1}); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+ if _, err := log.Append(ctx, &proto.ConversationEvent{ConversationId: conv2, Seq: 1, ExecId: task3}); err != nil {
+ t.Fatalf("append: %v", err)
+ }
+
+
+ if err := log.DeleteAll(ctx, conv1); err != nil {
+ t.Fatalf("failed to delete events: %v", err)
+ }
+
+ if ev, _ := log.Events(ctx, conv1); len(ev) != 0 {
+ t.Errorf("expected 0 events for conv1, got %d", len(ev))
+ }
+ if ev, _ := log.Events(ctx, conv2); len(ev) != 1 {
+ t.Errorf("expected 1 event for conv2, got %d", len(ev))
+ }
+
+ })
+
+ // AutoSeq exercises the seq==0 auto-assignment path: appends with Seq unset
+ // receive sequential numbers starting at 1.
+ t.Run("AutoSeq", func(t *testing.T) {
+ ctx := context.Background()
+ log := newLog(t)
+
+ conv := t.Name() + "-conv"
+ _ = log.DeleteAll(ctx, conv)
+ t.Cleanup(func() { _ = log.DeleteAll(ctx, conv) })
+
+ const n = 3
+ for i := int32(1); i <= n; i++ {
+ seq, err := log.Append(ctx, &proto.ConversationEvent{ConversationId: conv, ExecId: "t"})
+ if err != nil {
+ t.Fatalf("auto-seq append failed: %v", err)
+ }
+ if seq != i {
+ t.Errorf("expected seq %d, got %d", i, seq)
+ }
+ }
+
+ events, err := log.Events(ctx, conv)
+ if err != nil {
+ t.Fatalf("failed to read events: %v", err)
+ }
+ if len(events) != n {
+ t.Fatalf("expected %d events, got %d", n, len(events))
+ }
+ for i, e := range events {
+ if e.Seq != int32(i+1) {
+ t.Errorf("event %d: expected seq %d, got %d", i, i+1, e.Seq)
+ }
+ }
+ })
+}
+
+// TestSQLiteEventLog runs the EventLog contract against the SQLite backend.
+func TestSQLiteEventLog(t *testing.T) {
+ testEventLog(t, func(t *testing.T) EventLog {
+ t.Helper()
+ log, err := OpenSQLiteEventLog(filepath.Join(t.TempDir(), "test.db"))
+ if err != nil {
+ t.Fatalf("failed to open sqlite event log: %v", err)
+ }
+ t.Cleanup(func() { log.Close() })
+ return log
+ })
+}
+
+// TestPostgresEventLog runs the EventLog contract against the Postgres backend
+// described by PG_TEST_DSN, skipping when that variable is not set.
+func TestPostgresEventLog(t *testing.T) {
+ dsn := os.Getenv("PG_TEST_DSN")
+ if dsn == "" {
+ t.Skip("PG_TEST_DSN not set; skipping Postgres event log tests")
+ }
+ testEventLog(t, func(t *testing.T) EventLog {
+ t.Helper()
+ log, err := OpenPostgresEventLog(dsn)
+ if err != nil {
+ t.Fatalf("failed to open postgres event log: %v", err)
+ }
+ t.Cleanup(func() { log.Close() })
+ return log
+ })
+}
+
+// TestSQLiteEventLog_CreatesParentDirectory is SQLite-specific: OpenSQLiteEventLog
+// must create the database file's parent directory if it does not exist.
+func TestSQLiteEventLog_CreatesParentDirectory(t *testing.T) {
+ dbPath := filepath.Join(t.TempDir(), "newdir", "test.db")
+
+ log, err := OpenSQLiteEventLog(dbPath)
+ if err != nil {
+ t.Fatalf("failed to open sqlite event log and create directory: %v", err)
+ }
+ defer log.Close()
+
+ if _, err := os.Stat(filepath.Dir(dbPath)); os.IsNotExist(err) {
+ t.Fatalf("expected parent directory to be created, but it does not exist")
+ }
+ if _, err := os.Stat(dbPath); os.IsNotExist(err) {
+ t.Fatalf("expected database file to be created, but it does not exist")
+ }
+}
diff --git a/internal/controller/eventlog/sqlite.go b/internal/controller/eventlog/sqlite.go
new file mode 100644
index 00000000..b29ea5a6
--- /dev/null
+++ b/internal/controller/eventlog/sqlite.go
@@ -0,0 +1,56 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package eventlog
+
+import (
+ "database/sql"
+ "fmt"
+ "os"
+ "path/filepath"
+ "time"
+
+ _ "modernc.org/sqlite"
+)
+
+const sqliteBusyTimeout = 10 * time.Second
+
+// OpenSQLiteEventLog opens (or creates) a SQLite database at path and initializes the event log schema.
+func OpenSQLiteEventLog(path string) (EventLog, error) {
+ dir := filepath.Dir(path)
+ if err := os.MkdirAll(dir, 0755); err != nil {
+ return nil, fmt.Errorf("sqlite_eventlog: mkdir %s: %w", dir, err)
+ }
+
+ dsn := fmt.Sprintf("%s?_pragma=busy_timeout(%d)&_txlock=immediate", path, sqliteBusyTimeout.Milliseconds())
+ db, err := sql.Open("sqlite", dsn)
+ if err != nil {
+ return nil, fmt.Errorf("sqlite_eventlog: open %s: %w", dsn, err)
+ }
+
+ // Create tables if they don't exist.
+ if _, err := db.Exec(`
+ CREATE TABLE IF NOT EXISTS conversation_log (
+ conversation_id TEXT NOT NULL,
+ seq INTEGER NOT NULL,
+ payload TEXT NOT NULL,
+ PRIMARY KEY (conversation_id, seq)
+ )`); err != nil {
+ db.Close()
+ return nil, fmt.Errorf("sqlite_eventlog: create conversation_log table: %w", err)
+ }
+
+
+ return &sqlEventLog{db: db}, nil
+}
diff --git a/internal/controller/gemini.go b/internal/controller/gemini.go
deleted file mode 100644
index 8de3d82d..00000000
--- a/internal/controller/gemini.go
+++ /dev/null
@@ -1,184 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package controller
-
-import (
- "context"
- "fmt"
- "os"
- "time"
-
- "github.com/google/gar/proto"
- "google.golang.org/genai"
-)
-
-// GeminiPlannerConfig configures the Gemini-based planner.
-type GeminiPlannerConfig struct {
- APIKey string // Google AI API key (for programmatic use only; if empty, uses GEMINI_API_KEY env var - recommended)
- Model string // Model name (default: gemini-2.5-flash)
- MaxTokens int32 // Max output tokens (default: 8192)
- Timeout time.Duration // Request timeout (default: 60s)
- SystemPrompt string // Custom system prompt (optional)
-}
-
-// NewGeminiPlanFunc creates a planning function that uses Gemini for intelligent agent selection.
-// It converts available agents into function declarations and lets Gemini decide which agent
-// to invoke based on the session context and agent capabilities.
-func NewGeminiPlanFunc(ctx context.Context, registry *Registry, config GeminiPlannerConfig) (PlanFunc, error) {
- // Set defaults
- if config.Timeout == 0 {
- config.Timeout = 60 * time.Second
- }
- if config.Model == "" {
- config.Model = os.Getenv("GAR_GEMINI_MODEL")
- if config.Model == "" {
- config.Model = "gemini-2.5-flash"
- }
- }
- if config.APIKey == "" {
- config.APIKey = os.Getenv("GEMINI_API_KEY")
- if config.APIKey == "" {
- return nil, fmt.Errorf("GEMINI_API_KEY not set and no API key provided in config")
- }
- }
-
- // Default system prompt
- if config.SystemPrompt == "" {
- config.SystemPrompt = `You are an intelligent agent orchestrator. Your role is to analyze the conversation history and user requests, then select the most appropriate agent to handle the task.
-
-Available agents have been provided to you as function tools. Each agent has:
-- A unique ID
-- A description of its capabilities
-
-Your job is to:
-1. Analyze the current conversation context and understand what needs to be done
-2. Select the best agent for the task by calling the appropriate function
-3. If enough work is done, stop to indicate completion
-
-Guidelines:
-- Choose agents based on their capabilities and the user's needs
-- If no suitable agent exists, stop.
-- Keep the conversation context in mind when selecting agents`
- }
-
- client, err := genai.NewClient(ctx, &genai.ClientConfig{
- APIKey: config.APIKey,
- })
- if err != nil {
- return nil, fmt.Errorf("failed to create Gemini client: %w", err)
- }
-
- // Return the plan function
- return func(ctx context.Context, inputs []*proto.Content) (*Task, error) {
- // Create a context with timeout
- ctx, cancel := context.WithTimeout(ctx, config.Timeout)
- defer cancel()
-
- // Convert agents to Gemini function declarations
- tools, err := agentsToTools(registry)
- if err != nil {
- return nil, fmt.Errorf("failed to convert agents to tools: %w", err)
- }
-
- // Convert session to conversation history
- contents := protoToContents(inputs)
- resp, err := client.Models.GenerateContent(ctx, config.Model, contents, &genai.GenerateContentConfig{
- Tools: tools,
- SystemInstruction: genai.Text(config.SystemPrompt)[0],
- MaxOutputTokens: config.MaxTokens,
- CandidateCount: 1,
- })
-
- if err != nil {
- return nil, fmt.Errorf("failed to generate in planner: %w", err)
- }
- if len(resp.Candidates) == 0 {
- return nil, fmt.Errorf("no candidates from Gemini in planner")
- }
- candidate := resp.Candidates[0]
- if candidate.Content == nil || candidate.Content.Parts == nil {
- if candidate.FinishReason == genai.FinishReasonStop {
- return nil, nil // No more tasks
- }
- return nil, fmt.Errorf("no content in candidates from Gemini")
- }
-
- // Look for function calls in the response
- for _, part := range candidate.Content.Parts {
- if part == nil {
- continue
- }
-
- if fc := part.FunctionCall; fc != nil {
- return &Task{
- AgentID: fc.Name,
- Inputs: inputs,
- }, nil
- }
- }
- return nil, nil
- }, nil
-}
-
-// agentsToTools converts registry agents to Gemini function declarations.
-func agentsToTools(registry *Registry) ([]*genai.Tool, error) {
- healthyAgents := registry.ListHealthy()
- if len(healthyAgents) == 0 {
- return nil, fmt.Errorf("no healthy agents available")
- }
-
- var tools []*genai.Tool
- for _, id := range healthyAgents {
- info, err := registry.GetInfo(id)
- if err != nil {
- continue // Skip agents we can't get info for
- }
-
- // Create a function declaration for this agent
- funcDecl := &genai.FunctionDeclaration{
- Name: id, // Use agent ID as function name
- Description: fmt.Sprintf("%s, %s", info.Name, info.Description),
- }
-
- tools = append(tools, &genai.Tool{
- FunctionDeclarations: []*genai.FunctionDeclaration{funcDecl},
- })
- }
- return tools, nil
-}
-
-// protoToContents converts session message history to Gemini conversation format.
-func protoToContents(inputs []*proto.Content) []*genai.Content {
- var contents []*genai.Content
-
- // Convert each message to Gemini format
- for _, msg := range inputs {
- role := msg.Role
- if role != "user" {
- role = "model"
- }
- contents = append(contents, &genai.Content{
- Role: role,
- Parts: []*genai.Part{
- {
- Text: msg.Data,
- // TODO(jbd): Handle other content types (e.g., images, files)
- },
- },
- })
- }
-
- return contents
-}
diff --git a/internal/controller/loop.go b/internal/controller/loop.go
deleted file mode 100644
index ce1c4ecd..00000000
--- a/internal/controller/loop.go
+++ /dev/null
@@ -1,143 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package controller
-
-import (
- "context"
- "fmt"
-
- "github.com/google/gar/agent"
- "github.com/google/gar/proto"
-)
-
-// Task represents a task to be executed by an agent.
-type Task struct {
- AgentID string
- Inputs []*proto.Content
-}
-
-// LoopExecutor orchestrates the agentic loop workflow.
-// It implements the plan-execute-evaluate-checkpoint cycle.
-type LoopExecutor struct {
- registry *Registry
- sessionManager *SessionManager
- maxSteps int
- planFunc PlanFunc
-}
-
-// PlanFunc determines the next agent task to execute.
-// It receives the current session state and returns the next task.
-type PlanFunc func(ctx context.Context, inputs []*proto.Content) (*Task, error)
-
-// LoopConfig configures the loop executor.
-type LoopConfig struct {
- Registry *Registry
- SessionManager *SessionManager
- MaxSteps int
- PlanFunc PlanFunc
-}
-
-// NewLoopExecutor creates a new loop executor.
-func NewLoopExecutor(ctx context.Context, config LoopConfig) (*LoopExecutor, error) {
- if config.Registry == nil {
- return nil, fmt.Errorf("registry cannot be nil")
- }
- if config.SessionManager == nil {
- return nil, fmt.Errorf("session manager cannot be nil")
- }
- if config.MaxSteps == 0 {
- config.MaxSteps = 10 // Default max steps per trigger
- }
-
- // Provide default plan function if not specified
- if config.PlanFunc == nil {
- // Use Gemini planner by default
- geminiPlanFunc, err := NewGeminiPlanFunc(ctx, config.Registry, GeminiPlannerConfig{})
- if err != nil {
- return nil, fmt.Errorf("failed to initialize default Gemini planner: %w (set GEMINI_API_KEY env var or provide custom PlanFunc)", err)
- }
- config.PlanFunc = geminiPlanFunc
- }
-
- return &LoopExecutor{
- registry: config.Registry,
- sessionManager: config.SessionManager,
- maxSteps: config.MaxSteps,
- planFunc: config.PlanFunc,
- }, nil
-}
-
-// Execute starts a new agentic loop execution for the given session.
-func (e *LoopExecutor) Execute(ctx context.Context, session *Session, handler agent.OutputHandler) error {
- return e.runLoop(ctx, session, handler)
-}
-
-// runLoop executes the main agentic loop.
-// It runs up to maxSteps iterations per trigger/resume invocation.
-func (e *LoopExecutor) runLoop(ctx context.Context, session *Session, handler agent.OutputHandler) error {
- steps := 0
-
- for steps < e.maxSteps {
- // Check context cancellation
- select {
- case <-ctx.Done():
- return ctx.Err()
- default:
- }
-
- task, err := e.planFunc(ctx, session.History())
- if err != nil {
- return fmt.Errorf("planning failed: %w", err)
- }
-
- if task == nil {
- // No more tasks to execute; loop is complete.
- return nil
- }
-
- taskOutputHandler := func(content *proto.Content) error {
- if _, err := session.WriteContentOut(ctx, content); err != nil {
- return fmt.Errorf("failed to write output content: %w", err)
- }
- if err := handler(content); err != nil {
- return fmt.Errorf("output handler error: %w", err)
- }
- return nil
- }
- if err := e.executeTask(ctx, session.ID(), task, taskOutputHandler); err != nil {
- return err
- }
-
- steps++
- }
-
- // Can be resumed later with another trigger
- return fmt.Errorf("max steps per trigger (%d) reached", e.maxSteps)
-}
-
-// executeTask sends input to an agent and collects output.
-func (e *LoopExecutor) executeTask(ctx context.Context, sessionID string, task *Task, handler agent.OutputHandler) error {
- // Get the agent from registry
- ag, err := e.registry.Get(task.AgentID)
- if err != nil {
- return fmt.Errorf("failed to get agent: %w", err)
- }
-
- // Process inputs with the agent
- if err := ag.Process(ctx, sessionID, task.Inputs, handler); err != nil {
- return fmt.Errorf("agent process failed: %w", err)
- }
- return nil
-}
diff --git a/internal/controller/registry.go b/internal/controller/registry.go
index 8122e145..7c1ab57b 100644
--- a/internal/controller/registry.go
+++ b/internal/controller/registry.go
@@ -15,259 +15,65 @@
package controller
import (
- "context"
"fmt"
"sync"
- "time"
- "github.com/google/gar/agent"
- "github.com/google/gar/internal/config"
+ "github.com/google/ax/internal/harness"
)
-// AgentType represents the type of agent (local or remote).
-type AgentType string
-
-const (
- AgentTypeLocal AgentType = "local"
- AgentTypeRemote AgentType = "remote"
-)
-
-// AgentInfo contains metadata about a registered agent.
-type AgentInfo struct {
- ID string
- Name string
- Description string
- Type AgentType
- Healthy bool
- LastHealthCheck time.Time
- Metadata map[string]string
-}
-
-// Registry manages a collection of local and remote agents.
-// It provides agent discovery, health monitoring, and load balancing.
+// Registry manages a collection of harnesses.
type Registry struct {
- mu sync.RWMutex
- agents map[string]agent.Agent
- agentInfo map[string]*AgentInfo
- healthCheckTicker *time.Ticker
- stopHealthCheck chan struct{}
+ mu sync.RWMutex
+ harnesses map[string]harness.Harness
+ defaultHarness string
}
-// NewRegistry creates a new agent registry.
-func NewRegistry(healthCheckInterval time.Duration) *Registry {
- if healthCheckInterval == 0 {
- healthCheckInterval = 30 * time.Second
+// NewRegistry creates a new harness registry.
+func NewRegistry() *Registry {
+ return &Registry{
+ harnesses: make(map[string]harness.Harness),
}
-
- r := &Registry{
- agents: make(map[string]agent.Agent),
- agentInfo: make(map[string]*AgentInfo),
- stopHealthCheck: make(chan struct{}),
- }
-
- // Start background health check if interval is positive
- if healthCheckInterval > 0 {
- r.healthCheckTicker = time.NewTicker(healthCheckInterval)
- go r.runHealthChecks()
- }
-
- return r
}
-// RegisterLocal registers a local (in-process) agent.
-func (r *Registry) RegisterLocal(cfg config.LocalAgentConfig) error {
- r.mu.Lock()
- defer r.mu.Unlock()
-
- if _, ok := r.agents[cfg.ID]; ok {
- return fmt.Errorf("agent %s already registered", cfg.ID)
- }
-
- r.agents[cfg.ID] = cfg.Agent
- r.agentInfo[cfg.ID] = &AgentInfo{
- ID: cfg.ID,
- Name: cfg.Name,
- Description: cfg.Description,
- Type: AgentTypeLocal,
- Healthy: true,
- LastHealthCheck: time.Now(),
- Metadata: cfg.Metadata,
- }
-
- return nil
-}
-
-// RegisterRemote registers a remote agent by creating a remote agent client.
-func (r *Registry) RegisterRemote(cfg config.RemoteAgentConfig) error {
- r.mu.Lock()
- defer r.mu.Unlock()
-
- if _, ok := r.agents[cfg.ID]; ok {
- return fmt.Errorf("agent %s already registered", cfg.ID)
- }
-
- // Create remote agent client
- remoteAgent, err := agent.NewRemoteAgent(agent.RemoteAgentConfig{
- Address: cfg.Address,
- Reconnect: true,
- MaxRetries: 3,
- })
- if err != nil {
- return fmt.Errorf("failed to create remote agent: %w", err)
- }
-
- r.agents[cfg.ID] = remoteAgent
- r.agentInfo[cfg.ID] = &AgentInfo{
- ID: cfg.ID,
- Name: cfg.Name,
- Description: cfg.Description,
- Type: AgentTypeRemote,
- Healthy: false, // Will be checked by health monitor
- LastHealthCheck: time.Time{},
- Metadata: cfg.Metadata,
+// RegisterHarness registers a harness under the given id.
+func (r *Registry) RegisterHarness(id string, h harness.Harness) error {
+ if err := validateID(id); err != nil {
+ return err
}
- return nil
-}
-
-// Unregister removes an agent from the registry.
-func (r *Registry) Unregister(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
- agent, ok := r.agents[id]
- if !ok {
- return fmt.Errorf("agent %s not found", id)
- }
-
- // Close the agent
- if err := agent.Close(); err != nil {
- return fmt.Errorf("failed to close agent: %w", err)
+ if _, ok := r.harnesses[id]; ok {
+ return fmt.Errorf("harness %q already registered", id)
}
-
- delete(r.agents, id)
- delete(r.agentInfo, id)
-
+ r.harnesses[id] = h
return nil
}
-// Get retrieves an agent by ID.
-func (r *Registry) Get(id string) (agent.Agent, error) {
- r.mu.RLock()
- defer r.mu.RUnlock()
-
- a, exists := r.agents[id]
- if !exists {
- return nil, fmt.Errorf("agent %s not found", id)
- }
-
- return a, nil
-}
-
-// GetInfo retrieves agent metadata by ID.
-func (r *Registry) GetInfo(id string) (*AgentInfo, error) {
+// Harness retrieves a harness by id.
+func (r *Registry) Harness(id string) (harness.Harness, error) {
r.mu.RLock()
defer r.mu.RUnlock()
-
- info, exists := r.agentInfo[id]
- if !exists {
- return nil, fmt.Errorf("agent %s not found", id)
- }
-
- return info, nil
-}
-
-// List returns all registered agent IDs.
-func (r *Registry) List() []string {
- r.mu.RLock()
- defer r.mu.RUnlock()
-
- ids := make([]string, 0, len(r.agents))
- for id := range r.agents {
- ids = append(ids, id)
- }
-
- return ids
-}
-
-// ListHealthy returns all healthy agent IDs.
-func (r *Registry) ListHealthy() []string {
- r.mu.RLock()
- defer r.mu.RUnlock()
-
- ids := make([]string, 0)
- for id, info := range r.agentInfo {
- if info.Healthy {
- ids = append(ids, id)
- }
+ h, ok := r.harnesses[id]
+ if !ok {
+ return nil, fmt.Errorf("harness %s not found", id)
}
-
- return ids
+ return h, nil
}
-// healthCheck performs a health check on a specific agent.
-func (r *Registry) healthCheck(id string) error {
- a, err := r.Get(id)
- if err != nil {
- return err
- }
-
- ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
- defer cancel()
-
- err = a.HealthCheck(ctx)
-
+// SetDefaultHarness marks a registered harness id as the default.
+func (r *Registry) SetDefaultHarness(id string) error {
r.mu.Lock()
defer r.mu.Unlock()
-
- info, exists := r.agentInfo[id]
- if exists {
- info.Healthy = (err == nil)
- info.LastHealthCheck = time.Now()
- }
-
- return err
-}
-
-// runHealthChecks runs periodic health checks on all agents.
-func (r *Registry) runHealthChecks() {
- for {
- select {
- case <-r.healthCheckTicker.C:
- r.performHealthChecks()
- case <-r.stopHealthCheck:
- return
- }
- }
-}
-
-// performHealthChecks checks the health of all registered agents.
-func (r *Registry) performHealthChecks() {
- ids := r.List()
- for _, id := range ids {
- // Run health check (ignore errors, status is updated in HealthCheck method)
- _ = r.healthCheck(id)
+ if _, ok := r.harnesses[id]; !ok {
+ return fmt.Errorf("harness %q not found", id)
}
+ r.defaultHarness = id
+ return nil
}
-// Close stops the registry and closes all agents.
+// Close releases resources held by the registry.
func (r *Registry) Close() error {
- r.mu.Lock()
- defer r.mu.Unlock()
-
- // Stop health checks
- if r.healthCheckTicker != nil {
- r.healthCheckTicker.Stop()
- close(r.stopHealthCheck)
- }
-
- // Close all agents
- var firstErr error
- for id, a := range r.agents {
- if err := a.Close(); err != nil && firstErr == nil {
- firstErr = fmt.Errorf("failed to close agent %s: %w", id, err)
- }
- }
-
- return firstErr
+ return nil
}
diff --git a/internal/controller/registry_test.go b/internal/controller/registry_test.go
new file mode 100644
index 00000000..c3eb7516
--- /dev/null
+++ b/internal/controller/registry_test.go
@@ -0,0 +1,87 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controller
+
+import (
+ "context"
+ "testing"
+
+ "github.com/google/ax/internal/harness"
+)
+
+type dummyHarness struct{}
+
+func (d *dummyHarness) Start(ctx context.Context, conversationID string, harnessConfig []byte) (harness.Execution, error) {
+ return nil, nil
+}
+
+func TestRegistry_RegisterHarness(t *testing.T) {
+ r := NewRegistry()
+ h := &dummyHarness{}
+
+ if err := r.RegisterHarness("antigravity", h); err != nil {
+ t.Fatalf("RegisterHarness(valid id): %v", err)
+ }
+
+ // Duplicate id is rejected.
+ if err := r.RegisterHarness("antigravity", h); err == nil {
+ t.Error("expected error registering duplicate id, got nil")
+ }
+
+ // Invalid id is rejected.
+ if err := r.RegisterHarness("bad id", h); err == nil {
+ t.Error("expected error registering invalid id, got nil")
+ }
+
+ // Empty id is reserved for the default harness.
+ if err := r.RegisterHarness("", h); err == nil {
+ t.Error("expected error registering empty id, got nil")
+ }
+}
+
+func TestRegistry_FindHarness(t *testing.T) {
+ r := NewRegistry()
+ h := &dummyHarness{}
+ if err := r.RegisterHarness("antigravity", h); err != nil {
+ t.Fatalf("RegisterHarness: %v", err)
+ }
+
+ if _, err := r.Harness("antigravity"); err != nil {
+ t.Errorf("Harness(antigravity): %v", err)
+ }
+ if _, err := r.Harness("missing"); err == nil {
+ t.Error("expected error looking up missing harness, got nil")
+ }
+}
+
+func TestRegistry_SetDefaultHarness(t *testing.T) {
+ r := NewRegistry()
+ if err := r.RegisterHarness("antigravity", &dummyHarness{}); err != nil {
+ t.Fatalf("RegisterHarness: %v", err)
+ }
+
+ // An unregistered id is rejected.
+ if err := r.SetDefaultHarness("missing"); err == nil {
+ t.Error("SetDefaultHarness(missing): expected error, got nil")
+ }
+
+ // A registered id becomes the default.
+ if err := r.SetDefaultHarness("antigravity"); err != nil {
+ t.Fatalf("SetDefaultHarness(antigravity): %v", err)
+ }
+ if r.defaultHarness != "antigravity" {
+ t.Errorf("defaultHarness = %q, want %q", r.defaultHarness, "antigravity")
+ }
+}
diff --git a/internal/controller/session.go b/internal/controller/session.go
deleted file mode 100644
index cb2fa285..00000000
--- a/internal/controller/session.go
+++ /dev/null
@@ -1,344 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package controller
-
-import (
- "context"
- "fmt"
- "sync"
- "time"
-
- "github.com/google/gar/internal/eventlog"
- "github.com/google/gar/proto"
- "github.com/google/uuid"
-)
-
-// Session represents an agentic loop execution session.
-// It maintains in-memory state and uses event log for durability.
-type Session struct {
- id string
-
- mu sync.RWMutex
- eventLog eventlog.EventLog
- currentStep int
- state proto.State
- activeAgents []string
- messageHistory []*proto.Content
- checkpointIDs []string // Ordered list of checkpoint UUIDs
-
- createdAt time.Time
- updatedAt time.Time
-}
-
-// SessionManager manages multiple sessions.
-type SessionManager struct {
- mu sync.RWMutex
- sessions map[string]*Session
- eventLogFactory eventlog.EventLogFactory
-}
-
-// NewSessionManager creates a new session manager with a custom EventLog factory.
-func NewSessionManager(factory eventlog.EventLogFactory) *SessionManager {
- return &SessionManager{
- sessions: make(map[string]*Session),
- eventLogFactory: factory,
- }
-}
-
-// NewSession creates a new session with the given ID.
-func (sm *SessionManager) NewSession(sessionID string) (*Session, error) {
- sm.mu.Lock()
- defer sm.mu.Unlock()
-
- // Check if session already ok
- if _, ok := sm.sessions[sessionID]; ok {
- return nil, fmt.Errorf("session %s already exists", sessionID)
- }
-
- // Create event log for this session using the factory
- el, err := sm.eventLogFactory(sessionID)
- if err != nil {
- return nil, fmt.Errorf("failed to create event log: %w", err)
- }
-
- now := time.Now()
- session := &Session{
- id: sessionID,
- state: proto.State_STATE_UNSPECIFIED,
- currentStep: 0,
- activeAgents: []string{},
- messageHistory: []*proto.Content{},
- checkpointIDs: []string{},
- createdAt: now,
- updatedAt: now,
- eventLog: el,
- }
-
- sm.sessions[sessionID] = session
- return session, nil
-}
-
-// LoadSession loads an existing session from event log.
-func (sm *SessionManager) LoadSession(ctx context.Context, sessionID string) (*Session, error) {
- return sm.LoadSessionFromCheckpoint(ctx, sessionID, "")
-}
-
-// LoadSessionFromCheckpoint loads an existing session from event log up to a specific checkpoint.
-// If checkpointID is empty, loads to the latest state.
-// If checkpointID is provided, loads up to and including that checkpoint UUID.
-func (sm *SessionManager) LoadSessionFromCheckpoint(ctx context.Context, sessionID string, checkpointID string) (*Session, error) {
- sm.mu.Lock()
- defer sm.mu.Unlock()
-
- // Check if already loaded - remove it to reload fresh from checkpoint
- delete(sm.sessions, sessionID)
-
- // Open event log for replay using the factory
- replayLog, err := sm.eventLogFactory(sessionID)
- if err != nil {
- return nil, fmt.Errorf("failed to open event log for replay: %w", err)
- }
- defer replayLog.Close()
-
- entries, state, err := replayLog.RetrieveEntries(ctx)
- if err != nil {
- return nil, fmt.Errorf("failed to get event log entries: %w", err)
- }
-
- // Reconstruct session state from event log
- session := &Session{
- id: sessionID,
- state: state,
- currentStep: 0,
- activeAgents: []string{},
- messageHistory: []*proto.Content{},
- checkpointIDs: []string{},
- createdAt: time.Now(),
- updatedAt: time.Now(),
- }
-
- targetReached := false
- checkpointFound := false
-
- // Replay entries to rebuild state
- for _, entry := range entries {
- // If we've reached the target checkpoint, stop processing
- if targetReached {
- break
- }
-
- switch entry.Type {
- case eventlog.EventTypeContentIn, eventlog.EventTypeContentOut:
- content := &proto.Content{
- Role: getStringFromData(entry.Data, "role"),
- Type: getStringFromData(entry.Data, "type"),
- Mimetype: getStringFromData(entry.Data, "mimetype"),
- Data: getStringFromData(entry.Data, "data"),
- }
- session.messageHistory = append(session.messageHistory, content)
-
- // Track checkpoint ID if present
- if entry.CheckpointID != "" {
- session.checkpointIDs = append(session.checkpointIDs, entry.CheckpointID)
-
- // Check if this is the target checkpoint
- if checkpointID != "" && entry.CheckpointID == checkpointID {
- targetReached = true
- checkpointFound = true
- }
- }
- }
-
- session.updatedAt = entry.Timestamp
- }
-
- // Validate checkpoint ID if provided
- if checkpointID != "" && !checkpointFound {
- return nil, fmt.Errorf("checkpoint ID %s not found in session", checkpointID)
- }
-
- // Reopen event log for appending using the factory
- el, err := sm.eventLogFactory(sessionID)
- if err != nil {
- return nil, fmt.Errorf("failed to reopen event log: %w", err)
- }
- session.eventLog = el
-
- sm.sessions[sessionID] = session
- return session, nil
-}
-
-// GetSession retrieves a session by ID.
-func (sm *SessionManager) GetSession(sessionID string) (*Session, error) {
- sm.mu.RLock()
- defer sm.mu.RUnlock()
-
- session, ok := sm.sessions[sessionID]
- if !ok {
- return nil, fmt.Errorf("session %s not found", sessionID)
- }
-
- return session, nil
-}
-
-// CloseSession closes a session and its event log.
-func (sm *SessionManager) CloseSession(sessionID string) error {
- sm.mu.Lock()
- defer sm.mu.Unlock()
-
- session, ok := sm.sessions[sessionID]
- if !ok {
- return fmt.Errorf("session %s not found", sessionID)
- }
-
- if err := session.eventLog.Close(); err != nil {
- return fmt.Errorf("failed to close event log: %w", err)
- }
-
- delete(sm.sessions, sessionID)
- return nil
-}
-
-// CloseAll closes all active sessions.
-func (sm *SessionManager) CloseAll() {
- sm.mu.Lock()
- defer sm.mu.Unlock()
-
- for sessionID, session := range sm.sessions {
- if err := session.eventLog.Close(); err != nil {
- // Log error but continue closing other sessions
- _ = err
- }
- delete(sm.sessions, sessionID)
- }
-}
-
-// WriteContentIn appends an incoming content message to the session.
-// Creates a checkpoint only if checkpoint_id is provided in the content.
-func (s *Session) WriteContentIn(ctx context.Context, content *proto.Content) (string, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- // Use checkpoint_id from content if provided
- checkpointID := content.CheckpointId
-
- if checkpointID != "" {
- // TODO(jbd): Optimize the lookup.
- for _, existingID := range s.checkpointIDs {
- if existingID == checkpointID {
- return "", fmt.Errorf("checkpoint %s already exists", checkpointID)
- }
- }
- }
-
- if err := s.eventLog.AppendContent(ctx, eventlog.EventTypeContentIn, checkpointID, content); err != nil {
- return "", err
- }
-
- s.messageHistory = append(s.messageHistory, content)
- if checkpointID != "" {
- s.checkpointIDs = append(s.checkpointIDs, checkpointID)
- }
- s.updatedAt = time.Now()
- return checkpointID, nil
-}
-
-// WriteContentOut appends an outgoing content message to the session with a new checkpoint.
-func (s *Session) WriteContentOut(ctx context.Context, content *proto.Content) (string, error) {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- // Generate a new checkpoint UUID
- checkpointID := uuid.New().String()
-
- if err := s.eventLog.AppendContent(ctx, eventlog.EventTypeContentOut, checkpointID, content); err != nil {
- return "", err
- }
-
- s.messageHistory = append(s.messageHistory, content)
- s.checkpointIDs = append(s.checkpointIDs, checkpointID)
- s.updatedAt = time.Now()
- return checkpointID, nil
-}
-
-// SetState updates the session state.
-func (s *Session) SetState(ctx context.Context, state proto.State) error {
- s.mu.Lock()
- defer s.mu.Unlock()
-
- if err := s.eventLog.AppendState(ctx, state); err != nil {
- return fmt.Errorf("failed to append state: %w", err)
- }
-
- s.state = state
- s.updatedAt = time.Now()
- return nil
-}
-
-func (s *Session) ID() string {
- return s.id
-}
-
-func (s *Session) State() proto.State {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- return s.state
-}
-
-func (s *Session) ActiveAgents() []string {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- return s.activeAgents
-}
-
-func (s *Session) History() []*proto.Content {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- return s.messageHistory
-}
-
-func (s *Session) CheckpointIDs() []string {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- return s.checkpointIDs
-}
-
-func (s *Session) CreatedAt() time.Time {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- return s.createdAt
-}
-
-func (s *Session) UpdatedAt() time.Time {
- s.mu.RLock()
- defer s.mu.RUnlock()
-
- return s.updatedAt
-}
-
-// Helper function to extract string from map[string]any
-func getStringFromData(data map[string]any, key string) string {
- if val, ok := data[key]; ok {
- if str, ok := val.(string); ok {
- return str
- }
- }
- return ""
-}
diff --git a/internal/controller/validation.go b/internal/controller/validation.go
new file mode 100644
index 00000000..729b2435
--- /dev/null
+++ b/internal/controller/validation.go
@@ -0,0 +1,36 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controller
+
+import (
+ "errors"
+ "fmt"
+ "regexp"
+)
+
+var validIDRegex = regexp.MustCompile(`^[A-Za-z0-9\-_]+$`)
+
+// validateID checks if a harness ID contains allowed characters.
+func validateID(id string) error {
+ if id == "" {
+ return errors.New("empty ID")
+ }
+
+ if !validIDRegex.MatchString(id) {
+ return fmt.Errorf("invalid harness ID %q: must only contain A-Z, a-z, 0-9, -, and _", id)
+ }
+
+ return nil
+}
diff --git a/internal/controller/validation_test.go b/internal/controller/validation_test.go
new file mode 100644
index 00000000..4b1dde13
--- /dev/null
+++ b/internal/controller/validation_test.go
@@ -0,0 +1,72 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package controller
+
+import (
+ "testing"
+)
+
+func TestValidateID(t *testing.T) {
+ tests := []struct {
+ name string
+ id string
+ wantErr bool
+ }{
+ {
+ name: "valid lowercase",
+ id: "task123",
+ wantErr: false,
+ },
+ {
+ name: "valid mixed",
+ id: "Task-ID_123",
+ wantErr: false,
+ },
+ {
+ name: "valid simple",
+ id: "Task-ID",
+ wantErr: false,
+ },
+ {
+ name: "valid underscore",
+ id: "task_id",
+ wantErr: false,
+ },
+ {
+ name: "invalid space",
+ id: "task id",
+ wantErr: true,
+ },
+ {
+ name: "invalid char",
+ id: "task!",
+ wantErr: true,
+ },
+ {
+ name: "empty",
+ id: "",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ err := validateID(tt.id)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("validateID(%q) error = %v, wantErr %v", tt.id, err, tt.wantErr)
+ }
+ })
+ }
+}
diff --git a/internal/eventlog/eventlog.go b/internal/eventlog/eventlog.go
deleted file mode 100644
index 495d0f43..00000000
--- a/internal/eventlog/eventlog.go
+++ /dev/null
@@ -1,66 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Package eventlog implements event logging for session durability.
-// Event log entries use JSON Lines format with one log file per session.
-package eventlog
-
-import (
- "context"
- "time"
-
- "github.com/google/gar/proto"
-)
-
-// EventType represents the type of event stored in the event log.
-type EventType string
-
-const (
- EventTypeState EventType = "STATE"
- EventTypeContentIn EventType = "CONTENT_IN"
- EventTypeContentOut EventType = "CONTENT_OUT"
- // TODO(jbd): Add EventTypeCompaction.
-)
-
-// Entry represents a single entry in the event log.
-type Entry struct {
- SessionID string `json:"session_id"`
- CheckpointID string `json:"checkpoint_id,omitempty"` // UUID for checkpoint tracking
- Sequence int64 `json:"seq"` // Monotonic sequence number
- Timestamp time.Time `json:"timestamp"`
- Type EventType `json:"type"`
- Data map[string]any `json:"data"`
-}
-
-// EventLog is the interface that all event log implementations must satisfy.
-// It provides methods for appending events, reading entries, and managing the log lifecycle.
-type EventLog interface {
- // AppendContent appends a content message to the event log with a checkpoint UUID.
- AppendContent(ctx context.Context, t EventType, checkpointID string, content *proto.Content) error
-
- // AppendState appends a state to the event log.
- AppendState(ctx context.Context, s proto.State) error
-
- // RetrieveEntries returns all entries from the event log in order.
- RetrieveEntries(ctx context.Context) ([]Entry, proto.State, error)
-
- // Close closes the event log and releases any resources.
- Close() error
-
- // SessionID returns the session ID for this event log.
- SessionID() string
-}
-
-// EventLogFactory is a function that creates EventLog instances for sessions.
-type EventLogFactory func(sessionID string) (EventLog, error)
diff --git a/internal/eventlog/file.go b/internal/eventlog/file.go
deleted file mode 100644
index 4c2c8584..00000000
--- a/internal/eventlog/file.go
+++ /dev/null
@@ -1,246 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-package eventlog
-
-import (
- "bufio"
- "context"
- "encoding/json"
- "fmt"
- "os"
- "path/filepath"
- "sync"
- "time"
-
- "github.com/google/gar/proto"
-)
-
-// FileEventLog represents a file-based event log for a single session.
-// It provides append-only durability with JSON Lines format.
-type FileEventLog struct {
- mu sync.Mutex
- sessionID string
- filePath string
- file *os.File
- writer *bufio.Writer
- sequence int64 // Monotonic sequence counter
-}
-
-// FileConfig configures a FileEventLog instance.
-type FileConfig struct {
- SessionID string
- Dir string // Directory where event log files are stored
-}
-
-// NewFileEventLog creates a new file-based EventLog instance for the given session.
-// It creates the event log directory and file if they don't exist.
-func NewFileEventLog(config FileConfig) (*FileEventLog, error) {
- if config.SessionID == "" {
- return nil, fmt.Errorf("session ID cannot be empty")
- }
- if config.Dir == "" {
- config.Dir = "eventlog"
- }
-
- // Create event log directory if it doesn't exist
- if err := os.MkdirAll(config.Dir, 0755); err != nil {
- return nil, fmt.Errorf("failed to create event log directory: %w", err)
- }
-
- filePath := filepath.Join(config.Dir, fmt.Sprintf("%s.log", config.SessionID))
-
- // Open file in append mode, create if doesn't exist
- file, err := os.OpenFile(filePath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
- if err != nil {
- return nil, fmt.Errorf("failed to open event log file: %w", err)
- }
-
- // Read existing entries to determine the current sequence number
- var sequence int64
- if fileInfo, err := os.Stat(filePath); err == nil && fileInfo.Size() > 0 {
- readFile, err := os.Open(filePath)
- if err != nil {
- file.Close()
- return nil, fmt.Errorf("failed to read existing log for sequence: %w", err)
- }
- scanner := bufio.NewScanner(readFile)
- for scanner.Scan() {
- var entry Entry
- if err := json.Unmarshal(scanner.Bytes(), &entry); err == nil {
- if entry.Sequence > sequence {
- sequence = entry.Sequence
- }
- }
- }
- readFile.Close()
- }
-
- return &FileEventLog{
- sessionID: config.SessionID,
- filePath: filePath,
- file: file,
- writer: bufio.NewWriter(file),
- sequence: sequence,
- }, nil
-}
-
-// NewFileConfig creates a new file-based EventLog instance for the given session.
-// This is a convenience function that wraps NewFileEventLog.
-// Deprecated: Use NewFileEventLog for clarity, or implement your own EventLog.
-func NewFileConfig(config FileConfig) (EventLog, error) {
- return NewFileEventLog(config)
-}
-
-// Append writes an entry to the event log.
-// Entries are written in JSON Lines format with atomic appends.
-func (e *FileEventLog) Append(entryType EventType, checkpointID string, data map[string]any) error {
- e.mu.Lock()
- defer e.mu.Unlock()
-
- entry := Entry{
- SessionID: e.sessionID,
- Timestamp: time.Now(),
- Sequence: e.sequence,
- Type: entryType,
- CheckpointID: checkpointID,
- Data: data,
- }
-
- // Increment sequence for next entry
- e.sequence++
-
- jsonData, err := json.Marshal(entry)
- if err != nil {
- return fmt.Errorf("failed to marshal entry: %w", err)
- }
- if _, err := e.writer.Write(jsonData); err != nil {
- return fmt.Errorf("failed to write entry: %w", err)
- }
- if _, err := e.writer.WriteString("\n"); err != nil {
- return fmt.Errorf("failed to write newline: %w", err)
- }
-
- // Flush to ensure durability
- if err := e.writer.Flush(); err != nil {
- return fmt.Errorf("failed to flush: %w", err)
- }
- if err := e.file.Sync(); err != nil {
- return fmt.Errorf("failed to sync: %w", err)
- }
- return nil
-}
-
-// AppendContent writes a content message to the event log with a checkpoint UUID.
-func (e *FileEventLog) AppendContent(ctx context.Context, direction EventType, checkpointID string, content *proto.Content) error {
- data := map[string]any{
- "role": content.Role,
- "type": content.Type,
- "mimetype": content.Mimetype,
- "data": content.Data,
- }
- return e.Append(direction, checkpointID, data)
-}
-
-func (e *FileEventLog) AppendState(ctx context.Context, state proto.State) error {
- data := map[string]any{
- "state": state,
- }
- return e.Append(EventTypeState, "", data)
-}
-
-// Close closes the event log file.
-func (e *FileEventLog) Close() error {
- e.mu.Lock()
- defer e.mu.Unlock()
-
- if err := e.writer.Flush(); err != nil {
- return fmt.Errorf("failed to flush on close: %w", err)
- }
- if err := e.file.Close(); err != nil {
- return fmt.Errorf("failed to close file: %w", err)
- }
- return nil
-}
-
-// FilePath returns the path to the event log file.
-// This method is specific to FileEventLog and not part of the EventLog interface.
-func (e *FileEventLog) FilePath() string {
- return e.filePath
-}
-
-// SessionID returns the session ID for this event log.
-func (e *FileEventLog) SessionID() string {
- return e.sessionID
-}
-
-// RetrieveEntries reads and returns all entries from the event log file.
-// Returns entries in order.
-func (e *FileEventLog) RetrieveEntries(ctx context.Context) ([]Entry, proto.State, error) {
- e.mu.Lock()
- defer e.mu.Unlock()
-
- var state proto.State
-
- // Flush any pending writes first
- if err := e.writer.Flush(); err != nil {
- return nil, 0, fmt.Errorf("failed to flush before reading: %w", err)
- }
-
- // Check if file exists
- if _, err := os.Stat(e.filePath); os.IsNotExist(err) {
- return nil, 0, fmt.Errorf("event log file does not exist")
- }
-
- // Open file for reading
- readFile, err := os.Open(e.filePath)
- if err != nil {
- return nil, 0, fmt.Errorf("failed to open event log file for reading: %w", err)
- }
- defer readFile.Close()
-
- var entries []Entry
- scanner := bufio.NewScanner(readFile)
-
- lineNum := 0
- var expectedSequence int64 = 0
- for scanner.Scan() {
- lineNum++
- line := scanner.Bytes()
-
- var entry Entry
- if err := json.Unmarshal(line, &entry); err != nil {
- return nil, 0, fmt.Errorf("failed to unmarshal entry at line %d: %w", lineNum, err)
- }
-
- // Validate sequence number
- if entry.Sequence != expectedSequence {
- return nil, 0, fmt.Errorf("sequence number mismatch at line %d: expected %d, got %d", lineNum, expectedSequence, entry.Sequence)
- }
-
- expectedSequence++
- if entry.Type == EventTypeState {
- if s, ok := entry.Data["state"].(proto.State); ok {
- state = s
- }
- }
- entries = append(entries, entry)
- }
-
- if err := scanner.Err(); err != nil {
- return nil, 0, fmt.Errorf("error reading event log file: %w", err)
- }
-
- return entries, state, nil
-}
diff --git a/internal/harness/antigravity/antigravity.go b/internal/harness/antigravity/antigravity.go
new file mode 100644
index 00000000..1185dad3
--- /dev/null
+++ b/internal/harness/antigravity/antigravity.go
@@ -0,0 +1,216 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravity
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/signal"
+ "path/filepath"
+ "sync"
+ "syscall"
+
+ "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
+ "go.opentelemetry.io/otel"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+
+ "github.com/google/ax/internal/config"
+ "github.com/google/ax/internal/harness"
+ "github.com/google/ax/internal/pythonsidecar"
+ "github.com/google/ax/proto"
+ "github.com/google/ax/python"
+ "github.com/google/uuid"
+)
+
+// Compile-time interface assertions.
+var _ harness.Harness = (*AntigravityHarness)(nil)
+var _ harness.Execution = (*antigravityExecution)(nil)
+
+// DefaultStateDir returns the default trajectory storage directory,
+// ~/.ax/antigravity/conversations. AX owns this path so it stays a harness
+// implementation detail rather than a user-facing ax.yaml knob. It lives
+// outside the agent's working directory on purpose: the working directory is
+// the agent's operating surface (it reads and edits files there), so AX's
+// internal state is kept separate to avoid the agent seeing or clobbering it.
+func DefaultStateDir() (string, error) {
+ axDir, err := config.AXAssetsDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(axDir, "antigravity", "conversations"), nil
+}
+
+// AntigravityHarness implements the Harness interface by connecting to the
+// Antigravity Python agent server over gRPC.
+type AntigravityHarness struct {
+ address string
+ sidecar *pythonsidecar.Sidecar // non-nil when this harness forked the process
+}
+
+// New creates a new AntigravityHarness. When autoStart is true, New forks the
+// Antigravity Python sidecar and waits for it to become reachable. If
+// stateDir is non-empty, it is passed to the sidecar as --state-dir; empty
+// stateDir lets the sidecar apply its own default.
+func New(ctx context.Context, address, stateDir string, autoStart bool) (*AntigravityHarness, error) {
+ if address == "" {
+ address = "127.0.0.1:50053"
+ }
+ h := &AntigravityHarness{address: address}
+ if !autoStart {
+ return h, nil
+ }
+ host, port, err := net.SplitHostPort(address)
+ if err != nil {
+ return nil, fmt.Errorf("antigravity: invalid address %q: %w", address, err)
+ }
+ args := []string{"--host", host, "--port", port}
+ if stateDir != "" {
+ args = append(args, "--state-dir", stateDir)
+ }
+ cfg := pythonsidecar.Config{
+ Module: "python.antigravity.harness_server",
+ Args: args,
+ ReadyFunc: pythonsidecar.TCPReady(address),
+ }
+ sidecar := pythonsidecar.New(cfg)
+ path, err := pythonsidecar.Setup(ctx, pythonsidecar.SetupOptions{
+ FS: python.FS,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("failed to setup antigravity harness server assets: %w", err)
+ }
+ if err := sidecar.Start(ctx, path); err != nil {
+ return nil, fmt.Errorf("failed to start antigravity harness server: %w", err)
+ }
+ h.sidecar = sidecar
+ // Own the sidecar lifecycle here (no registry->controller->antigravity
+ // teardown chain): on ctrl-C / SIGTERM, stop the Python process
+ // directly. Mirrors cmd/ax/harness.go.
+ sigChan := make(chan os.Signal, 1)
+ signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
+ go func() {
+ <-sigChan
+ _ = sidecar.Stop()
+ }()
+ return h, nil
+}
+
+// Start implements Harness.Start.
+func (h *AntigravityHarness) Start(ctx context.Context, conversationID string, harnessConfig []byte) (harness.Execution, error) {
+ return &antigravityExecution{
+ harness: h,
+ conversationID: conversationID,
+ id: uuid.NewString(),
+ harnessConfig: harnessConfig,
+ }, nil
+}
+
+// antigravityExecution implements the Execution interface.
+type antigravityExecution struct {
+ harness *AntigravityHarness
+ conversationID string
+ id string
+ harnessConfig []byte
+
+ mu sync.Mutex
+ queued []*proto.Message
+ closed bool
+}
+
+// ID implements Execution.ID.
+func (e *antigravityExecution) ID() string {
+ return e.id
+}
+
+// Queue implements Execution.Queue.
+func (e *antigravityExecution) Queue(ctx context.Context, msg ...*proto.Message) error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if e.closed {
+ return fmt.Errorf("execution session already closed")
+ }
+ e.queued = append(e.queued, msg...)
+ return nil
+}
+
+// Run executes the turn over gRPC bidirectional streaming and forwards events to the handler.
+func (e *antigravityExecution) Run(ctx context.Context, handler harness.Handler) error {
+ ctx, span := otel.Tracer("antigravity-harness").Start(ctx, "Run")
+ defer span.End()
+
+ e.mu.Lock()
+ if e.closed {
+ e.mu.Unlock()
+ return fmt.Errorf("execution session already closed")
+ }
+ // Retrieve queued inputs.
+ inputs := e.queued
+ e.queued = nil
+ e.mu.Unlock()
+
+ // 1. Connect to the gRPC server
+ dialOpts := []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())}
+ dialOpts = append(dialOpts, grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
+ conn, err := grpc.DialContext(ctx, e.harness.address, dialOpts...)
+ if err != nil {
+ return fmt.Errorf("failed to connect to gRPC harness server at %s: %w", e.harness.address, err)
+ }
+ defer conn.Close()
+
+ // 2. Create HarnessService client.
+ client := proto.NewHarnessServiceClient(conn)
+
+ // 3. Build standard HarnessRequest.
+ start := &proto.HarnessRequest{
+ ConversationId: e.conversationID,
+ HarnessId: "antigravity",
+ Type: &proto.HarnessRequest_Start{
+ Start: &proto.HarnessStart{
+ HarnessConfig: e.harnessConfig,
+ Messages: inputs,
+ },
+ },
+ }
+
+ // 4. Call Connect to start bidirectional streaming
+ stream, err := client.Connect(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to call gRPC HarnessService.Connect: %w", err)
+ }
+ // A server that fails before reading the start frame makes Send/CloseSend
+ // report io.EOF; the real status is surfaced by DrainStream's Recv below, so
+ // only treat non-EOF errors as send failures.
+ if err := stream.Send(start); err != nil && err != io.EOF {
+ return fmt.Errorf("failed to send harness start: %w", err)
+ }
+ if err := stream.CloseSend(); err != nil && err != io.EOF {
+ return fmt.Errorf("failed to close stream send direction: %w", err)
+ }
+
+ // 5. Stream responses and trigger callbacks
+ return harness.DrainStream(ctx, stream, e.id, handler)
+}
+
+// Close implements Execution.Close.
+func (e *antigravityExecution) Close(ctx context.Context) error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.closed = true
+ return nil
+}
diff --git a/internal/harness/antigravity/antigravity_test.go b/internal/harness/antigravity/antigravity_test.go
new file mode 100644
index 00000000..c01ade27
--- /dev/null
+++ b/internal/harness/antigravity/antigravity_test.go
@@ -0,0 +1,124 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravity
+
+import (
+ "bytes"
+ "context"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "github.com/google/ax/internal/harness/harnesstest"
+ "github.com/google/ax/proto"
+)
+
+var antigravityHarnessConfig = []byte(`{"system_instructions":"be terse"}`)
+
+func TestRun_AutoStartFalse_ServerOK_Succeeds(t *testing.T) {
+ srv := &harnesstest.MockHarnessServer{
+ Outputs: []*proto.Message{harnesstest.ThoughtText("Analyzing"), harnesstest.AssistantText("Hello world")},
+ }
+ harnessClient, err := New(context.Background(), harnesstest.StartHarnessServer(t, srv), "", false)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ exec, err := harnessClient.Start(context.Background(), "conv-test", antigravityHarnessConfig)
+ if err != nil {
+ t.Fatalf("failed to start execution: %v", err)
+ }
+ defer exec.Close(context.Background())
+
+ if err := exec.Queue(context.Background(), harnesstest.UserText("Hi")); err != nil {
+ t.Fatalf("failed to queue message: %v", err)
+ }
+
+ handler := &harnesstest.MockHandler{}
+ if err := exec.Run(context.Background(), handler); err != nil {
+ t.Fatalf("Run failed: %v", err)
+ }
+
+ if !handler.IsDone() {
+ t.Error("expected OnComplete to be called")
+ }
+ msgs := handler.Collected()
+ if len(msgs) != 2 {
+ t.Fatalf("expected 2 messages, got %d", len(msgs))
+ }
+ if got := msgs[0].GetContent().GetThought().GetSummary()[0].GetText().GetText(); got != "Analyzing" {
+ t.Errorf("expected 'Analyzing', got %q", got)
+ }
+ if got := msgs[1].GetContent().GetText().GetText(); got != "Hello world" {
+ t.Errorf("expected 'Hello world', got %q", got)
+ }
+ // The harness propagated the conversation id and config to the server.
+ convID, _, harnessConfig, _ := srv.Received()
+ if convID != "conv-test" {
+ t.Errorf("server got convID=%q, want conv-test", convID)
+ }
+ if !bytes.Equal(harnessConfig, antigravityHarnessConfig) {
+ t.Errorf("server got harnessConfig=%q, want %q", harnessConfig, antigravityHarnessConfig)
+ }
+}
+
+func TestRun_AutoStartFalse_ServerErrorFrame_Fails(t *testing.T) {
+ srv := &harnesstest.MockHarnessServer{FailConnect: true, ErrMessage: "internal mock server crash"}
+ harnessClient, err := New(context.Background(), harnesstest.StartHarnessServer(t, srv), "", false)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+
+ exec, _ := harnessClient.Start(context.Background(), "conv-test", antigravityHarnessConfig)
+ defer exec.Close(context.Background())
+
+ if err := exec.Queue(context.Background(), harnesstest.UserText("Hi")); err != nil {
+ t.Fatalf("failed to queue message: %v", err)
+ }
+
+ err = exec.Run(context.Background(), &harnesstest.MockHandler{})
+ if err == nil {
+ t.Fatal("expected error from Run(), got nil")
+ }
+ if !strings.Contains(err.Error(), "internal mock server crash") {
+ t.Errorf("unexpected error message: %v", err)
+ }
+}
+
+// TestNew_AutoStartFalse_NilSidecar: autoStart=false leaves sidecar nil.
+func TestNew_AutoStartFalse_NilSidecar(t *testing.T) {
+ h, err := New(context.Background(), "127.0.0.1:1", "", false)
+ if err != nil {
+ t.Fatalf("New: %v", err)
+ }
+ if h.sidecar != nil {
+ t.Errorf("expected sidecar to be nil, got %v", h.sidecar)
+ }
+}
+
+// TestDefaultStateDir returns ~/.ax/antigravity/conversations under the user's
+// home directory.
+func TestDefaultStateDir(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ got, err := DefaultStateDir()
+ if err != nil {
+ t.Fatalf("DefaultStateDir: %v", err)
+ }
+ if want := filepath.Join(home, ".ax", "antigravity", "conversations"); got != want {
+ t.Errorf("DefaultStateDir() = %q, want %q", got, want)
+ }
+}
diff --git a/internal/harness/antigravityinteractions/antigravityinteractions.go b/internal/harness/antigravityinteractions/antigravityinteractions.go
new file mode 100644
index 00000000..cb84faf8
--- /dev/null
+++ b/internal/harness/antigravityinteractions/antigravityinteractions.go
@@ -0,0 +1,975 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravityinteractions
+
+// AntigravityInteractionsHarness drives an Antigravity agent through the Vertex
+// GenAI Interactions API over HTTPS + Server-Sent Events, using the steps-based
+// ("step_list") request format. It implements the Harness interface for the
+// client-side ("local") environment: the agent runs server-side as the brain
+// while the harness, on the client, drives the turn loop and executes every tool
+// the agent yields.
+//
+// Tool execution (all internal to the harness):
+//
+// - Built-in environment tools (view_file, run_command, list_dir, ...) are
+// executed against the local filesystem/shell.
+// - Third-party / MCP tools are executed via a ThirdPartyExecutor, the seam
+// through which the controller injects the caller's tool implementations. If
+// no executor is configured, no third-party tools are advertised.
+//
+// Neither kind of tool call is surfaced to the caller: Run drives the whole
+// interaction loop to completion (initial turn -> continuation turn -> ... ->
+// final answer) and only forwards the agent's text output via Handler.OnMessage.
+// Each turn after the first is chained to the previous one via the Interactions
+// API's previous_interaction_id; this is an implementation detail of the loop,
+// not an AX resume.
+//
+// Queue carries human input only -- the initial prompt and "steering" messages
+// injected mid-run. It never carries tool results (the harness produces those
+// itself). Queued input is drained at each interaction gap, which is the only
+// place the harness can influence an otherwise atomic interaction.
+
+import (
+ "bufio"
+ "bytes"
+ "context"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "math/rand"
+ "net/http"
+ "os"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "sync"
+ "time"
+
+ "github.com/google/ax/internal/config"
+ "github.com/google/ax/internal/harness"
+ "github.com/google/ax/proto"
+ "github.com/google/uuid"
+ "golang.org/x/oauth2"
+ "golang.org/x/oauth2/google"
+)
+
+const (
+ // interactionsEndpoint is the public Vertex GenAI dataplane endpoint.
+ interactionsEndpoint = "https://aiplatform.googleapis.com"
+ // interactionsAPIVersion is the Interactions API version this harness targets.
+ interactionsAPIVersion = "v1beta1"
+ // cloudPlatformScope is the OAuth2 scope required to call Vertex AI.
+ cloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
+
+ // Cloud project and location are read from these standard environment
+ // variables (see https://github.com/google/ax#authentication).
+ envCloudProject = "GOOGLE_CLOUD_PROJECT"
+ envCloudLocation = "GOOGLE_CLOUD_LOCATION"
+ // defaultLocation is used when GOOGLE_CLOUD_LOCATION is unset.
+ defaultLocation = "global"
+
+ // DefaultAgent is the Interactions API agent used when the harness is
+ // registered without an explicit agent override.
+ DefaultAgent = "antigravity-preview-05-2026"
+)
+
+// Compile-time interface assertions.
+var _ harness.Harness = (*AntigravityInteractionsHarness)(nil)
+var _ harness.Execution = (*antigravityInteractionsExecution)(nil)
+
+// AntigravityInteractionsConfig configures an AntigravityInteractionsHarness.
+// Use New, which fills sensible defaults.
+//
+// Cloud project and location come from the standard GOOGLE_CLOUD_PROJECT and
+// GOOGLE_CLOUD_LOCATION environment variables.
+type AntigravityInteractionsConfig struct {
+ // --- Required ---
+
+ // Agent is the Interactions API agent name to run. Required: the API rejects a
+ // request with no agent.
+ Agent string
+
+ // StateDir is the directory where each conversation's resume cursor is
+ // persisted, so a conversation can resume after a restart. Required: New
+ // returns an error if it is empty.
+ //
+ // Correctness relies on a single writer per conversation: writes are
+ // last-write-wins with no compare-and-swap. This is an expectation the caller
+ // must satisfy (e.g. by routing a conversation id to one worker, or resuming
+ // a worker from that conversation's snapshot) -- it is not currently enforced
+ // by the controller.
+ StateDir string
+
+ // --- Optional ---
+
+ // SystemInstruction, if set, is sent as the interaction's system_instruction
+ // (a free-form system prompt prepended to the agent's own instructions). It
+ // is sent on every turn of the interaction loop so it persists across them.
+ SystemInstruction string
+ // MaxTurns caps the number of interaction turns the harness will drive within
+ // a single Run before giving up. Defaults to 100.
+ MaxTurns int
+ // Debug, if true, logs concise per-conversation tool activity to stderr: a
+ // line for each function call (FC) the agent yields and each function result
+ // (FR) the harness produces. Useful for observing the FC/FR exchange that is
+ // otherwise internal to the harness.
+ Debug bool
+ // ThirdPartyExecutor executes third-party (non-built-in) function tool calls
+ // and declares them to the agent. It is the seam for the controller to inject
+ // the caller's tool implementations. If nil, the harness advertises no
+ // third-party tools and any non-built-in call the agent attempts yields an
+ // error result.
+ ThirdPartyExecutor ThirdPartyExecutor
+ // TokenSource overrides how the bearer token is obtained. If nil, the harness
+ // builds an auto-refreshing source from Application Default Credentials.
+ TokenSource oauth2.TokenSource
+}
+
+func (c *AntigravityInteractionsConfig) withDefaults() {
+ if c.MaxTurns == 0 {
+ c.MaxTurns = 100
+ }
+}
+
+// cloudProject returns the Cloud project id from GOOGLE_CLOUD_PROJECT.
+func cloudProject() string {
+ return os.Getenv(envCloudProject)
+}
+
+// cloudLocation returns the Cloud location from GOOGLE_CLOUD_LOCATION, falling
+// back to the default ("global").
+func cloudLocation() string {
+ if loc := os.Getenv(envCloudLocation); loc != "" {
+ return loc
+ }
+ return defaultLocation
+}
+
+// DefaultStateDir returns the default resume-cursor directory, ~/.ax/antigravityinteractions/cursors,
+// used when a caller does not set StateDir explicitly. It lives outside the
+// agent's working directory on purpose: the working directory is the agent's
+// operating surface (it reads and edits files there), so AX's internal state is
+// kept separate to avoid the agent seeing or clobbering it. New still requires a
+// non-empty StateDir; callers apply this default.
+func DefaultStateDir() (string, error) {
+ axDir, err := config.AXAssetsDir()
+ if err != nil {
+ return "", err
+ }
+ return filepath.Join(axDir, "antigravityinteractions", "cursors"), nil
+}
+
+// AntigravityInteractionsHarness implements Harness by talking to the public
+// Vertex GenAI Interactions API.
+type AntigravityInteractionsHarness struct {
+ cfg AntigravityInteractionsConfig
+ httpClient *http.Client
+
+ // cursors persists each conversation's resume cursor to disk so a conversation
+ // can resume after a restart. It is always non-nil (the constructor requires a
+ // usable state directory).
+ cursors *cursorStore
+
+ // tsOnce guards lazy initialization of ts, the resolved OAuth2 token source.
+ // It is resolved on first use (rather than in the constructor) so credential
+ // errors surface to the caller of Run instead of at construction time.
+ tsOnce sync.Once
+ ts oauth2.TokenSource
+ tsErr error
+}
+
+// New creates a harness from the given config, filling in defaults for unset
+// fields. It returns an error if cfg.StateDir is empty or the cursor store cannot
+// be created: resume-cursor persistence is required, so a usable state directory
+// must be provided.
+func New(cfg AntigravityInteractionsConfig) (*AntigravityInteractionsHarness, error) {
+ return newWithHTTPClient(cfg, nil)
+}
+
+// newWithHTTPClient is New with an injectable HTTP client. If hc is nil it builds
+// the default client. It exists so tests can inject a fake transport; the public
+// config intentionally does not expose an HTTP client override.
+func newWithHTTPClient(cfg AntigravityInteractionsConfig, hc *http.Client) (*AntigravityInteractionsHarness, error) {
+ cfg.withDefaults()
+ if hc == nil {
+ // Disable HTTP keep-alives so no TCP/TLS connection is reused across
+ // requests. On substrate the actor is suspended after a turn and resumed
+ // for the next (with a new routable IP), which leaves any pooled keep-alive
+ // connection stale; reusing it makes the next turn's request fail. Opening a
+ // fresh connection per request avoids that at the cost of an extra handshake.
+ //
+ // Clone DefaultTransport (rather than a bare &http.Transport{}) so we inherit
+ // its production defaults -- proxy from the environment, dial/TLS/handshake
+ // timeouts, and HTTP/2 -- and only override keep-alives.
+ tr := http.DefaultTransport.(*http.Transport).Clone()
+ tr.DisableKeepAlives = true
+ hc = &http.Client{Timeout: 10 * time.Minute, Transport: tr}
+ }
+ if cfg.StateDir == "" {
+ return nil, errors.New("AntigravityInteractionsConfig.StateDir must be set")
+ }
+ cursors, err := newCursorStore(cfg.StateDir)
+ if err != nil {
+ return nil, fmt.Errorf("creating cursor store: %w", err)
+ }
+ return &AntigravityInteractionsHarness{cfg: cfg, httpClient: hc, cursors: cursors}, nil
+}
+
+// Start implements Harness.Start. It loads any previously persisted resume
+// cursor for conversationID so the returned Execution resumes the existing
+// interaction chain instead of starting a new one.
+func (h *AntigravityInteractionsHarness) Start(ctx context.Context, conversationID string, harnessConfig []byte) (harness.Execution, error) {
+ e := &antigravityInteractionsExecution{
+ harness: h,
+ conversationID: conversationID,
+ id: uuid.NewString(),
+ harnessConfig: harnessConfig,
+ }
+
+ cur, found, err := h.cursors.load(conversationID)
+ if err != nil {
+ // A real load failure must not be silently treated as "new", which would
+ // lose an existing conversation's history.
+ return nil, fmt.Errorf("loading resume cursor for %q: %w", conversationID, err)
+ }
+ if found {
+ // A persisted cursor is only written after a successful turn, so a
+ // non-empty interaction id means the conversation has already started;
+ // the first-turn check in Run derives that from prevInteractionID.
+ e.prevInteractionID = cur.PrevInteractionID
+ }
+ return e, nil
+}
+
+// antigravityInteractionsExecution implements Execution. It is long-lived
+// across Run calls and owns the interaction chain (prevInteractionID). Queue
+// may be called concurrently with Run to inject steering input, which is
+// drained at each interaction gap.
+type antigravityInteractionsExecution struct {
+ harness *AntigravityInteractionsHarness
+ conversationID string
+ id string
+ harnessConfig []byte
+
+ mu sync.Mutex
+ queued []*proto.Message
+ closed bool
+
+ // prevInteractionID chains the turns of the interaction loop (the interaction
+ // chain this Execution owns), and is the value persisted for cross-Execution
+ // resume. It is empty until the first turn completes successfully; an empty
+ // value therefore means "no turn has succeeded yet" (the first turn).
+ prevInteractionID string
+}
+
+// ID implements Execution.ID.
+func (e *antigravityInteractionsExecution) ID() string { return e.id }
+
+// Queue implements Execution.Queue. It carries human input only: the initial
+// prompt, or steering messages injected mid-run. Tool results are NOT queued by
+// the caller -- the harness executes all tools itself. Queued messages are
+// drained at the next interaction gap within Run.
+func (e *antigravityInteractionsExecution) Queue(ctx context.Context, msg ...*proto.Message) error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ if e.closed {
+ return fmt.Errorf("execution session already closed")
+ }
+ e.queued = append(e.queued, msg...)
+ return nil
+}
+
+// Close implements Execution.Close.
+func (e *antigravityInteractionsExecution) Close(ctx context.Context) error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.closed = true
+ return nil
+}
+
+// drainQueue removes and returns any queued human input, converting it to
+// user_input steps. Called at each interaction gap so newly-queued steering
+// messages are folded into the next interaction.
+func (e *antigravityInteractionsExecution) drainQueue() []any {
+ e.mu.Lock()
+ msgs := e.queued
+ e.queued = nil
+ e.mu.Unlock()
+ return messagesToInputSteps(msgs)
+}
+
+// setPrevID records the latest interaction id (in memory) and durably persists
+// the resume cursor so the conversation can resume after a restart. A persistence
+// failure is returned to the caller so the run can decide how to handle it rather
+// than silently losing durability.
+func (e *antigravityInteractionsExecution) setPrevID(ctx context.Context, id string) error {
+ if id == "" {
+ return nil
+ }
+ e.mu.Lock()
+ e.prevInteractionID = id
+ e.mu.Unlock()
+
+ if err := e.harness.cursors.save(e.conversationID, resumeCursor{PrevInteractionID: id}); err != nil {
+ return fmt.Errorf("persisting resume cursor: %w", err)
+ }
+ return nil
+}
+
+// Run implements Execution.Run. It drives the interaction to completion,
+// executing every tool the agent yields internally (built-in env tools locally,
+// third-party tools via the configured executor) and resuming until the agent
+// produces a final answer (a turn with no tool calls) or the turn cap is hit.
+//
+// The agent's text output is forwarded via handler.OnMessage; handler.OnComplete
+// is called once when the conversation finishes. At each interaction gap, any
+// human input queued via Queue (steering) is folded into the next turn.
+func (e *antigravityInteractionsExecution) Run(ctx context.Context, handler harness.Handler) error {
+ e.mu.Lock()
+ if e.closed {
+ e.mu.Unlock()
+ return fmt.Errorf("execution session already closed")
+ }
+ prevID := e.prevInteractionID
+ e.mu.Unlock()
+
+ token, err := e.harness.token(ctx)
+ if err != nil {
+ return fmt.Errorf("obtaining access token: %w", err)
+ }
+
+ // Initial input for this Run: drain whatever is queued (the prompt on the
+ // first Run, or steering input on later Runs). An empty prevID means no turn
+ // has completed yet, so this is the conversation's first turn. (A first turn
+ // that failed leaves prevID empty, so a retried Run is correctly treated as
+ // the first turn again.)
+ input := e.drainQueue()
+ if len(input) == 0 {
+ if prevID == "" {
+ return fmt.Errorf("no input messages queued for the initial turn")
+ }
+ return fmt.Errorf("Run called with no queued input and no work pending")
+ }
+
+ // Set the working directory for this execution. Substrate resets the
+ // process cwd to the OCI spec's root directory, "/" after gVisor
+ // restore. We need to explicitly set it for each execution.
+ if workDir := os.Getenv("AX_HARNESS_WORKDIR"); workDir != "" {
+ if err := os.Chdir(workDir); err != nil {
+ return fmt.Errorf("set working directory %q: %w", workDir, err)
+ }
+ }
+
+ res, err := e.harness.postTurn(ctx, token, e.harness.newRequest(input, prevID))
+ if err != nil {
+ return fmt.Errorf("interaction turn failed: %w", err)
+ }
+ prevID = res.interactionID
+ if err := e.setPrevID(ctx, prevID); err != nil {
+ return err
+ }
+
+ for turn := 0; turn < e.harness.cfg.MaxTurns; turn++ {
+ e.harness.debugTurn(e.conversationID, turn+1, len(res.toolCalls))
+ if err := emitText(ctx, handler, e.id, res.modelText); err != nil {
+ return err
+ }
+
+ // Execute every tool the agent yielded (built-in env tools and
+ // third-party tools alike are executed internally) and collect the
+ // results to send back.
+ var next []any
+ for i, call := range res.toolCalls {
+ e.harness.debugCall(e.conversationID, i+1, len(res.toolCalls), call)
+ out := e.harness.executeTool(ctx, call)
+ e.harness.debugResult(e.conversationID, call.name, out)
+ next = append(next, toolResultStep{
+ Type: "function_result",
+ CallID: call.callID,
+ Name: call.name,
+ Result: out,
+ })
+ }
+
+ // Fold in any human input queued (steering) since the last gap.
+ next = append(next, e.drainQueue()...)
+
+ // If the agent yielded no tool calls and there is no queued input to send,
+ // the conversation is complete.
+ if len(next) == 0 {
+ return handler.OnComplete(ctx, e.id)
+ }
+
+ res, err = e.harness.postTurn(ctx, token, e.harness.newRequest(next, prevID))
+ if err != nil {
+ return fmt.Errorf("continuation turn failed: %w", err)
+ }
+ prevID = res.interactionID
+ if err := e.setPrevID(ctx, prevID); err != nil {
+ return err
+ }
+ }
+
+ // Hit the turn cap while still driving tools.
+ return handler.OnComplete(ctx, e.id)
+}
+
+// emitText forwards non-empty model text to the handler as a Message.
+func emitText(ctx context.Context, handler harness.Handler, execID, text string) error {
+ if strings.TrimSpace(text) == "" {
+ return nil
+ }
+ return handler.OnMessage(ctx, execID, &proto.Message{
+ Role: "assistant",
+ Content: &proto.Content{
+ Type: &proto.Content_Text{Text: &proto.TextContent{Text: text}},
+ },
+ })
+}
+
+// messagesToInputSteps converts queued ax Messages (human input) into user_input
+// steps. Only text content is supported as input today.
+func messagesToInputSteps(msgs []*proto.Message) []any {
+ var steps []any
+ for _, m := range msgs {
+ if t, ok := m.GetContent().GetType().(*proto.Content_Text); ok && t.Text.GetText() != "" {
+ steps = append(steps, userInputStep{
+ Type: "user_input",
+ Content: []textPart{{Type: "text", Text: t.Text.GetText()}},
+ })
+ }
+ }
+ return steps
+}
+
+// ---------------------------------------------------------------------------
+// Wire types and helpers (steps-based Interactions API).
+// ---------------------------------------------------------------------------
+
+// textPart is a single Content part within a user_input step.
+type textPart struct {
+ Type string `json:"type"`
+ Text string `json:"text"`
+}
+
+// userInputStep is the steps-based representation of a user prompt.
+type userInputStep struct {
+ Type string `json:"type"` // "user_input"
+ Content []textPart `json:"content"`
+}
+
+// toolResultStep is one tool result returned on a continuation turn: a flat Step with
+// "type":"function_result", the matching call_id, the tool name, and the result
+// object under "result".
+type toolResultStep struct {
+ Type string `json:"type"` // "function_result"
+ CallID string `json:"call_id"`
+ Name string `json:"name"`
+ Result any `json:"result"`
+}
+
+// FunctionTool is a client-declared function tool declaration. It is the element
+// type a ThirdPartyExecutor returns from Declarations.
+type FunctionTool struct {
+ Type string `json:"type"` // "function"
+ Name string `json:"name"`
+ Desc string `json:"description,omitempty"`
+ Params any `json:"parameters,omitempty"`
+}
+
+// environmentConfig is the JSON shape of the Interaction.environment oneof. This
+// harness always uses {"type":"local"} (the client-side environment).
+type environmentConfig struct {
+ Type string `json:"type"`
+}
+
+type interactionRequest struct {
+ Stream bool `json:"stream"`
+ Background bool `json:"background"`
+ Store bool `json:"store"`
+ Agent string `json:"agent"`
+ SystemInstruction string `json:"system_instruction,omitempty"`
+ Environment *environmentConfig `json:"environment,omitempty"`
+ PreviousInteractionID string `json:"previous_interaction_id,omitempty"`
+ Input []any `json:"input,omitempty"`
+ Tools []FunctionTool `json:"tools,omitempty"`
+}
+
+// capturedToolCall is a tool call the agent yielded during a turn.
+type capturedToolCall struct {
+ callID string
+ name string
+ arguments map[string]any
+}
+
+// turnResult is what we extracted from streaming one turn.
+type turnResult struct {
+ interactionID string
+ toolCalls []capturedToolCall
+ modelText string
+}
+
+// Debug output layout. When Debug is enabled, each Run turn is logged as a
+// hierarchical block so the function-call / function-result (FC/FR) exchange is
+// easy to follow:
+//
+// [harness:e2e-conv] ==== turn 1 (2 function calls) ============
+// [harness:e2e-conv] FC 1/2 list_dir
+// [harness:e2e-conv] DirectoryPath: .
+// [harness:e2e-conv] explanation: Listing the current directory.
+// [harness:e2e-conv] FR list_dir
+// [harness:e2e-conv] results: [...]
+// [harness:e2e-conv] FC 2/2 run_command
+// [harness:e2e-conv] CommandLine: pwd
+// [harness:e2e-conv] FR run_command
+// [harness:e2e-conv] ExitCode: 0
+// [harness:e2e-conv] Output: /home/user/project
+const (
+ debugIndentCall = " " // FC / FR lines, under the turn header
+ debugIndentDetail = " " // params and result values, under FC / FR
+)
+
+// debugln writes a single debug line (prefixed with the conversation id) to
+// stderr when Debug is enabled.
+func (h *AntigravityInteractionsHarness) debugln(conversationID, line string) {
+ if !h.cfg.Debug {
+ return
+ }
+ fmt.Fprintf(os.Stderr, "[harness:%s] %s\n", conversationID, line)
+}
+
+// debugTurn logs the separator header that begins a turn.
+func (h *AntigravityInteractionsHarness) debugTurn(conversationID string, turn, numCalls int) {
+ if !h.cfg.Debug {
+ return
+ }
+ plural := "s"
+ if numCalls == 1 {
+ plural = ""
+ }
+ header := fmt.Sprintf("==== turn %d (%d function call%s) ", turn, numCalls, plural)
+ // Pad the header with '=' to a fixed width for an easy visual break.
+ if pad := 56 - len(header); pad > 0 {
+ header += strings.Repeat("=", pad)
+ }
+ h.debugln(conversationID, header)
+}
+
+// debugCall logs a function call (FC): a header line plus one indented line per
+// argument (keys sorted for stable output).
+func (h *AntigravityInteractionsHarness) debugCall(conversationID string, idx, total int, call capturedToolCall) {
+ if !h.cfg.Debug {
+ return
+ }
+ h.debugln(conversationID, fmt.Sprintf("%sFC %d/%d %s", debugIndentCall, idx, total, call.name))
+ h.debugKeyValues(conversationID, call.arguments)
+}
+
+// debugResult logs a function result (FR): a header line plus the indented
+// result value(s).
+func (h *AntigravityInteractionsHarness) debugResult(conversationID, name string, result any) {
+ if !h.cfg.Debug {
+ return
+ }
+ h.debugln(conversationID, fmt.Sprintf("%sFR %s", debugIndentCall, name))
+ if m, ok := result.(map[string]any); ok {
+ h.debugKeyValues(conversationID, m)
+ return
+ }
+ h.debugln(conversationID, debugIndentDetail+debugTruncate(fmt.Sprintf("%v", result)))
+}
+
+// debugKeyValues logs a map as one "key: value" line per entry, indented under
+// an FC/FR header, with keys sorted for stable output.
+func (h *AntigravityInteractionsHarness) debugKeyValues(conversationID string, m map[string]any) {
+ keys := make([]string, 0, len(m))
+ for k := range m {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ h.debugln(conversationID, fmt.Sprintf("%s%s: %s", debugIndentDetail, k, debugTruncate(fmt.Sprintf("%v", m[k]))))
+ }
+}
+
+// debugTruncate collapses newlines and caps very long values so a single debug
+// line stays readable.
+func debugTruncate(s string) string {
+ s = strings.ReplaceAll(s, "\n", "\\n")
+ const max = 200
+ if len(s) > max {
+ return s[:max] + "…"
+ }
+ return s
+}
+
+func (h *AntigravityInteractionsHarness) interactionsURL() string {
+ return fmt.Sprintf("%s/%s/projects/%s/locations/%s/interactions",
+ interactionsEndpoint, interactionsAPIVersion, cloudProject(), cloudLocation())
+}
+
+// Endpoint returns the Interactions API base endpoint this harness targets
+// (e.g. the prod or autopush host). Exposed so callers/tools can log which
+// backend is in use, since the endpoint is a compile-time constant.
+func Endpoint() string { return interactionsEndpoint }
+
+// APIVersion returns the Interactions API version this harness targets.
+func APIVersion() string { return interactionsAPIVersion }
+
+// token returns a bearer access token from the harness's OAuth2 token source.
+// The source is resolved once (lazily) and auto-refreshes thereafter.
+func (h *AntigravityInteractionsHarness) token(ctx context.Context) (string, error) {
+ h.tsOnce.Do(func() {
+ if h.cfg.TokenSource != nil {
+ h.ts = h.cfg.TokenSource
+ return
+ }
+ h.ts, h.tsErr = newTokenSource(ctx)
+ })
+ if h.tsErr != nil {
+ return "", h.tsErr
+ }
+ tok, err := h.ts.Token()
+ if err != nil {
+ return "", fmt.Errorf("obtaining access token: %w", err)
+ }
+ return tok.AccessToken, nil
+}
+
+// newTokenSource builds an auto-refreshing OAuth2 token source for Vertex AI
+// from Application Default Credentials.
+func newTokenSource(ctx context.Context) (oauth2.TokenSource, error) {
+ creds, err := google.FindDefaultCredentials(ctx, cloudPlatformScope)
+ if err != nil {
+ return nil, fmt.Errorf("finding application default credentials: %w", err)
+ }
+ return creds.TokenSource, nil
+}
+
+// newRequest builds an interactionRequest common to every turn. The environment
+// is always the client-side ("local") environment -- this harness exists to
+// execute the agent's built-in env tools locally. Tools are re-declared on every
+// turn so they stay known to the agent across the turns of the interaction loop.
+func (h *AntigravityInteractionsHarness) newRequest(input []any, previousID string) interactionRequest {
+ var tools []FunctionTool
+ if h.cfg.ThirdPartyExecutor != nil {
+ tools = h.cfg.ThirdPartyExecutor.Declarations()
+ }
+ return interactionRequest{
+ Stream: true,
+ Background: true,
+ Store: true,
+ Agent: h.cfg.Agent,
+ SystemInstruction: h.cfg.SystemInstruction,
+ Environment: &environmentConfig{Type: "local"},
+ PreviousInteractionID: previousID,
+ Input: input,
+ Tools: tools,
+ }
+}
+
+// retryableHTTPError marks a transient HTTP response that should be retried.
+// retryAfter is the server-suggested delay (parsed from the Retry-After
+// header), or 0 if none was provided.
+type retryableHTTPError struct {
+ status int
+ retryAfter time.Duration
+ body string
+}
+
+func (e *retryableHTTPError) Error() string {
+ return fmt.Sprintf("HTTP %d: %s", e.status, e.body)
+}
+
+// Retry tuning for transient rate-limit (429) responses. The stateful
+// interaction quota is per-minute, so the backoff must be able to span ~a
+// minute; with these values the cumulative wait reaches ~1-2 minutes before
+// giving up.
+const (
+ rateLimitMaxRetries = 6
+ rateLimitBaseDelay = 2 * time.Second
+ rateLimitMaxDelay = 32 * time.Second
+)
+
+// postTurn POSTs one turn, retrying on HTTP 429 (rate limit / quota) with
+// exponential backoff and jitter, honoring a Retry-After header when present.
+// Creating an interaction is not idempotent, so only 429 -- which is rejected
+// before any interaction is created -- is retried here.
+func (h *AntigravityInteractionsHarness) postTurn(ctx context.Context, token string, reqBody interactionRequest) (*turnResult, error) {
+ delay := rateLimitBaseDelay
+ for attempt := 0; ; attempt++ {
+ res, err := h.postTurnOnce(ctx, token, reqBody)
+ if err == nil {
+ return res, nil
+ }
+ var re *retryableHTTPError
+ if !errors.As(err, &re) || attempt >= rateLimitMaxRetries {
+ return nil, err
+ }
+
+ wait := re.retryAfter
+ if wait <= 0 {
+ wait = backoffWithJitter(delay)
+ }
+ fmt.Fprintf(os.Stderr, "[harness] rate limited (HTTP %d); retrying in %s (attempt %d/%d)\n",
+ re.status, wait.Round(time.Millisecond), attempt+1, rateLimitMaxRetries)
+
+ select {
+ case <-ctx.Done():
+ return nil, ctx.Err()
+ case <-time.After(wait):
+ }
+ if delay *= 2; delay > rateLimitMaxDelay {
+ delay = rateLimitMaxDelay
+ }
+ }
+}
+
+// backoffWithJitter returns a randomized delay in [delay/2, delay] to avoid
+// synchronized retries.
+func backoffWithJitter(delay time.Duration) time.Duration {
+ half := delay / 2
+ return half + time.Duration(rand.Int63n(int64(half)+1))
+}
+
+// parseRetryAfter parses a Retry-After header value, which may be either an
+// integer number of seconds or an HTTP date. Returns 0 if absent or unparseable.
+func parseRetryAfter(v string) time.Duration {
+ v = strings.TrimSpace(v)
+ if v == "" {
+ return 0
+ }
+ if secs, err := strconv.Atoi(v); err == nil {
+ if secs <= 0 {
+ return 0
+ }
+ return time.Duration(secs) * time.Second
+ }
+ if t, err := http.ParseTime(v); err == nil {
+ if d := time.Until(t); d > 0 {
+ return d
+ }
+ }
+ return 0
+}
+
+// postTurnOnce POSTs the request and streams the SSE response for one turn.
+func (h *AntigravityInteractionsHarness) postTurnOnce(ctx context.Context, token string, reqBody interactionRequest) (*turnResult, error) {
+ body, err := json.Marshal(reqBody)
+ if err != nil {
+ return nil, err
+ }
+ httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, h.interactionsURL(), bytes.NewReader(body))
+ if err != nil {
+ return nil, err
+ }
+ httpReq.Header.Set("Authorization", "Bearer "+token)
+ httpReq.Header.Set("Content-Type", "application/json")
+ httpReq.Header.Set("X-Goog-User-Project", cloudProject())
+
+ resp, err := h.httpClient.Do(httpReq)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode != http.StatusOK {
+ b := new(bytes.Buffer)
+ if _, err := b.ReadFrom(resp.Body); err != nil {
+ return nil, fmt.Errorf("HTTP %d (failed to read error body: %v)", resp.StatusCode, err)
+ }
+ msg := b.String()
+ // 429 means the request was rejected by quota before any interaction was
+ // created, so it is safe to retry (unlike a partial 5xx). Mark it.
+ if resp.StatusCode == http.StatusTooManyRequests {
+ return nil, &retryableHTTPError{
+ status: resp.StatusCode,
+ retryAfter: parseRetryAfter(resp.Header.Get("Retry-After")),
+ body: msg,
+ }
+ }
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, msg)
+ }
+ return h.parseStreamedTurn(resp.Body)
+}
+
+// serverErrorMessage extracts a human-readable message from an SSE "error"
+// event. The error payload is an object with a "message" field (and often a
+// "status"/"code"); e.g. {"error":{"message":"...","status":"INVALID_ARGUMENT"}}.
+// Falls back to a generic string when the shape is unexpected so the caller
+// always gets a non-empty error.
+func serverErrorMessage(event map[string]any) string {
+ errObj, ok := event["error"].(map[string]any)
+ if !ok {
+ return "unknown server error"
+ }
+ msg, _ := errObj["message"].(string)
+ status, _ := errObj["status"].(string)
+ switch {
+ case msg != "" && status != "":
+ return fmt.Sprintf("%s (%s)", msg, status)
+ case msg != "":
+ return msg
+ case status != "":
+ return status
+ default:
+ return "unknown server error"
+ }
+}
+
+// parseStreamedTurn parses the event:/data: Server-Sent Events stream for one
+// interaction turn and extracts the tool calls the agent yielded and the model's
+// text output.
+//
+// Function-call streaming works per step index: a step.start announces the call
+// (id, name, sometimes inline arguments), then step.delta "arguments_delta"
+// events carry the arguments as a JSON STRING. The server re-emits the SAME call
+// (same id) at several consecutive indices while it streams, and each emission
+// carries the COMPLETE arguments JSON (a snapshot, not an incremental fragment).
+// So we track state per step index, always REPLACING the latest arguments
+// snapshot, then dedupe by id at the end -- concatenating snapshots would
+// corrupt the JSON.
+func (h *AntigravityInteractionsHarness) parseStreamedTurn(body io.Reader) (*turnResult, error) {
+ turn := &turnResult{}
+
+ // pendingCall accumulates one function call as it streams in.
+ type pendingCall struct {
+ id string
+ name string
+ argsJSON string // latest complete arguments JSON snapshot
+ args map[string]any // inline arguments, if provided directly
+ }
+ callsByStepIndex := map[int]*pendingCall{}
+ var stepIndexOrder []int
+
+ scanner := bufio.NewScanner(body)
+ scanner.Buffer(make([]byte, 64*1024), 64*1024*1024)
+ for scanner.Scan() {
+ line := scanner.Text()
+ if !strings.HasPrefix(line, "data:") {
+ continue
+ }
+ eventJSON := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
+ if eventJSON == "[DONE]" {
+ break
+ }
+ var event map[string]any
+ if err := json.Unmarshal([]byte(eventJSON), &event); err != nil {
+ continue
+ }
+ switch event["event_type"] {
+ case "error":
+ // The server reports a turn-level failure (e.g. INVALID_ARGUMENT for a
+ // malformed client tool result) as an SSE "error" event. Surface it as
+ // a real error so the caller aborts the turn instead of treating the
+ // stream as a completed-but-empty turn. Silently dropping it made an
+ // empty directory listing look like a blank "no response, seq=0" turn
+ // AND caused the failing turn's interaction id to be persisted as a
+ // poisoned resume cursor.
+ return nil, fmt.Errorf(
+ "antigravity interactions: server error event: %s",
+ serverErrorMessage(event),
+ )
+
+ case "interaction.created", "interaction.completed", "interaction.status_update":
+ if interaction, ok := event["interaction"].(map[string]any); ok {
+ if id, ok := interaction["id"].(string); ok && id != "" {
+ turn.interactionID = id
+ }
+ }
+ if id, ok := event["interaction_id"].(string); ok && id != "" {
+ turn.interactionID = id
+ }
+
+ case "step.start":
+ // encoding/json decodes JSON numbers as float64; convert the step
+ // index to int at this boundary so the maps are keyed by a plain int.
+ stepIndexF, _ := event["index"].(float64)
+ stepIndex := int(stepIndexF)
+ if step, ok := event["step"].(map[string]any); ok && step["type"] == "function_call" {
+ call := callsByStepIndex[stepIndex]
+ if call == nil {
+ call = &pendingCall{}
+ callsByStepIndex[stepIndex] = call
+ stepIndexOrder = append(stepIndexOrder, stepIndex)
+ }
+ if id, ok := step["id"].(string); ok && id != "" {
+ call.id = id
+ }
+ if name, ok := step["name"].(string); ok && name != "" {
+ call.name = name
+ }
+ if inlineArgs, ok := step["arguments"].(map[string]any); ok && len(inlineArgs) > 0 {
+ call.args = inlineArgs
+ }
+ }
+ case "step.delta":
+ stepIndexF, _ := event["index"].(float64)
+ stepIndex := int(stepIndexF)
+ if delta, ok := event["delta"].(map[string]any); ok {
+ switch delta["type"] {
+ case "arguments_delta":
+ if call := callsByStepIndex[stepIndex]; call != nil {
+ if argsSnapshot, ok := delta["arguments"].(string); ok && argsSnapshot != "" {
+ call.argsJSON = argsSnapshot // snapshot: replace, not append
+ }
+ }
+ case "text":
+ if textChunk, ok := delta["text"].(string); ok {
+ // Text deltas are mostly incremental, but the server
+ // periodically re-sends a full cumulative restatement of the
+ // text so far (e.g. when finalizing a turn before a tool
+ // call). Detect that case and replace rather than append, so
+ // the restated text is not duplicated.
+ if strings.HasPrefix(textChunk, turn.modelText) && turn.modelText != "" {
+ turn.modelText = textChunk
+ } else {
+ turn.modelText += textChunk
+ }
+ }
+ }
+ }
+ }
+ }
+ if err := scanner.Err(); err != nil {
+ return nil, err
+ }
+
+ // Collapse the per-step-index pending calls into unique tool calls, in
+ // first-seen order, deduped by id. A call missing a name or id is an
+ // incomplete streaming artifact and is skipped.
+ seenCallIDs := map[string]bool{}
+ for _, stepIndex := range stepIndexOrder {
+ call := callsByStepIndex[stepIndex]
+ if call.name == "" || call.id == "" || seenCallIDs[call.id] {
+ continue
+ }
+ seenCallIDs[call.id] = true
+ if call.args == nil && call.argsJSON != "" {
+ var parsedArgs map[string]any
+ if err := json.Unmarshal([]byte(call.argsJSON), &parsedArgs); err == nil {
+ call.args = parsedArgs
+ }
+ }
+ if call.args == nil {
+ call.args = map[string]any{}
+ }
+ turn.toolCalls = append(turn.toolCalls, capturedToolCall{
+ callID: call.id, name: call.name, arguments: call.args,
+ })
+ }
+ return turn, nil
+}
diff --git a/internal/harness/antigravityinteractions/antigravityinteractions_test.go b/internal/harness/antigravityinteractions/antigravityinteractions_test.go
new file mode 100644
index 00000000..fe4e17b7
--- /dev/null
+++ b/internal/harness/antigravityinteractions/antigravityinteractions_test.go
@@ -0,0 +1,302 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravityinteractions
+
+import (
+ "context"
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "path/filepath"
+ "strings"
+ "sync"
+ "testing"
+
+ "github.com/google/ax/internal/harness/harnesstest"
+ "golang.org/x/oauth2"
+)
+
+// fakeInteractions is a fake Interactions API: an http.RoundTripper that records
+// the decoded request body of every POST and replies with a canned SSE stream.
+// It lets the harness's real Start/Run/cursorStore code run end to end while the
+// network is faked, so we can assert exactly what previous_interaction_id the
+// harness sends on each turn.
+type fakeInteractions struct {
+ mu sync.Mutex
+ // requests holds the decoded body of each interaction request, in order.
+ requests []interactionRequest
+ // interactionIDs are returned (in order) as the completed interaction id for
+ // successive turns; the i-th request gets interactionIDs[i].
+ interactionIDs []string
+}
+
+func (f *fakeInteractions) RoundTrip(req *http.Request) (*http.Response, error) {
+ body, err := io.ReadAll(req.Body)
+ if err != nil {
+ return nil, err
+ }
+ var decoded interactionRequest
+ if err := json.Unmarshal(body, &decoded); err != nil {
+ return nil, fmt.Errorf("fake server: decoding request: %w", err)
+ }
+
+ f.mu.Lock()
+ idx := len(f.requests)
+ f.requests = append(f.requests, decoded)
+ id := fmt.Sprintf("INT-%d", idx+1)
+ if idx < len(f.interactionIDs) {
+ id = f.interactionIDs[idx]
+ }
+ f.mu.Unlock()
+
+ // A minimal completed turn: no tool calls, so Run finishes in one turn.
+ sse := "" +
+ "event: interaction.created\n" +
+ `data: {"interaction":{"id":"` + id + `","status":"in_progress"},"event_type":"interaction.created"}` + "\n\n" +
+ "event: interaction.completed\n" +
+ `data: {"interaction":{"id":"` + id + `","status":"completed"},"event_type":"interaction.completed"}` + "\n\n" +
+ "event: done\n" +
+ "data: [DONE]\n\n"
+
+ return &http.Response{
+ StatusCode: http.StatusOK,
+ Header: http.Header{"Content-Type": []string{"text/event-stream"}},
+ Body: io.NopCloser(strings.NewReader(sse)),
+ Request: req,
+ }, nil
+}
+
+func (f *fakeInteractions) recorded() []interactionRequest {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]interactionRequest(nil), f.requests...)
+}
+
+// newTestHarness builds a harness wired to the fake server, a static token (no
+// ADC), and the given state dir. It also sets the project env so the request URL
+// and X-Goog-User-Project header are well-formed.
+func newTestHarness(t *testing.T, fake *fakeInteractions, stateDir string) *AntigravityInteractionsHarness {
+ t.Helper()
+ t.Setenv(envCloudProject, "test-project")
+ h, err := newWithHTTPClient(AntigravityInteractionsConfig{
+ Agent: "test-agent",
+ StateDir: stateDir,
+ TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake-token"}),
+ }, &http.Client{Transport: fake})
+ if err != nil {
+ t.Fatalf("newWithHTTPClient: %v", err)
+ }
+ return h
+}
+
+// runOneTurn starts an Execution for conversationID, queues a single user
+// message, and runs it to completion.
+func runOneTurn(t *testing.T, h *AntigravityInteractionsHarness, conversationID, prompt string) {
+ t.Helper()
+ ctx := context.Background()
+ exec, err := h.Start(ctx, conversationID, nil)
+ if err != nil {
+ t.Fatalf("Start(%q): %v", conversationID, err)
+ }
+ if err := exec.Queue(ctx, harnesstest.UserText(prompt)); err != nil {
+ t.Fatalf("Queue: %v", err)
+ }
+ if err := exec.Run(ctx, &harnesstest.MockHandler{}); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if err := exec.Close(ctx); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+}
+
+// TestResumeAcrossRestart is the core CUJ: a first Execution starts a new
+// interaction chain (empty previous_interaction_id) and persists the returned
+// interaction id; then a brand-new harness over the SAME state dir (a simulated
+// restart / snapshot restore) loads that cursor and sends it as
+// previous_interaction_id on the next request.
+func TestResumeAcrossRestart(t *testing.T) {
+ fake := &fakeInteractions{interactionIDs: []string{"INT-1", "INT-2"}}
+ stateDir := t.TempDir()
+
+ // First Execution: starts the chain.
+ h1 := newTestHarness(t, fake, stateDir)
+ runOneTurn(t, h1, "conv-1", "hello")
+
+ // Simulated restart: a brand-new harness over the same state dir, so any
+ // resumed state must come from disk, not h1's memory.
+ h2 := newTestHarness(t, fake, stateDir)
+ runOneTurn(t, h2, "conv-1", "again")
+
+ reqs := fake.recorded()
+ if len(reqs) != 2 {
+ t.Fatalf("expected 2 requests, got %d", len(reqs))
+ }
+ if reqs[0].PreviousInteractionID != "" {
+ t.Errorf("turn 1: previous_interaction_id = %q, want empty (new chain)", reqs[0].PreviousInteractionID)
+ }
+ if got, want := reqs[1].PreviousInteractionID, "INT-1"; got != want {
+ t.Errorf("turn 2 (after restart): previous_interaction_id = %q, want %q (resumed from persisted cursor)", got, want)
+ }
+}
+
+// TestNewRequiresStateDir verifies that the constructor rejects an empty
+// StateDir: resume-cursor persistence is required.
+func TestNewRequiresStateDir(t *testing.T) {
+ t.Setenv(envCloudProject, "test-project")
+ _, err := New(AntigravityInteractionsConfig{
+ Agent: "test-agent",
+ StateDir: "", // missing
+ TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake-token"}),
+ })
+ if err == nil {
+ t.Fatal("New with empty StateDir: got nil error, want error")
+ }
+}
+
+// TestSameHarnessSecondTurnResumes checks that even without a "restart", a second
+// Execution on the same harness/conversation continues the chain via the cursor.
+func TestSameHarnessSecondTurnResumes(t *testing.T) {
+ fake := &fakeInteractions{interactionIDs: []string{"INT-1", "INT-2"}}
+ h := newTestHarness(t, fake, t.TempDir())
+
+ runOneTurn(t, h, "conv-1", "hello")
+ runOneTurn(t, h, "conv-1", "again")
+
+ reqs := fake.recorded()
+ if len(reqs) != 2 {
+ t.Fatalf("expected 2 requests, got %d", len(reqs))
+ }
+ if got, want := reqs[1].PreviousInteractionID, "INT-1"; got != want {
+ t.Errorf("turn 2: previous_interaction_id = %q, want %q", got, want)
+ }
+}
+
+// TestCursorStoreLoadSave is a focused unit test of the harness-local cursor
+// store round-trip.
+func TestCursorStoreLoadSave(t *testing.T) {
+ cs, err := newCursorStore(t.TempDir())
+ if err != nil {
+ t.Fatalf("newCursorStore: %v", err)
+ }
+
+ // Missing key: found is false, no error.
+ if _, found, err := cs.load("missing"); err != nil || found {
+ t.Fatalf("load(missing) = found=%v err=%v, want found=false err=nil", found, err)
+ }
+
+ // Round-trip.
+ if err := cs.save("conv-1", resumeCursor{PrevInteractionID: "INT-7"}); err != nil {
+ t.Fatalf("save: %v", err)
+ }
+ cur, found, err := cs.load("conv-1")
+ if err != nil || !found {
+ t.Fatalf("load(conv-1) = found=%v err=%v, want found=true err=nil", found, err)
+ }
+ if cur.PrevInteractionID != "INT-7" {
+ t.Errorf("loaded PrevInteractionID = %q, want %q", cur.PrevInteractionID, "INT-7")
+ }
+
+ // Last-write-wins overwrite.
+ if err := cs.save("conv-1", resumeCursor{PrevInteractionID: "INT-8"}); err != nil {
+ t.Fatalf("save (overwrite): %v", err)
+ }
+ cur, _, err = cs.load("conv-1")
+ if err != nil {
+ t.Fatalf("load after overwrite: %v", err)
+ }
+ if cur.PrevInteractionID != "INT-8" {
+ t.Errorf("after overwrite PrevInteractionID = %q, want %q", cur.PrevInteractionID, "INT-8")
+ }
+}
+
+// TestDefaultStateDir returns ~/.ax/antigravityinteractions/cursors under the
+// user's home directory.
+func TestDefaultStateDir(t *testing.T) {
+ home := t.TempDir()
+ t.Setenv("HOME", home)
+
+ got, err := DefaultStateDir()
+ if err != nil {
+ t.Fatalf("DefaultStateDir: %v", err)
+ }
+ if want := filepath.Join(home, ".ax", "antigravityinteractions", "cursors"); got != want {
+ t.Errorf("DefaultStateDir() = %q, want %q", got, want)
+ }
+}
+
+// TestParseStreamedTurnSurfacesErrorEvent verifies that a server-emitted SSE
+// "error" event (e.g. INVALID_ARGUMENT for a malformed client tool result) is
+// surfaced as a real error, instead of being silently dropped and returned as a
+// completed-but-empty turn. Dropping it made an empty directory listing look
+// like a blank "no response" turn and poisoned the resume cursor with the
+// failing interaction's id.
+func TestParseStreamedTurnSurfacesErrorEvent(t *testing.T) {
+ sse := "" +
+ "event: interaction.created\n" +
+ `data: {"interaction":{"id":"INT-1","status":"in_progress"},"event_type":"interaction.created"}` + "\n\n" +
+ "event: error\n" +
+ `data: {"event_type":"error","error":{"message":"field 'results' must be a list_value, got unset","status":"INVALID_ARGUMENT"}}` + "\n\n" +
+ "event: done\n" +
+ "data: [DONE]\n\n"
+
+ h := &AntigravityInteractionsHarness{}
+ turn, err := h.parseStreamedTurn(strings.NewReader(sse))
+ if err == nil {
+ t.Fatalf("parseStreamedTurn() = %+v, nil error; want an error for the SSE error event", turn)
+ }
+ if !strings.Contains(err.Error(), "INVALID_ARGUMENT") {
+ t.Errorf("error = %q, want it to include the server status INVALID_ARGUMENT", err)
+ }
+ if !strings.Contains(err.Error(), "must be a list_value") {
+ t.Errorf("error = %q, want it to include the server message", err)
+ }
+}
+
+func TestServerErrorMessage(t *testing.T) {
+ tests := []struct {
+ name string
+ event map[string]any
+ want string
+ }{
+ {
+ name: "message and status",
+ event: map[string]any{"error": map[string]any{"message": "boom", "status": "INVALID_ARGUMENT"}},
+ want: "boom (INVALID_ARGUMENT)",
+ },
+ {
+ name: "message only",
+ event: map[string]any{"error": map[string]any{"message": "boom"}},
+ want: "boom",
+ },
+ {
+ name: "status only",
+ event: map[string]any{"error": map[string]any{"status": "UNAVAILABLE"}},
+ want: "UNAVAILABLE",
+ },
+ {
+ name: "no error object",
+ event: map[string]any{"event_type": "error"},
+ want: "unknown server error",
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if got := serverErrorMessage(tt.event); got != tt.want {
+ t.Errorf("serverErrorMessage() = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
diff --git a/internal/harness/antigravityinteractions/antigravityinteractions_tools.go b/internal/harness/antigravityinteractions/antigravityinteractions_tools.go
new file mode 100644
index 00000000..ad4b979f
--- /dev/null
+++ b/internal/harness/antigravityinteractions/antigravityinteractions_tools.go
@@ -0,0 +1,521 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravityinteractions
+
+import (
+ "context"
+ "fmt"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+ "unicode/utf8"
+)
+
+// executeTool runs a tool call the agent yielded and returns the result value to
+// send back as a function_result step. The built-in environment tools enumerated
+// in the switch below (file reads, command execution, and the file-mutation
+// family) are executed internally against the local filesystem/shell; every
+// other tool is dispatched to the configured ThirdPartyExecutor, or, if none is
+// configured, returned as an error result. All execution is internal to the
+// harness -- no tool call is surfaced to the caller.
+//
+// Argument names match the agent's tool schema (PascalCase). Mutation tools
+// (move, delete_dir, create_file, edit_file, multi_edit_file, delete_file)
+// require no success payload -- on success they return an empty result; on
+// failure they return {"error": }, which marks the step as failed.
+func (h *AntigravityInteractionsHarness) executeTool(ctx context.Context, call capturedToolCall) any {
+ switch call.name {
+ case "view_file":
+ return execViewFile(call)
+ case "run_command":
+ return execRunCommand(ctx, call)
+ case "list_dir", "list_directory":
+ return execListDir(call)
+ case "move":
+ return execMove(call)
+ case "delete_dir":
+ return execDeleteDir(call)
+ case "create_file":
+ return execCreateFile(call)
+ case "edit_file":
+ return execEditFile(call)
+ case "multi_edit_file":
+ return execMultiEditFile(call)
+ case "delete_file":
+ return execDeleteFile(call)
+ default:
+ if h.cfg.ThirdPartyExecutor == nil {
+ return map[string]any{"error": fmt.Sprintf("no executor configured for third-party tool %q", call.name)}
+ }
+ return h.cfg.ThirdPartyExecutor.Execute(ctx, call.name, call.arguments)
+ }
+}
+
+// ---------------------------------------------------------------------------
+// Built-in environment tools: real local implementations.
+//
+// These run against the actual local filesystem/shell because they ARE the
+// client-side environment. Argument names follow the agent's tool schema
+// (view_file -> AbsolutePath, run_command -> CommandLine, list_dir ->
+// DirectoryPath), and result field names follow what the server maps back into
+// the step's own output (content, Output/ExitCode, results).
+// ---------------------------------------------------------------------------
+
+// view_file result caps. Mirrors the Antigravity view_file contract:
+// StartLine/EndLine are 1-indexed inclusive, at most viewFileMaxLines lines are
+// returned per view, and content is byte-capped with a ContentOffset
+// continuation so a large file can't blob a multi-hundred-KB tool result that
+// stalls the turn / blows past API context limits.
+const (
+ viewFileMaxLines = 2000
+ viewFileMaxBytes = 256 * 1024 // 256 KiB
+)
+
+func execViewFile(call capturedToolCall) any {
+ path := stringArg(call.arguments, "AbsolutePath")
+ if path == "" {
+ return map[string]any{"error": "view_file: missing required argument 'AbsolutePath'"}
+ }
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return map[string]any{"error": fmt.Sprintf("view_file: %v", err)}
+ }
+
+ // Resolve the 1-indexed inclusive line window using slice notation (see
+ // resolveLineWindow), then apply the byte cap, honoring ContentOffset as the
+ // read position within the windowed content. The caps bound the result size so
+ // a large file can't blob.
+ start, startSet := intArgOK(call.arguments, "StartLine")
+ end, endSet := intArgOK(call.arguments, "EndLine")
+ offset := intArg(call.arguments, "ContentOffset")
+
+ whole := string(data)
+ windowed, lo, hi, totalLines := resolveLineWindow(whole, start, startSet, end, endSet)
+ content := applyByteWindow(windowed, offset)
+
+ // Return the metadata the server needs to distinguish a complete read from a
+ // paginated/byte-truncated one:
+ // - start_line/end_line: 0-indexed inclusive served line range (the result
+ // is 0-indexed; the tool-call StartLine/EndLine args are 1-indexed).
+ // - content_offset: byte offset within the line-range content this slice
+ // starts at (for pagination continuation).
+ // - line_range_bytes: total bytes of the selected line range *before* byte
+ // truncation; the server compares content_offset+len(content) against
+ // this to detect byte truncation.
+ // - num_lines/num_bytes: whole-file totals.
+ result := map[string]any{
+ "content": content,
+ "content_offset": offset,
+ "line_range_bytes": len(windowed),
+ "num_lines": totalLines,
+ "num_bytes": len(whole),
+ }
+ // Only report a concrete served range when there was content to serve;
+ // otherwise leave the (0,0) default.
+ if lo > 0 && hi >= lo {
+ result["start_line"] = lo - 1 // 1-indexed -> 0-indexed
+ result["end_line"] = hi - 1
+ }
+ return result
+}
+
+// resolveLineWindow returns the requested line window of content, following the
+// Antigravity view_file slice notation over 1-indexed inclusive [start, end]:
+//
+// - neither set: the first viewFileMaxLines lines (or the whole file if smaller)
+// - start only: viewFileMaxLines lines starting at start (forward window)
+// - end only: viewFileMaxLines lines ending at end (backward window)
+// - both set: lines [start, end], capped to viewFileMaxLines from start
+//
+// It returns the joined window text, the served 1-indexed inclusive [lo, hi]
+// range (0, 0 when nothing is served), and the whole-file total line count.
+func resolveLineWindow(content string, start int, startSet bool, end int, endSet bool) (windowed string, lo, hi, total int) {
+ lines := strings.Split(content, "\n")
+ // strings.Split on a trailing newline yields a final empty element; drop it so
+ // line counts match the file's logical lines.
+ if n := len(lines); n > 0 && lines[n-1] == "" {
+ lines = lines[:n-1]
+ }
+ total = len(lines)
+ if total == 0 {
+ return "", 0, 0, 0
+ }
+
+ // Compute a 1-indexed inclusive [lo, hi] window per slice notation.
+ switch {
+ case !startSet && !endSet:
+ lo, hi = 1, viewFileMaxLines
+ case startSet && !endSet:
+ lo = start
+ hi = start + viewFileMaxLines - 1
+ case !startSet && endSet:
+ hi = end
+ lo = end - viewFileMaxLines + 1
+ default: // both set
+ lo, hi = start, end
+ if hi-lo+1 > viewFileMaxLines {
+ hi = lo + viewFileMaxLines - 1
+ }
+ }
+
+ // Clamp to the file bounds.
+ if lo < 1 {
+ lo = 1
+ }
+ if hi > total {
+ hi = total
+ }
+ if lo > total || hi < lo {
+ // Window is entirely past EOF (or inverted after clamping): nothing to show.
+ return "", 0, 0, total
+ }
+
+ return strings.Join(lines[lo-1:hi], "\n"), lo, hi, total
+}
+
+// applyByteWindow returns the slice of content starting at offset (the agent's
+// ContentOffset), capped to viewFileMaxBytes so a large window can't blob. When
+// it truncates, the cut is backed off to the last complete UTF-8 rune so the
+// returned string is always valid UTF-8 (never a split multi-byte character).
+//
+// Callers detect truncation and the resume point from the returned slice: the
+// window was truncated when offset+len(out) < len(content), and the agent
+// resumes by re-reading with ContentOffset == offset+len(out).
+func applyByteWindow(content string, offset int) string {
+ if offset < 0 {
+ offset = 0
+ }
+ if offset > len(content) {
+ offset = len(content)
+ }
+ remaining := content[offset:]
+ if len(remaining) <= viewFileMaxBytes {
+ return remaining
+ }
+
+ // Cap at viewFileMaxBytes, then back off to a valid UTF-8 boundary so we
+ // don't split a multi-byte rune across the cut.
+ cut := viewFileMaxBytes
+ for cut > 0 && !utf8.RuneStart(remaining[cut]) {
+ cut--
+ }
+ // remaining[cut] is now the start of a rune (or cut == 0). Guard the
+ // pathological case of a single rune larger than the cap: emit at least that
+ // rune so we always make forward progress.
+ if cut == 0 {
+ _, size := utf8.DecodeRuneInString(remaining)
+ cut = size
+ }
+ return remaining[:cut]
+}
+
+// runCommandTimeout bounds how long a single run_command may take, so a runaway
+// command (e.g. `find /`, or `ping` without a count) cannot wedge the harness.
+const runCommandTimeout = 60 * time.Second
+
+func execRunCommand(ctx context.Context, call capturedToolCall) any {
+ cmdLine := stringArg(call.arguments, "CommandLine")
+ if cmdLine == "" {
+ return map[string]any{"error": "run_command: missing required argument 'CommandLine'"}
+ }
+
+ // Bound the command's runtime so it cannot hang the harness.
+ runCtx, cancel := context.WithTimeout(ctx, runCommandTimeout)
+ defer cancel()
+
+ cmd := exec.CommandContext(runCtx, "/bin/sh", "-c", cmdLine)
+ if cwd := stringArg(call.arguments, "Cwd"); cwd != "" {
+ cmd.Dir = cwd
+ }
+
+ out, err := cmd.CombinedOutput()
+
+ // Timed out: report a clear, non-zero result rather than blocking.
+ if runCtx.Err() == context.DeadlineExceeded {
+ return map[string]any{
+ "Output": fmt.Sprintf("%scommand timed out after %s", out, runCommandTimeout),
+ "ExitCode": 124, // conventional timeout exit code
+ }
+ }
+
+ exitCode := 0
+ if err != nil {
+ if ee, ok := err.(*exec.ExitError); ok {
+ exitCode = ee.ExitCode()
+ } else {
+ // Failed to start: surface as a non-zero exit + output.
+ return map[string]any{"Output": fmt.Sprintf("%s%v", out, err), "ExitCode": 1}
+ }
+ }
+ return map[string]any{"Output": string(out), "ExitCode": exitCode}
+}
+
+func execListDir(call capturedToolCall) any {
+ dir := stringArg(call.arguments, "DirectoryPath")
+ if dir == "" {
+ return map[string]any{"error": "list_dir: missing required argument 'DirectoryPath'"}
+ }
+ entries, err := os.ReadDir(dir)
+ if err != nil {
+ return map[string]any{"error": fmt.Sprintf("list_dir: %v", err)}
+ }
+ results := make([]map[string]any, 0, len(entries))
+ for _, e := range entries {
+ var sizeBytes int64
+ if info, err := e.Info(); err == nil {
+ sizeBytes = info.Size()
+ }
+ results = append(results, map[string]any{
+ "name": e.Name(),
+ "is_dir": e.IsDir(),
+ "size_bytes": sizeBytes,
+ })
+ }
+ sort.Slice(results, func(i, j int) bool {
+ return results[i]["name"].(string) < results[j]["name"].(string)
+ })
+ return map[string]any{"results": results}
+}
+
+// ---------------------------------------------------------------------------
+// Built-in file mutation tools.
+//
+// These mutate the real local filesystem. By contract, a successful mutation
+// needs no result payload (the server renders success from the call's input
+// args); a failure is reported as {"error": }, which marks the step
+// ERROR. Argument names follow the agent's tool schema (PascalCase).
+// ---------------------------------------------------------------------------
+
+// mutationOK is the empty success result for a mutation tool.
+func mutationOK() any { return map[string]any{} }
+
+func mutationErr(tool string, err error) any {
+ return map[string]any{"error": fmt.Sprintf("%s: %v", tool, err)}
+}
+
+// execMove implements the "move" tool: SourcePath -> DestinationPath.
+func execMove(call capturedToolCall) any {
+ src := stringArg(call.arguments, "SourcePath")
+ dst := stringArg(call.arguments, "DestinationPath")
+ if src == "" || dst == "" {
+ return mutationErr("move", fmt.Errorf("missing required argument 'SourcePath' and/or 'DestinationPath'"))
+ }
+ if err := os.Rename(src, dst); err != nil {
+ return mutationErr("move", err)
+ }
+ return mutationOK()
+}
+
+// execDeleteDir implements the "delete_dir" tool: removes DirectoryPath. Force
+// allows recursive removal; otherwise only an empty directory is removed.
+func execDeleteDir(call capturedToolCall) any {
+ dir := stringArg(call.arguments, "DirectoryPath")
+ if dir == "" {
+ return mutationErr("delete_dir", fmt.Errorf("missing required argument 'DirectoryPath'"))
+ }
+ var err error
+ if boolArg(call.arguments, "Force") {
+ err = os.RemoveAll(dir)
+ } else {
+ err = os.Remove(dir) // fails if not empty / not a dir
+ }
+ if err != nil {
+ return mutationErr("delete_dir", err)
+ }
+ return mutationOK()
+}
+
+// execCreateFile implements the "create_file" tool: writes Content to TargetFile.
+// Overwrite must be true to replace an existing file.
+func execCreateFile(call capturedToolCall) any {
+ path := stringArg(call.arguments, "TargetFile")
+ if path == "" {
+ return mutationErr("create_file", fmt.Errorf("missing required argument 'TargetFile'"))
+ }
+ if !boolArg(call.arguments, "Overwrite") {
+ if _, err := os.Stat(path); err == nil {
+ return mutationErr("create_file", fmt.Errorf("file %q already exists and Overwrite is false", path))
+ }
+ }
+ if dir := filepath.Dir(path); dir != "" {
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return mutationErr("create_file", err)
+ }
+ }
+ if err := os.WriteFile(path, []byte(stringArg(call.arguments, "Content")), 0o644); err != nil {
+ return mutationErr("create_file", err)
+ }
+ return mutationOK()
+}
+
+// execEditFile implements the "edit_file" tool: replaces the first occurrence of
+// TargetContent with ReplacementContent in TargetFile.
+func execEditFile(call capturedToolCall) any {
+ path := stringArg(call.arguments, "TargetFile")
+ target := stringArg(call.arguments, "TargetContent")
+ repl := stringArg(call.arguments, "ReplacementContent")
+ if path == "" || target == "" {
+ return mutationErr("edit_file", fmt.Errorf("missing required argument 'TargetFile' and/or 'TargetContent'"))
+ }
+ return applyReplacements(path, "edit_file", []replacement{{target: target, repl: repl}})
+}
+
+// execMultiEditFile implements the "multi_edit_file" tool: applies a list of
+// search/replace ReplacementChunks to TargetFile, in order.
+func execMultiEditFile(call capturedToolCall) any {
+ path := stringArg(call.arguments, "TargetFile")
+ if path == "" {
+ return mutationErr("multi_edit_file", fmt.Errorf("missing required argument 'TargetFile'"))
+ }
+ chunks, ok := call.arguments["ReplacementChunks"].([]any)
+ if !ok {
+ return mutationErr("multi_edit_file", fmt.Errorf("missing or invalid 'ReplacementChunks' array"))
+ }
+ reps := make([]replacement, 0, len(chunks))
+ for _, c := range chunks {
+ m, ok := c.(map[string]any)
+ if !ok {
+ return mutationErr("multi_edit_file", fmt.Errorf("each ReplacementChunk must be an object"))
+ }
+ reps = append(reps, replacement{
+ target: stringArg(m, "TargetContent"),
+ repl: stringArg(m, "ReplacementContent"),
+ })
+ }
+ return applyReplacements(path, "multi_edit_file", reps)
+}
+
+// execDeleteFile implements the "delete_file" tool: removes TargetFile.
+func execDeleteFile(call capturedToolCall) any {
+ path := stringArg(call.arguments, "TargetFile")
+ if path == "" {
+ return mutationErr("delete_file", fmt.Errorf("missing required argument 'TargetFile'"))
+ }
+ if err := os.Remove(path); err != nil {
+ return mutationErr("delete_file", err)
+ }
+ return mutationOK()
+}
+
+// replacement is a single search/replace edit.
+type replacement struct {
+ target string
+ repl string
+}
+
+// applyReplacements reads path, applies each search/replace (first occurrence,
+// in order), and writes the result back. Each target must be found.
+func applyReplacements(path, tool string, reps []replacement) any {
+ data, err := os.ReadFile(path)
+ if err != nil {
+ return mutationErr(tool, err)
+ }
+ content := string(data)
+ for i, r := range reps {
+ if r.target == "" {
+ return mutationErr(tool, fmt.Errorf("chunk %d has empty TargetContent", i))
+ }
+ idx := strings.Index(content, r.target)
+ if idx < 0 {
+ return mutationErr(tool, fmt.Errorf("chunk %d: TargetContent not found in %q", i, path))
+ }
+ content = content[:idx] + r.repl + content[idx+len(r.target):]
+ }
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ return mutationErr(tool, err)
+ }
+ return mutationOK()
+}
+
+// ---------------------------------------------------------------------------
+// Third-party / MCP function tools.
+//
+// Third-party tools are executed INTERNALLY by the harness, via a
+// ThirdPartyExecutor. The executor both declares the tools (advertised to the
+// agent in the request's "tools" field) and executes calls to them.
+//
+// The executor is the seam for the controller to inject the caller's own tool
+// implementations (and their declarations). If no executor is configured, the
+// harness advertises no third-party tools and reports an error result for any
+// non-built-in call the agent attempts.
+// ---------------------------------------------------------------------------
+
+// ThirdPartyExecutor declares and executes third-party (non-built-in) function
+// tools. The harness owns when it is called; an implementation just needs to
+// describe its tools and produce a result for a given call.
+type ThirdPartyExecutor interface {
+ // Declarations returns the tool declarations advertised to the agent.
+ Declarations() []FunctionTool
+ // Execute runs the named tool with the given arguments and returns the result
+ // value to send back to the agent (wrapped into the function_result step).
+ Execute(ctx context.Context, name string, args map[string]any) any
+}
+
+// ---------------------------------------------------------------------------
+// Argument helpers.
+// ---------------------------------------------------------------------------
+
+func stringArg(args map[string]any, name string) string {
+ if args == nil {
+ return ""
+ }
+ if v, ok := args[name].(string); ok {
+ return v
+ }
+ return ""
+}
+
+func boolArg(args map[string]any, name string) bool {
+ if args == nil {
+ return false
+ }
+ if v, ok := args[name].(bool); ok {
+ return v
+ }
+ return false
+}
+
+// intArg reads an integer argument. JSON numbers decode to float64, but tolerate
+// int and numeric strings too. Returns 0 when absent or unparseable.
+func intArg(args map[string]any, name string) int {
+ n, _ := intArgOK(args, name)
+ return n
+}
+
+// intArgOK is like intArg but also reports whether the argument was present and
+// parseable. Callers use the ok flag to distinguish "unset" from an explicit 0
+// (needed for view_file slice notation, where start-only vs end-only differ).
+func intArgOK(args map[string]any, name string) (int, bool) {
+ if args == nil {
+ return 0, false
+ }
+ switch v := args[name].(type) {
+ case float64:
+ return int(v), true
+ case int:
+ return v, true
+ case int64:
+ return int(v), true
+ case string:
+ if n, err := strconv.Atoi(strings.TrimSpace(v)); err == nil {
+ return n, true
+ }
+ }
+ return 0, false
+}
diff --git a/internal/harness/antigravityinteractions/antigravityinteractions_tools_test.go b/internal/harness/antigravityinteractions/antigravityinteractions_tools_test.go
new file mode 100644
index 00000000..e38f3a36
--- /dev/null
+++ b/internal/harness/antigravityinteractions/antigravityinteractions_tools_test.go
@@ -0,0 +1,343 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravityinteractions
+
+import (
+ "fmt"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "unicode/utf8"
+)
+
+// numberedLines returns "1\n2\n...\nN\n".
+func numberedLines(n int) string {
+ var b strings.Builder
+ for i := 1; i <= n; i++ {
+ fmt.Fprintf(&b, "%d\n", i)
+ }
+ return b.String()
+}
+
+func TestResolveLineWindow(t *testing.T) {
+ content := "l1\nl2\nl3\nl4\nl5\n"
+ tests := []struct {
+ name string
+ start int
+ startSet bool
+ end int
+ endSet bool
+ want string
+ }{
+ {"neither set (small file)", 0, false, 0, false, "l1\nl2\nl3\nl4\nl5"},
+ {"both set middle", 2, true, 4, true, "l2\nl3\nl4"},
+ {"both set whole", 1, true, 5, true, "l1\nl2\nl3\nl4\nl5"},
+ {"start only", 3, true, 0, false, "l3\nl4\nl5"},
+ {"end only", 0, false, 3, true, "l1\nl2\nl3"},
+ {"end past EOF clamps", 3, true, 999, true, "l3\nl4\nl5"},
+ {"start past EOF empty", 99, true, 0, false, ""},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got, _, _, _ := resolveLineWindow(content, tt.start, tt.startSet, tt.end, tt.endSet)
+ if got != tt.want {
+ t.Errorf("content = %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
+func TestResolveLineWindow_StartOnlyForwardWindow(t *testing.T) {
+ // start only => viewFileMaxLines lines starting at start (forward window).
+ content := numberedLines(viewFileMaxLines + 500)
+ got, _, _, _ := resolveLineWindow(content, 10, true, 0, false)
+ lines := strings.Split(got, "\n")
+ if len(lines) != viewFileMaxLines {
+ t.Fatalf("got %d lines, want forward window of %d", len(lines), viewFileMaxLines)
+ }
+ if lines[0] != "10" {
+ t.Errorf("first line = %q, want %q (window starts at start)", lines[0], "10")
+ }
+}
+
+func TestResolveLineWindow_EndOnlyBackwardWindow(t *testing.T) {
+ // end only => viewFileMaxLines lines ending at end (backward window).
+ total := viewFileMaxLines + 500
+ content := numberedLines(total)
+ end := total // last line
+ got, _, _, _ := resolveLineWindow(content, 0, false, end, true)
+ lines := strings.Split(got, "\n")
+ if len(lines) != viewFileMaxLines {
+ t.Fatalf("got %d lines, want backward window of %d", len(lines), viewFileMaxLines)
+ }
+ if lines[len(lines)-1] != fmt.Sprintf("%d", end) {
+ t.Errorf("last line = %q, want %q (window ends at end)", lines[len(lines)-1], fmt.Sprintf("%d", end))
+ }
+}
+
+func TestResolveLineWindow_NeitherSetCapsToMaxLines(t *testing.T) {
+ content := numberedLines(viewFileMaxLines + 100)
+ got, _, _, _ := resolveLineWindow(content, 0, false, 0, false)
+ if n := len(strings.Split(got, "\n")); n != viewFileMaxLines {
+ t.Errorf("got %d lines, want cap %d", n, viewFileMaxLines)
+ }
+}
+
+func TestApplyByteWindow(t *testing.T) {
+ // truncated reports whether applyByteWindow(content, offset)==out capped the
+ // window, i.e. there is still content past the returned slice. Callers derive
+ // this the same way from the wire fields (offset+len(content) <
+ // line_range_bytes).
+ truncated := func(content string, offset, gotLen int) bool {
+ return offset+gotLen < len(content)
+ }
+
+ t.Run("under cap: not truncated", func(t *testing.T) {
+ got := applyByteWindow("hello", 0)
+ if got != "hello" || truncated("hello", 0, len(got)) {
+ t.Errorf("got %q (truncated=%v), want hello (false)", got, truncated("hello", 0, len(got)))
+ }
+ })
+ t.Run("over cap truncates to viewFileMaxBytes + resumes at next", func(t *testing.T) {
+ big := strings.Repeat("x", viewFileMaxBytes+100)
+ got := applyByteWindow(big, 0)
+ if len(got) != viewFileMaxBytes || !truncated(big, 0, len(got)) {
+ t.Errorf("got len %d (truncated=%v), want %d (true)", len(got), truncated(big, 0, len(got)), viewFileMaxBytes)
+ }
+ // Resume offset is offset+len(got); the remainder is the trailing 100 bytes.
+ if next := len(got); next != viewFileMaxBytes {
+ t.Errorf("resume offset = %d, want %d", next, viewFileMaxBytes)
+ }
+ })
+ t.Run("resume via offset returns remainder", func(t *testing.T) {
+ big := strings.Repeat("x", viewFileMaxBytes+100)
+ got := applyByteWindow(big, viewFileMaxBytes)
+ if len(got) != 100 || truncated(big, viewFileMaxBytes, len(got)) {
+ t.Errorf("got len %d (truncated=%v), want 100 (false)", len(got), truncated(big, viewFileMaxBytes, len(got)))
+ }
+ })
+ t.Run("offset past end is empty", func(t *testing.T) {
+ got := applyByteWindow("abc", 999)
+ if got != "" {
+ t.Errorf("got %q, want empty", got)
+ }
+ })
+ t.Run("caps at valid UTF-8 boundary (no split rune)", func(t *testing.T) {
+ // Fill just under the cap with ASCII, then place a 3-byte rune ("€" is
+ // U+20AC, 3 bytes) straddling the cap so a naive byte cut would split it.
+ const r = "€" // 3 bytes: E2 82 AC
+ pad := viewFileMaxBytes - 1 // cap lands 1 byte into the rune
+ content := strings.Repeat("a", pad) + r + "tail"
+ got := applyByteWindow(content, 0)
+ if !truncated(content, 0, len(got)) {
+ t.Fatal("expected truncation")
+ }
+ if !utf8.ValidString(got) {
+ t.Errorf("returned content is not valid UTF-8 (split rune): last bytes %x", got[len(got)-3:])
+ }
+ // The rune must not be partially included: length backs off to pad.
+ if len(got) != pad {
+ t.Errorf("got len %d, want %d (backed off before the straddling rune)", len(got), pad)
+ }
+ // Resuming from offset+len(got) yields the rune intact at the front.
+ rest := applyByteWindow(content, len(got))
+ if !strings.HasPrefix(rest, r) {
+ t.Errorf("resume did not start at the rune boundary: %.6q", rest)
+ }
+ })
+}
+
+func TestExecViewFile_HonorsRange(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "f.txt")
+ if err := os.WriteFile(path, []byte("a\nb\nc\nd\ne\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ res := execViewFile(capturedToolCall{arguments: map[string]any{
+ "AbsolutePath": path,
+ "StartLine": float64(2), // JSON numbers arrive as float64
+ "EndLine": float64(3),
+ }})
+ m := res.(map[string]any)
+ if m["content"] != "b\nc" {
+ t.Errorf("content = %q, want %q", m["content"], "b\nc")
+ }
+}
+
+func TestExecViewFile_LargeFileIsCapped(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "big.csv")
+ var b strings.Builder
+ for i := 0; i < 5000; i++ {
+ fmt.Fprintf(&b, "row-%d-with-some-padding\n", i)
+ }
+ if err := os.WriteFile(path, []byte(b.String()), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // No range requested (the regression: previously returned the whole file).
+ res := execViewFile(capturedToolCall{arguments: map[string]any{"AbsolutePath": path}})
+ content := res.(map[string]any)["content"].(string)
+ if len(content) > viewFileMaxBytes {
+ t.Errorf("content = %d bytes, exceeds cap %d", len(content), viewFileMaxBytes)
+ }
+ if lines := len(strings.Split(content, "\n")); lines > viewFileMaxLines {
+ t.Errorf("content = %d lines, exceeds cap %d", lines, viewFileMaxLines)
+ }
+}
+
+func TestExecViewFile_PaginationMetadataAndResume(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "wide.txt")
+ totalBytes := viewFileMaxBytes + 50
+ // One long single line exceeding the byte cap.
+ if err := os.WriteFile(path, []byte(strings.Repeat("z", totalBytes)), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ // First read: capped at viewFileMaxBytes; reports pagination metadata so the
+ // converter can detect byte truncation (content_offset+len(content) <
+ // line_range_bytes).
+ first := execViewFile(capturedToolCall{arguments: map[string]any{"AbsolutePath": path}}).(map[string]any)
+ if got := first["content"].(string); len(got) != viewFileMaxBytes {
+ t.Fatalf("first read = %d bytes, want cap %d", len(got), viewFileMaxBytes)
+ }
+ if first["content_offset"] != 0 {
+ t.Errorf("content_offset = %v, want 0", first["content_offset"])
+ }
+ if first["line_range_bytes"] != totalBytes {
+ t.Errorf("line_range_bytes = %v, want %d (pre-truncation total)", first["line_range_bytes"], totalBytes)
+ }
+ // Byte-truncation detectable: offset + len(content) < line_range_bytes.
+ if got := first["content_offset"].(int) + len(first["content"].(string)); got >= first["line_range_bytes"].(int) {
+ t.Errorf("offset+len = %d, want < line_range_bytes %d (should look truncated)", got, first["line_range_bytes"])
+ }
+
+ // Resume from where the first slice ended: returns the remaining 50 bytes.
+ resumeOffset := len(first["content"].(string)) // 0 + viewFileMaxBytes
+ second := execViewFile(capturedToolCall{arguments: map[string]any{
+ "AbsolutePath": path,
+ "ContentOffset": float64(resumeOffset),
+ }}).(map[string]any)
+ if got := second["content"].(string); len(got) != 50 {
+ t.Errorf("resume read = %d bytes, want 50", len(got))
+ }
+ // Now complete: offset + len == line_range_bytes.
+ if got := second["content_offset"].(int) + len(second["content"].(string)); got != second["line_range_bytes"].(int) {
+ t.Errorf("resume offset+len = %d, want == line_range_bytes %d (complete)", got, second["line_range_bytes"])
+ }
+}
+
+func TestExecViewFile_MultiByteRuneStraddlesByteCap(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "wide_utf8.txt")
+ // One long single line: ASCII padding so the byte cap lands 1 byte into a
+ // 3-byte rune ("€" is U+20AC, E2 82 AC), followed by more content so the read
+ // is byte-truncated and must continue.
+ const r = "€"
+ pad := viewFileMaxBytes - 1
+ content := strings.Repeat("a", pad) + r + strings.Repeat("b", 40)
+ if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ // First page: capped, backed off before the straddling rune -> valid UTF-8.
+ first := execViewFile(capturedToolCall{arguments: map[string]any{"AbsolutePath": path}}).(map[string]any)
+ firstContent := first["content"].(string)
+ if !utf8.ValidString(firstContent) {
+ t.Errorf("first page is not valid UTF-8 (split rune): last bytes %x", firstContent[len(firstContent)-3:])
+ }
+ if len(firstContent) != pad {
+ t.Errorf("first page = %d bytes, want %d (backed off before the straddling rune)", len(firstContent), pad)
+ }
+ // Byte-truncation detectable: content_offset+len(content) < line_range_bytes.
+ if got := first["content_offset"].(int) + len(firstContent); got >= first["line_range_bytes"].(int) {
+ t.Errorf("offset+len = %d, want < line_range_bytes %d (should look truncated)", got, first["line_range_bytes"])
+ }
+
+ // Second page: resume from where the first ended. The rune must appear intact
+ // at the front and the page must be valid UTF-8.
+ resumeOffset := first["content_offset"].(int) + len(firstContent)
+ second := execViewFile(capturedToolCall{arguments: map[string]any{
+ "AbsolutePath": path,
+ "ContentOffset": float64(resumeOffset),
+ }}).(map[string]any)
+ secondContent := second["content"].(string)
+ if !utf8.ValidString(secondContent) {
+ t.Errorf("resume page is not valid UTF-8: first bytes %x", secondContent[:3])
+ }
+ if !strings.HasPrefix(secondContent, r) {
+ t.Errorf("resume page did not start at the rune boundary: %.6q", secondContent)
+ }
+ // Now complete: offset + len == line_range_bytes.
+ if got := second["content_offset"].(int) + len(secondContent); got != second["line_range_bytes"].(int) {
+ t.Errorf("resume offset+len = %d, want == line_range_bytes %d (complete)", got, second["line_range_bytes"])
+ }
+}
+
+func TestExecViewFile_CompleteReadMetadata(t *testing.T) {
+ dir := t.TempDir()
+ path := filepath.Join(dir, "small.txt")
+ if err := os.WriteFile(path, []byte("a\nb\nc\n"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ res := execViewFile(capturedToolCall{arguments: map[string]any{"AbsolutePath": path}}).(map[string]any)
+ // 3 lines, served whole: start_line=0, end_line=2 (0-indexed inclusive).
+ if res["start_line"] != 0 || res["end_line"] != 2 {
+ t.Errorf("served range = (%v,%v), want (0,2)", res["start_line"], res["end_line"])
+ }
+ if res["num_lines"] != 3 {
+ t.Errorf("num_lines = %v, want 3", res["num_lines"])
+ }
+ // Complete (not byte-truncated): content_offset+len(content) == line_range_bytes.
+ if got := res["content_offset"].(int) + len(res["content"].(string)); got != res["line_range_bytes"].(int) {
+ t.Errorf("offset+len = %d, want == line_range_bytes %d (complete read)", got, res["line_range_bytes"])
+ }
+}
+
+func TestExecViewFile_MissingPath(t *testing.T) {
+ res := execViewFile(capturedToolCall{arguments: map[string]any{}})
+ m := res.(map[string]any)
+ if _, ok := m["error"]; !ok {
+ t.Errorf("expected error for missing AbsolutePath, got %+v", m)
+ }
+}
+
+func TestIntArgOK(t *testing.T) {
+ args := map[string]any{
+ "f": float64(7),
+ "i": 9,
+ "s": "12",
+ "z": float64(0),
+ "bad": "nope",
+ }
+ cases := []struct {
+ name string
+ wantN int
+ wantOK bool
+ }{
+ {"f", 7, true},
+ {"i", 9, true},
+ {"s", 12, true},
+ {"z", 0, true}, // explicit 0 is present
+ {"bad", 0, false},
+ {"absent", 0, false},
+ }
+ for _, c := range cases {
+ n, ok := intArgOK(args, c.name)
+ if n != c.wantN || ok != c.wantOK {
+ t.Errorf("intArgOK(%q) = (%d,%v), want (%d,%v)", c.name, n, ok, c.wantN, c.wantOK)
+ }
+ }
+}
diff --git a/internal/harness/antigravityinteractions/cursorstore.go b/internal/harness/antigravityinteractions/cursorstore.go
new file mode 100644
index 00000000..9ebfc99a
--- /dev/null
+++ b/internal/harness/antigravityinteractions/cursorstore.go
@@ -0,0 +1,119 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravityinteractions
+
+import (
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "os"
+ "path/filepath"
+)
+
+// resumeCursor is the small per-conversation state persisted so a conversation
+// can resume across restarts/replicas. It records the tail of the server-side
+// interaction chain (PrevInteractionID).
+//
+// It is a struct (rather than a bare string) so it can grow to hold richer
+// resume state later, e.g. partial function-call results for mid-tool-loop
+// recovery, without changing the on-disk shape's identity.
+type resumeCursor struct {
+ PrevInteractionID string `json:"prev_interaction_id"`
+}
+
+// cursorStore is a minimal filesystem-backed store for resume cursors, local to
+// the Antigravity Interactions harness. Each conversation maps to one file whose
+// contents are the JSON-encoded cursor.
+//
+// It assumes a single writer per conversation (the controller guarantees at most
+// one Execution per conversation), so writes are last-write-wins with no
+// compare-and-swap. Writes are atomic (temp file + rename) so a reader never
+// observes a torn value.
+type cursorStore struct {
+ dir string
+}
+
+// newCursorStore creates a cursorStore rooted at dir, creating it if needed.
+func newCursorStore(dir string) (*cursorStore, error) {
+ if dir == "" {
+ return nil, errors.New("cursorStore dir must not be empty")
+ }
+ if err := os.MkdirAll(dir, 0o755); err != nil {
+ return nil, fmt.Errorf("creating cursor dir %q: %w", dir, err)
+ }
+ return &cursorStore{dir: dir}, nil
+}
+
+// path maps a conversation id to its cursor file. The id is hashed so arbitrary
+// id strings map to a safe, fixed-length filename.
+func (s *cursorStore) path(conversationID string) string {
+ sum := sha256.Sum256([]byte(conversationID))
+ return filepath.Join(s.dir, hex.EncodeToString(sum[:])+".json")
+}
+
+// load returns the stored cursor for conversationID. found is false if no cursor
+// has been persisted yet; a non-nil error means the lookup itself failed (which
+// callers must not treat as "no cursor").
+func (s *cursorStore) load(conversationID string) (cur resumeCursor, found bool, err error) {
+ blob, err := os.ReadFile(s.path(conversationID))
+ if errors.Is(err, os.ErrNotExist) {
+ return resumeCursor{}, false, nil
+ }
+ if err != nil {
+ return resumeCursor{}, false, fmt.Errorf("reading cursor: %w", err)
+ }
+ if err := json.Unmarshal(blob, &cur); err != nil {
+ return resumeCursor{}, false, fmt.Errorf("decoding cursor: %w", err)
+ }
+ return cur, true, nil
+}
+
+// save durably writes the cursor for conversationID (last-write-wins).
+func (s *cursorStore) save(conversationID string, cur resumeCursor) error {
+ blob, err := json.Marshal(cur)
+ if err != nil {
+ return fmt.Errorf("encoding cursor: %w", err)
+ }
+ return s.atomicWrite(s.path(conversationID), blob)
+}
+
+// atomicWrite writes value to a temp file, fsyncs it, and renames it into place
+// so a reader never observes a partial write.
+func (s *cursorStore) atomicWrite(path string, value []byte) error {
+ tmp, err := os.CreateTemp(s.dir, ".tmp-*")
+ if err != nil {
+ return fmt.Errorf("creating temp file: %w", err)
+ }
+ tmpName := tmp.Name()
+ defer os.Remove(tmpName) // no-op if the rename succeeded
+
+ if _, err := tmp.Write(value); err != nil {
+ tmp.Close()
+ return fmt.Errorf("writing temp file: %w", err)
+ }
+ if err := tmp.Sync(); err != nil {
+ tmp.Close()
+ return fmt.Errorf("syncing temp file: %w", err)
+ }
+ if err := tmp.Close(); err != nil {
+ return fmt.Errorf("closing temp file: %w", err)
+ }
+ if err := os.Rename(tmpName, path); err != nil {
+ return fmt.Errorf("renaming temp file into place: %w", err)
+ }
+ return nil
+}
diff --git a/internal/harness/antigravityinteractions/server.go b/internal/harness/antigravityinteractions/server.go
new file mode 100644
index 00000000..7d77a438
--- /dev/null
+++ b/internal/harness/antigravityinteractions/server.go
@@ -0,0 +1,225 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravityinteractions
+
+import (
+ "context"
+ "errors"
+ "fmt"
+ "log"
+ "net"
+ "net/http"
+ "os"
+ "os/signal"
+ "strconv"
+ "sync"
+ "syscall"
+
+ "github.com/google/ax/internal/harness"
+ "github.com/google/ax/proto"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/health"
+ healthpb "google.golang.org/grpc/health/grpc_health_v1"
+)
+
+// server adapts the Antigravity Interactions harness (a Go harness.Harness) onto
+// the gRPC HarnessService that the AX controller connects to. One server serves
+// one actor's lifetime; substrate creates the actor per request.
+type server struct {
+ proto.UnimplementedHarnessServiceServer
+ h harness.Harness
+}
+
+// Serve builds the Antigravity Interactions harness from cfg and serves the
+// HarnessService (plus gRPC health) on host:port, and an HTTP /readyz endpoint on
+// readyzPort that reflects this server's own serving state. It blocks until ctx
+// is cancelled or a termination signal (SIGINT/SIGTERM) is received, then shuts
+// down gracefully.
+func Serve(ctx context.Context, cfg AntigravityInteractionsConfig, host string, port, readyzPort int) error {
+ h, err := New(cfg)
+ if err != nil {
+ return fmt.Errorf("creating antigravity interactions harness: %w", err)
+ }
+
+ lis, err := net.Listen("tcp", net.JoinHostPort(host, strconv.Itoa(port)))
+ if err != nil {
+ return fmt.Errorf("listen on %s:%d: %w", host, port, err)
+ }
+
+ grpcServer := grpc.NewServer()
+ proto.RegisterHarnessServiceServer(grpcServer, &server{h: h})
+
+ // gRPC health: report SERVING for the default service so the controller's
+ // health checks pass.
+ hs := health.NewServer()
+ hs.SetServingStatus("", healthpb.HealthCheckResponse_SERVING)
+ healthpb.RegisterHealthServer(grpcServer, hs)
+
+ // Go-based readiness: /readyz is OK once this server is up and serving.
+ readyzSrv := serveReadyz(host, readyzPort)
+
+ // Shut down on ctx cancellation or termination signals.
+ ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
+ defer stop()
+ go func() {
+ <-ctx.Done()
+ log.Printf("antigravity interactions harness: shutting down")
+ if readyzSrv != nil {
+ _ = readyzSrv.Close()
+ }
+ grpcServer.GracefulStop()
+ }()
+
+ log.Printf("antigravity interactions harness serving on %s:%d (readyz :%d)", host, port, readyzPort)
+ if err := grpcServer.Serve(lis); err != nil {
+ return fmt.Errorf("harness gRPC server: %w", err)
+ }
+ return nil
+}
+
+// serveReadyz starts an HTTP server exposing /readyz that returns 200 as long as
+// this process's gRPC server is running. Readiness is intrinsic to this server.
+func serveReadyz(host string, readyzPort int) *http.Server {
+ mux := http.NewServeMux()
+ mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
+ w.WriteHeader(http.StatusOK)
+ _, _ = w.Write([]byte("ok\n"))
+ })
+ srv := &http.Server{
+ Addr: net.JoinHostPort(host, strconv.Itoa(readyzPort)),
+ Handler: mux,
+ }
+ go func() {
+ if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
+ log.Printf("readyz server on %s exited: %v", srv.Addr, err)
+ }
+ }()
+ return srv
+}
+
+// Connect drives one harness execution. Per the HarnessService contract, the
+// client sends HarnessRequest{start} (and may send one HarnessRequest{cancel}
+// mid-stream); the server streams zero or more HarnessResponse{outputs} frames
+// terminated by exactly one HarnessResponse{end}.
+func (s *server) Connect(stream proto.HarnessService_ConnectServer) error {
+ ctx, cancel := context.WithCancel(stream.Context())
+ defer cancel()
+
+ // The first frame must be a start.
+ req, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+ start := req.GetStart()
+ if start == nil {
+ return fmt.Errorf("first HarnessRequest must be a start frame")
+ }
+ convID := req.GetConversationId()
+
+ exec, err := s.h.Start(ctx, convID, start.GetHarnessConfig())
+ if err != nil {
+ return sendEnd(stream, convID, proto.State_STATE_FAILED, err)
+ }
+ defer func() { _ = exec.Close(context.WithoutCancel(ctx)) }()
+
+ if len(start.GetMessages()) > 0 {
+ if err := exec.Queue(ctx, start.GetMessages()...); err != nil {
+ return sendEnd(stream, convID, proto.State_STATE_FAILED, err)
+ }
+ }
+
+ // Watch for a mid-stream cancel frame; it cancels the run's context.
+ //
+ // Note: mid-run steering (injecting extra human input while a turn is running)
+ // is intentionally NOT supported here. The execution can accept steering via
+ // Queue, but the HarnessRequest oneof only defines {start, cancel} -- there is
+ // no wire frame to carry additional input after start -- so a client cannot
+ // deliver steering over Connect. Multi-turn conversation is instead expressed
+ // as separate Connect executions that resume via the persisted cursor. Any
+ // non-cancel frame received here is therefore ignored.
+ go func() {
+ for {
+ r, err := stream.Recv()
+ if err != nil {
+ return // stream closed or errored; run ends by other means
+ }
+ if r.GetCancel() != nil {
+ cancel()
+ return
+ }
+ }
+ }()
+
+ // Run the turn, forwarding each agent message as an outputs frame.
+ handler := &streamHandler{stream: stream, convID: convID}
+ if err := exec.Run(ctx, handler); err != nil {
+ if ctx.Err() != nil {
+ return sendEnd(stream, convID, proto.State_STATE_CANCELED, ctx.Err())
+ }
+ return sendEnd(stream, convID, proto.State_STATE_FAILED, err)
+ }
+ if serr := handler.err(); serr != nil {
+ return serr // failed while sending an outputs frame; stream is broken
+ }
+ return sendEnd(stream, convID, proto.State_STATE_COMPLETED, nil)
+}
+
+// streamHandler forwards each message the harness produces during a turn as a
+// HarnessResponse{outputs} frame.
+type streamHandler struct {
+ stream proto.HarnessService_ConnectServer
+ convID string
+
+ mu sync.Mutex
+ sendErr error
+}
+
+var _ harness.Handler = (*streamHandler)(nil)
+
+func (h *streamHandler) OnMessage(_ context.Context, _ string, msg *proto.Message) error {
+ err := h.stream.Send(&proto.HarnessResponse{
+ ConversationId: h.convID,
+ Type: &proto.HarnessResponse_Outputs{
+ Outputs: &proto.HarnessOutputs{Messages: []*proto.Message{msg}},
+ },
+ })
+ if err != nil {
+ h.mu.Lock()
+ h.sendErr = err
+ h.mu.Unlock()
+ }
+ return err
+}
+
+func (h *streamHandler) OnComplete(_ context.Context, _ string) error { return nil }
+
+func (h *streamHandler) err() error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.sendErr
+}
+
+// sendEnd sends the single terminal HarnessResponse{end} frame. If err is
+// non-nil, it is attached as the terminal Error.
+func sendEnd(stream proto.HarnessService_ConnectServer, convID string, state proto.State, err error) error {
+ end := &proto.HarnessEnd{State: state}
+ if err != nil {
+ end.Error = &proto.Error{Description: err.Error()}
+ }
+ return stream.Send(&proto.HarnessResponse{
+ ConversationId: convID,
+ Type: &proto.HarnessResponse_End{End: end},
+ })
+}
diff --git a/internal/harness/antigravityinteractions/server_test.go b/internal/harness/antigravityinteractions/server_test.go
new file mode 100644
index 00000000..e0e454b2
--- /dev/null
+++ b/internal/harness/antigravityinteractions/server_test.go
@@ -0,0 +1,142 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravityinteractions
+
+import (
+ "context"
+ "errors"
+ "io"
+ "net"
+ "testing"
+
+ "github.com/google/ax/internal/harness/harnesstest"
+ "github.com/google/ax/proto"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/test/bufconn"
+)
+
+// startTestServer serves the HarnessService (backed by h) over an in-memory
+// bufconn and returns a connected HarnessService client.
+func startTestServer(t *testing.T, h *AntigravityInteractionsHarness) proto.HarnessServiceClient {
+ t.Helper()
+ lis := bufconn.Listen(1024 * 1024)
+ s := grpc.NewServer()
+ proto.RegisterHarnessServiceServer(s, &server{h: h})
+ go func() { _ = s.Serve(lis) }()
+ t.Cleanup(s.Stop)
+
+ conn, err := grpc.NewClient(
+ "passthrough:///bufnet",
+ grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) {
+ return lis.DialContext(ctx)
+ }),
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ )
+ if err != nil {
+ t.Fatalf("dial bufconn: %v", err)
+ }
+ t.Cleanup(func() { _ = conn.Close() })
+ return proto.NewHarnessServiceClient(conn)
+}
+
+// TestConnect_StartToEnd verifies the Connect wire contract: a start frame drives
+// one harness turn, and the stream terminates with exactly one HarnessEnd
+// (COMPLETED) against the fake Interactions API.
+func TestConnect_StartToEnd(t *testing.T) {
+ fake := &fakeInteractions{interactionIDs: []string{"INT-1"}}
+ h := newTestHarness(t, fake, t.TempDir())
+ client := startTestServer(t, h)
+
+ stream, err := client.Connect(context.Background())
+ if err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ if err := stream.Send(&proto.HarnessRequest{
+ ConversationId: "conv-1",
+ HarnessId: "antigravity-interactions",
+ Type: &proto.HarnessRequest_Start{
+ Start: &proto.HarnessStart{Messages: []*proto.Message{harnesstest.UserText("hello")}},
+ },
+ }); err != nil {
+ t.Fatalf("send start: %v", err)
+ }
+ _ = stream.CloseSend()
+
+ var gotEnd *proto.HarnessEnd
+ for {
+ resp, err := stream.Recv()
+ if errors.Is(err, io.EOF) {
+ break
+ }
+ if err != nil {
+ t.Fatalf("recv: %v", err)
+ }
+ if end := resp.GetEnd(); end != nil {
+ if gotEnd != nil {
+ t.Fatalf("received more than one HarnessEnd")
+ }
+ gotEnd = end
+ }
+ }
+
+ if gotEnd == nil {
+ t.Fatal("stream ended without a HarnessEnd frame")
+ }
+ if gotEnd.GetState() != proto.State_STATE_COMPLETED {
+ t.Errorf("terminal state = %v, want COMPLETED (error: %v)", gotEnd.GetState(), gotEnd.GetError())
+ }
+
+ // The fake recorded the request: first turn has no previous_interaction_id.
+ reqs := fake.recorded()
+ if len(reqs) != 1 {
+ t.Fatalf("fake received %d requests, want 1", len(reqs))
+ }
+ if reqs[0].PreviousInteractionID != "" {
+ t.Errorf("previous_interaction_id = %q, want empty", reqs[0].PreviousInteractionID)
+ }
+}
+
+// TestConnect_FirstFrameMustBeStart verifies that a non-start first frame is
+// rejected.
+func TestConnect_FirstFrameMustBeStart(t *testing.T) {
+ fake := &fakeInteractions{}
+ h := newTestHarness(t, fake, t.TempDir())
+ client := startTestServer(t, h)
+
+ stream, err := client.Connect(context.Background())
+ if err != nil {
+ t.Fatalf("Connect: %v", err)
+ }
+ if err := stream.Send(&proto.HarnessRequest{
+ ConversationId: "conv-1",
+ Type: &proto.HarnessRequest_Cancel{Cancel: &proto.HarnessCancel{}},
+ }); err != nil {
+ t.Fatalf("send cancel: %v", err)
+ }
+ _ = stream.CloseSend()
+
+ // Draining the stream should surface an error (Connect returns it).
+ for {
+ _, err := stream.Recv()
+ if err == nil {
+ continue
+ }
+ if errors.Is(err, io.EOF) {
+ t.Fatal("expected an error for a non-start first frame, got clean EOF")
+ }
+ break // got the expected non-EOF error
+ }
+}
diff --git a/internal/harness/antigravityinteractions/skills.go b/internal/harness/antigravityinteractions/skills.go
new file mode 100644
index 00000000..5e0d57f8
--- /dev/null
+++ b/internal/harness/antigravityinteractions/skills.go
@@ -0,0 +1,61 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravityinteractions
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/google/ax/internal/skills/geminienterprise"
+)
+
+// SkillsSystemInstruction builds a system-instruction pointer telling the agent
+// where its materialized skills live and lists them.
+//
+// This is discovery logic specific to the Antigravity Interactions harness: it
+// has no SKILLS_DIR concept, so it must be told where to find skills via its
+// system instruction (its built-in file tools then read that directory).
+// Harnesses that auto-discover a skills directory (e.g. the Antigravity SDK
+// harness via SKILLS_DIR) do not use this.
+func SkillsSystemInstruction(res geminienterprise.Result) string {
+ if res.Empty() {
+ return ""
+ }
+ var b strings.Builder
+ for _, w := range res.Written {
+ if b.Len() > 0 {
+ b.WriteString("\n")
+ }
+ fmt.Fprintf(&b, "Agent skills are available under %s. Available skills:", w.Dir)
+ for _, s := range w.Skills {
+ fmt.Fprintf(&b, " %s", s.SkillID)
+ }
+ b.WriteString(". Read a skill's SKILL.md before using it.")
+ }
+ return b.String()
+}
+
+// JoinSystemInstruction combines a user-configured system instruction with an
+// optional skills pointer (from SkillsSystemInstruction), dropping empties.
+func JoinSystemInstruction(base, pointer string) string {
+ parts := make([]string, 0, 2)
+ if strings.TrimSpace(base) != "" {
+ parts = append(parts, base)
+ }
+ if strings.TrimSpace(pointer) != "" {
+ parts = append(parts, pointer)
+ }
+ return strings.Join(parts, "\n\n")
+}
diff --git a/internal/harness/antigravityinteractions/skills_test.go b/internal/harness/antigravityinteractions/skills_test.go
new file mode 100644
index 00000000..151e993b
--- /dev/null
+++ b/internal/harness/antigravityinteractions/skills_test.go
@@ -0,0 +1,68 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package antigravityinteractions
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/google/ax/internal/skills/geminienterprise"
+)
+
+func TestSkillsSystemInstruction(t *testing.T) {
+ t.Run("empty result yields empty pointer", func(t *testing.T) {
+ if got := SkillsSystemInstruction(geminienterprise.Result{}); got != "" {
+ t.Errorf("empty result pointer = %q, want empty", got)
+ }
+ })
+
+ t.Run("mentions dir and skill ids", func(t *testing.T) {
+ res := geminienterprise.Result{Written: []geminienterprise.Written{{
+ Dir: "/workspace",
+ Skills: []geminienterprise.MaterializedSkill{{SkillID: "emoji"}, {SkillID: "lowercase"}},
+ }}}
+ got := SkillsSystemInstruction(res)
+ if !strings.Contains(got, "/workspace") || !strings.Contains(got, "emoji") || !strings.Contains(got, "lowercase") {
+ t.Errorf("pointer = %q, want it to mention dir and skill ids", got)
+ }
+ })
+
+ t.Run("multiple registries produce multiple lines", func(t *testing.T) {
+ res := geminienterprise.Result{Written: []geminienterprise.Written{
+ {Dir: "/a", Skills: []geminienterprise.MaterializedSkill{{SkillID: "s1"}}},
+ {Dir: "/b", Skills: []geminienterprise.MaterializedSkill{{SkillID: "s2"}}},
+ }}
+ got := SkillsSystemInstruction(res)
+ if !strings.Contains(got, "/a") || !strings.Contains(got, "/b") || !strings.Contains(got, "\n") {
+ t.Errorf("pointer = %q, want both dirs on separate lines", got)
+ }
+ })
+}
+
+func TestJoinSystemInstruction(t *testing.T) {
+ cases := []struct {
+ base, ptr, want string
+ }{
+ {"", "", ""},
+ {"base only", "", "base only"},
+ {"", "ptr only", "ptr only"},
+ {"base", "ptr", "base\n\nptr"},
+ }
+ for _, c := range cases {
+ if got := JoinSystemInstruction(c.base, c.ptr); got != c.want {
+ t.Errorf("join(%q,%q) = %q, want %q", c.base, c.ptr, got, c.want)
+ }
+ }
+}
diff --git a/internal/harness/harness.go b/internal/harness/harness.go
new file mode 100644
index 00000000..716f0165
--- /dev/null
+++ b/internal/harness/harness.go
@@ -0,0 +1,63 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package harness defines a common execution boundary and lifecycle hooks
+// for interacting with agents, planners, or external gRPC services.
+package harness
+
+import (
+ "context"
+
+ "github.com/google/ax/proto"
+)
+
+// Handler defines the streaming event hook callbacks for an execution turn.
+type Handler interface {
+ // OnMessage is invoked when the agent generates output content during its turn.
+ OnMessage(ctx context.Context, execID string, msg *proto.Message) error
+
+ // OnComplete is invoked when the agent finishes its current execution turn.
+ OnComplete(ctx context.Context, execID string) error
+}
+
+// Harness represents a service capable of starting execution sessions.
+//
+// Single-writer expectation: the controller must ensure that at most one
+// Execution exists per conversation id at a time. Harness implementations rely
+// on this invariant -- for example, a harness that durably persists
+// per-conversation state may use a last-write-wins store without
+// compare-and-swap, which is correct only because there is a single writer per
+// conversation.
+type Harness interface {
+ // Start initializes a new Execution session for a conversation. harnessConfig
+ // carries optional per-request harness configuration; it is opaque to the
+ // controller and interpreted by the harness implementation.
+ Start(ctx context.Context, conversationID string, harnessConfig []byte) (Execution, error)
+}
+
+// Execution represents an active interactive session with an agent or planner.
+type Execution interface {
+ // Run executes the session and streams events to the provided Handler.
+ // It blocks until the current turn completes or fails.
+ Run(ctx context.Context, handler Handler) error
+
+ // Queue enqueues new input messages to be processed in the next turn.
+ Queue(ctx context.Context, msg ...*proto.Message) error
+
+ // ID returns the unique execution session ID.
+ ID() string
+
+ // Close cleanly releases all resources associated with the execution session.
+ Close(ctx context.Context) error
+}
diff --git a/internal/harness/harnesstest/harnesstest.go b/internal/harness/harnesstest/harnesstest.go
new file mode 100644
index 00000000..6328b063
--- /dev/null
+++ b/internal/harness/harnesstest/harnesstest.go
@@ -0,0 +1,294 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package harnesstest
+
+// Shared in-process mocks for the harness tests: a mock Substrate Control server
+// (the substrate control plane), a mock HarnessService server (the harness
+// inside an actor), a recording Handler, and message builders.
+
+import (
+ "context"
+ "net"
+ "sync"
+ "testing"
+
+ "github.com/agent-substrate/substrate/pkg/proto/ateapipb"
+ "github.com/google/ax/internal/harness"
+ "github.com/google/ax/proto"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/health"
+ "google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/status"
+)
+
+// mockControlServer is an in-process ateapipb.ControlServer that records the
+// actor lifecycle calls SubstrateHarness makes and lets tests steer the
+// CreateActor/ResumeActor responses. Only the three RPCs SubstrateHarness uses
+// are implemented; the rest come from the embedded Unimplemented server.
+type MockControlServer struct {
+ ateapipb.UnimplementedControlServer
+
+ mu sync.Mutex
+ createCalls []string
+ resumeCalls []string
+ suspendCalls []string
+
+ CreateErr error // returned from CreateActor when non-nil
+ ResumeIP string // AteomPodIp returned from ResumeActor
+ ResumeNilActor bool // when true, ResumeActor returns a nil Actor
+}
+
+func (f *MockControlServer) CreateAtespace(_ context.Context, req *ateapipb.CreateAtespaceRequest) (*ateapipb.CreateAtespaceResponse, error) {
+ return &ateapipb.CreateAtespaceResponse{Atespace: &ateapipb.Atespace{Name: req.GetName()}}, nil
+}
+
+func (f *MockControlServer) CreateActor(_ context.Context, req *ateapipb.CreateActorRequest) (*ateapipb.CreateActorResponse, error) {
+ f.mu.Lock()
+ f.createCalls = append(f.createCalls, req.GetActorRef().GetName())
+ f.mu.Unlock()
+ if f.CreateErr != nil {
+ return nil, f.CreateErr
+ }
+ return &ateapipb.CreateActorResponse{Actor: &ateapipb.Actor{ActorId: req.GetActorRef().GetName()}}, nil
+}
+
+func (f *MockControlServer) ResumeActor(_ context.Context, req *ateapipb.ResumeActorRequest) (*ateapipb.ResumeActorResponse, error) {
+ f.mu.Lock()
+ f.resumeCalls = append(f.resumeCalls, req.GetActorRef().GetName())
+ f.mu.Unlock()
+ if f.ResumeNilActor {
+ return &ateapipb.ResumeActorResponse{}, nil
+ }
+ return &ateapipb.ResumeActorResponse{Actor: &ateapipb.Actor{ActorId: req.GetActorRef().GetName(), AteomPodIp: f.ResumeIP}}, nil
+}
+
+func (f *MockControlServer) SuspendActor(_ context.Context, req *ateapipb.SuspendActorRequest) (*ateapipb.SuspendActorResponse, error) {
+ f.mu.Lock()
+ f.suspendCalls = append(f.suspendCalls, req.GetActorRef().GetName())
+ f.mu.Unlock()
+ return &ateapipb.SuspendActorResponse{}, nil
+}
+
+// Calls returns copies of the recorded call lists.
+func (f *MockControlServer) Calls() (create, resume, suspend []string) {
+ f.mu.Lock()
+ defer f.mu.Unlock()
+ return append([]string(nil), f.createCalls...),
+ append([]string(nil), f.resumeCalls...),
+ append([]string(nil), f.suspendCalls...)
+}
+
+// mockHarnessServer is an in-process proto.HarnessServiceServer standing in for
+// the harness running inside an actor (substrate) or a local subprocess
+// (antigravity). It records the start frame and emits its configured outputs
+// followed by a terminal HarnessEnd.
+type MockHarnessServer struct {
+ proto.UnimplementedHarnessServiceServer
+
+ // Outputs are the messages emitted (in a single Outputs frame) before the
+ // terminal HarnessEnd. When nil, each input is echoed as "ack: ".
+ Outputs []*proto.Message
+ // FailConnect makes Connect return an RPC error before any frame.
+ FailConnect bool
+ // FailFrame makes Connect terminate the turn with HarnessEnd{STATE_FAILED}.
+ FailFrame bool
+ // ErrCode is the error code used by FailFrame.
+ ErrCode int32
+ // ErrMessage is the error text used by FailConnect/FailFrame.
+ ErrMessage string
+
+ mu sync.Mutex
+ gotConvID string
+ gotHarnessID string
+ gotHarnessConfig []byte
+ gotInputs []string
+}
+
+func (s *MockHarnessServer) Connect(stream proto.HarnessService_ConnectServer) error {
+ if s.FailConnect {
+ return status.Error(codes.Internal, s.ErrMessage)
+ }
+
+ req, err := stream.Recv()
+ if err != nil {
+ return err
+ }
+
+ var inputs []string
+ for _, m := range req.GetStart().GetMessages() {
+ if text := m.GetContent().GetText().GetText(); text != "" {
+ inputs = append(inputs, text)
+ }
+ }
+ s.mu.Lock()
+ s.gotConvID = req.GetConversationId()
+ s.gotHarnessID = req.GetHarnessId()
+ s.gotHarnessConfig = req.GetStart().GetHarnessConfig()
+ s.gotInputs = inputs
+ s.mu.Unlock()
+
+ convID := req.GetConversationId()
+ if s.FailFrame {
+ return stream.Send(&proto.HarnessResponse{
+ ConversationId: convID,
+ Type: &proto.HarnessResponse_End{
+ End: &proto.HarnessEnd{
+ State: proto.State_STATE_FAILED,
+ Error: &proto.Error{
+ Code: s.ErrCode,
+ Description: s.ErrMessage,
+ },
+ },
+ },
+ })
+ }
+
+ msgs := s.Outputs
+ if msgs == nil {
+ for _, in := range inputs {
+ msgs = append(msgs, AssistantText("ack: "+in))
+ }
+ }
+ if len(msgs) > 0 {
+ if err := stream.Send(&proto.HarnessResponse{
+ ConversationId: convID,
+ Type: &proto.HarnessResponse_Outputs{
+ Outputs: &proto.HarnessOutputs{Messages: msgs},
+ },
+ }); err != nil {
+ return err
+ }
+ }
+ return stream.Send(&proto.HarnessResponse{
+ ConversationId: convID,
+ Type: &proto.HarnessResponse_End{End: &proto.HarnessEnd{State: proto.State_STATE_COMPLETED}},
+ })
+}
+
+// Received returns a copy of the start frame the server received.
+func (s *MockHarnessServer) Received() (convID, harnessID string, harnessConfig []byte, inputs []string) {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.gotConvID, s.gotHarnessID, append([]byte(nil), s.gotHarnessConfig...), append([]string(nil), s.gotInputs...)
+}
+
+// mockHandler records the messages and completion streamed during a turn.
+type MockHandler struct {
+ mu sync.Mutex
+ messages []*proto.Message
+ complete bool
+}
+
+var _ harness.Handler = (*MockHandler)(nil)
+
+func (h *MockHandler) OnMessage(_ context.Context, _ string, msg *proto.Message) error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.messages = append(h.messages, msg)
+ return nil
+}
+
+func (h *MockHandler) OnComplete(_ context.Context, _ string) error {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ h.complete = true
+ return nil
+}
+
+func (h *MockHandler) IsDone() bool {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return h.complete
+}
+
+// Collected returns a copy of the messages received via OnMessage.
+func (h *MockHandler) Collected() []*proto.Message {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ return append([]*proto.Message(nil), h.messages...)
+}
+
+// Texts returns the text content of each received message, in order.
+func (h *MockHandler) Texts() []string {
+ h.mu.Lock()
+ defer h.mu.Unlock()
+ var out []string
+ for _, m := range h.messages {
+ out = append(out, m.GetContent().GetText().GetText())
+ }
+ return out
+}
+
+func AssistantText(text string) *proto.Message {
+ return &proto.Message{
+ Role: "assistant",
+ Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: text}}},
+ }
+}
+
+func UserText(text string) *proto.Message {
+ return &proto.Message{
+ Role: "user",
+ Content: &proto.Content{Type: &proto.Content_Text{Text: &proto.TextContent{Text: text}}},
+ }
+}
+
+func ThoughtText(summary string) *proto.Message {
+ return &proto.Message{
+ Role: "model",
+ Content: &proto.Content{
+ Type: &proto.Content_Thought{
+ Thought: &proto.ThoughtContent{
+ Summary: []*proto.ThoughtSummaryContent{
+ {Type: &proto.ThoughtSummaryContent_Text{Text: &proto.TextContent{Text: summary}}},
+ },
+ },
+ },
+ },
+ }
+}
+
+// StartHarnessServer starts a HarnessService + health server (status SERVING)
+// on a random local port and returns its address.
+func StartHarnessServer(t *testing.T, srv *MockHarnessServer) string {
+ t.Helper()
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("failed to listen: %v", err)
+ }
+ s := grpc.NewServer()
+ proto.RegisterHarnessServiceServer(s, srv)
+ hs := health.NewServer()
+ hs.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
+ grpc_health_v1.RegisterHealthServer(s, hs)
+ go func() { _ = s.Serve(lis) }()
+ t.Cleanup(s.Stop)
+ return lis.Addr().String()
+}
+
+// StartControlServer starts a mock Substrate Control server on a random local port.
+func StartControlServer(t *testing.T, srv *MockControlServer) string {
+ t.Helper()
+ lis, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("failed to listen: %v", err)
+ }
+ s := grpc.NewServer()
+ ateapipb.RegisterControlServer(s, srv)
+ go func() { _ = s.Serve(lis) }()
+ t.Cleanup(s.Stop)
+ return lis.Addr().String()
+}
diff --git a/internal/harness/stream.go b/internal/harness/stream.go
new file mode 100644
index 00000000..7551da85
--- /dev/null
+++ b/internal/harness/stream.go
@@ -0,0 +1,68 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package harness
+
+import (
+ "context"
+ "fmt"
+ "io"
+
+ "github.com/google/ax/proto"
+)
+
+// DrainStream reads from the harness gRPC stream until io.EOF, dispatching messages
+// to the handler, and returns the final execution status.
+func DrainStream(ctx context.Context, stream proto.HarnessService_ConnectClient, execID string, handler Handler) error {
+ var endState proto.State
+ var endErr error
+ hasEnd := false
+
+ for {
+ resp, err := stream.Recv()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return fmt.Errorf("gRPC harness streaming failure: %w", err)
+ }
+
+ switch payload := resp.Type.(type) {
+ case *proto.HarnessResponse_Outputs:
+ for _, outMsg := range payload.Outputs.Messages {
+ if err := handler.OnMessage(ctx, execID, outMsg); err != nil {
+ return fmt.Errorf("failed to dispatch streamed output: %w", err)
+ }
+ }
+ case *proto.HarnessResponse_End:
+ hasEnd = true
+ endState = payload.End.GetState()
+ if endState == proto.State_STATE_FAILED {
+ if errDetail := payload.End.GetError(); errDetail != nil {
+ endErr = fmt.Errorf("harness failed: [%d] %s", errDetail.GetCode(), errDetail.GetDescription())
+ } else {
+ endErr = fmt.Errorf("harness failed with no error details")
+ }
+ }
+ }
+ }
+
+ if !hasEnd {
+ return fmt.Errorf("harness stream ended without HarnessEnd frame")
+ }
+ if endState == proto.State_STATE_FAILED {
+ return endErr
+ }
+ return handler.OnComplete(ctx, execID)
+}
diff --git a/internal/harness/substrate/substrate.go b/internal/harness/substrate/substrate.go
new file mode 100644
index 00000000..b1e84030
--- /dev/null
+++ b/internal/harness/substrate/substrate.go
@@ -0,0 +1,256 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package substrate
+
+import (
+ "context"
+ "crypto/tls"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "sync"
+ "time"
+
+ "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
+ "go.opentelemetry.io/otel"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/status"
+
+ "github.com/google/ax/internal/harness"
+ "github.com/google/ax/internal/k8s/ate"
+ "github.com/google/ax/proto"
+ "github.com/google/uuid"
+)
+
+// Compile-time interface assertions.
+var _ harness.Harness = (*SubstrateHarness)(nil)
+var _ harness.Execution = (*substrateExecution)(nil)
+
+// healthCheckTimeout defines the maximum time Start waits for a freshly
+// created/resumed actor's harness to become reachable and ready.
+const healthCheckTimeout = 60 * time.Second
+
+// SubstrateHarness manages execution in a SubstrATE sandboxed actor over gRPC HarnessService.
+type SubstrateHarness struct {
+ harnessID string
+ ateClient *ate.Client
+ port int
+ dialOpts []grpc.DialOption
+}
+
+// New creates a new SubstrateHarness.
+func New(harnessID string, endpoint string, namespace string, template string, port int, opts ...grpc.DialOption) (*SubstrateHarness, error) {
+ if port == 0 {
+ port = 50053 // Default HarnessService port
+ }
+ if namespace == "" {
+ namespace = "ax"
+ }
+ if template == "" {
+ template = "ax-harness-antigravity-template"
+ }
+ controlCreds := grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{InsecureSkipVerify: true}))
+ client, err := ate.NewClient(namespace, template, endpoint, controlCreds)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create ATE client: %w", err)
+ }
+ if len(opts) == 0 {
+ opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ }
+ opts = append(opts, grpc.WithStatsHandler(otelgrpc.NewClientHandler()))
+ return &SubstrateHarness{
+ harnessID: harnessID,
+ ateClient: client,
+ port: port,
+ dialOpts: opts,
+ }, nil
+}
+
+// Start implements Harness interface. It creates/resumes the target actor.
+func (h *SubstrateHarness) Start(ctx context.Context, conversationID string, harnessConfig []byte) (harness.Execution, error) {
+ if conversationID == "" {
+ return nil, errors.New("SubstrateHarness needs valid conversationID")
+ }
+
+ // CreateActor is idempotent here: on follow-up turns the actor was created
+ // (and suspended) on a previous turn, so AlreadyExists is expected and fine.
+ if _, err := h.ateClient.CreateActor(ctx, conversationID); err != nil && status.Code(err) != codes.AlreadyExists {
+ return nil, fmt.Errorf("failed to create substrate actor %s: %w", conversationID, err)
+ }
+
+ // Resume the actor so it is scheduled onto a worker and gets a routable IP.
+ resumeResp, err := h.ateClient.ResumeActor(ctx, conversationID)
+ if err != nil {
+ return nil, fmt.Errorf("failed to resume substrate actor %s: %w", conversationID, err)
+ }
+ actor := resumeResp.Actor
+ if actor == nil {
+ return nil, fmt.Errorf("received nil actor in response for %s", conversationID)
+ }
+ if actor.AteomPodIp == "" {
+ return nil, fmt.Errorf("actor %s has no active worker IP address", conversationID)
+ }
+
+ // Establish connection to the actor's worker IP
+ workerAddr := fmt.Sprintf("%s:%d", actor.AteomPodIp, h.port)
+ conn, err := grpc.NewClient(workerAddr, h.dialOpts...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to dial remote harness service at %s: %w", workerAddr, err)
+ }
+
+ // Wait for the harness to be reachable and ready before handing back the
+ // execution.
+ if err := waitForHealthy(ctx, conn, healthCheckTimeout); err != nil {
+ conn.Close()
+ return nil, fmt.Errorf("harness for %s not ready at %s: %w", conversationID, workerAddr, err)
+ }
+
+ return &substrateExecution{
+ harness: h,
+ conversationID: conversationID,
+ execID: uuid.NewString(),
+ conn: conn,
+ client: proto.NewHarnessServiceClient(conn),
+ harnessConfig: harnessConfig,
+ }, nil
+}
+
+// waitForHealthy blocks until the harness behind conn reports SERVING via the
+// standard gRPC health protocol until timeout. A harness that is reachable
+// but does not implement the health service (Unimplemented) is treated as
+// ready; connection failures (Unavailable) and NOT_SERVING are retried.
+func waitForHealthy(ctx context.Context, conn *grpc.ClientConn, timeout time.Duration) error {
+ ctx, cancel := context.WithTimeout(ctx, timeout)
+ defer cancel()
+
+ client := grpc_health_v1.NewHealthClient(conn)
+ const maxBackoff = 2 * time.Second
+ backoff := 100 * time.Millisecond
+ for {
+ resp, err := client.Check(ctx, &grpc_health_v1.HealthCheckRequest{Service: ""})
+ if err == nil && resp.GetStatus() == grpc_health_v1.HealthCheckResponse_SERVING {
+ return nil
+ }
+ if status.Code(err) == codes.Unimplemented {
+ // Reachable but no health service: the port is up, proceed.
+ return nil
+ }
+
+ select {
+ case <-ctx.Done():
+ if err != nil {
+ return fmt.Errorf("harness not healthy within %s: %w", timeout, err)
+ }
+ return fmt.Errorf("harness not healthy within %s (last status: %s)", timeout, resp.GetStatus())
+ case <-time.After(backoff):
+ }
+ backoff *= 2
+ if backoff > maxBackoff {
+ backoff = maxBackoff
+ }
+ }
+}
+
+type substrateExecution struct {
+ harness *SubstrateHarness
+ conversationID string
+ execID string
+ conn *grpc.ClientConn
+ client proto.HarnessServiceClient
+ harnessConfig []byte
+
+ mu sync.Mutex
+ pending []*proto.Message
+}
+
+func (e *substrateExecution) ID() string {
+ return e.execID
+}
+
+func (e *substrateExecution) Queue(ctx context.Context, msg ...*proto.Message) error {
+ e.mu.Lock()
+ defer e.mu.Unlock()
+ e.pending = append(e.pending, msg...)
+ return nil
+}
+
+func (e *substrateExecution) Run(ctx context.Context, handler harness.Handler) error {
+ ctx, span := otel.Tracer("substrate-harness").Start(ctx, "Run")
+ defer span.End()
+
+ e.mu.Lock()
+ inputs := e.pending
+ e.pending = nil
+ e.mu.Unlock()
+
+ stream, err := e.client.Connect(ctx)
+ if err != nil {
+ return fmt.Errorf("failed to open harness service stream: %w", err)
+ }
+
+ // Send a HarnessRequest to initiate the turn.
+ start := &proto.HarnessRequest{
+ ConversationId: e.conversationID,
+ HarnessId: e.harness.harnessID,
+ Type: &proto.HarnessRequest_Start{
+ Start: &proto.HarnessStart{
+ HarnessConfig: e.harnessConfig,
+ Messages: inputs,
+ },
+ },
+ }
+ // A server that fails before reading the start frame makes Send/CloseSend
+ // report io.EOF; the real status is surfaced by DrainStream's Recv below, so
+ // only treat non-EOF errors as send failures.
+ if err := stream.Send(start); err != nil && err != io.EOF {
+ return fmt.Errorf("failed to send harness start: %w", err)
+ }
+
+ // Close send direction to trigger server processing.
+ if err := stream.CloseSend(); err != nil && err != io.EOF {
+ return fmt.Errorf("failed to close stream send direction: %w", err)
+ }
+
+ // Drain HarnessResponse frames until the terminal HarnessEnd.
+ return harness.DrainStream(ctx, stream, e.execID, handler)
+}
+
+func (e *substrateExecution) Close(ctx context.Context) error {
+ // Close connection
+ if e.conn != nil {
+ e.conn.Close()
+ }
+
+ // Suspend actor to return resource to standard standby pool
+ slog.InfoContext(ctx, "Suspending SubstrATE actor",
+ slog.String("conversation_id", e.conversationID),
+ slog.String("exec_id", e.execID),
+ )
+ suspendCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+ if _, err := e.harness.ateClient.SuspendActor(suspendCtx, e.conversationID); err != nil {
+ slog.ErrorContext(ctx, "Failed to suspend SubstrATE actor",
+ slog.String("conversation_id", e.conversationID),
+ slog.Any("error", err),
+ )
+ }
+
+ return nil
+}
diff --git a/internal/harness/substrate/substrate_test.go b/internal/harness/substrate/substrate_test.go
new file mode 100644
index 00000000..111e0cc3
--- /dev/null
+++ b/internal/harness/substrate/substrate_test.go
@@ -0,0 +1,290 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package substrate
+
+import (
+ "bytes"
+ "context"
+ "net"
+ "slices"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/ax/internal/harness/harnesstest"
+ "github.com/google/ax/internal/k8s/ate"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/health"
+ "google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/status"
+)
+
+var substrateHarnessConfig = []byte(`{"model":"gemini-2.5-pro"}`)
+
+// startHealthTestServer starts a gRPC server on a random local port. If hs is
+// non-nil the standard health service is registered. Returns the listen address.
+func startHealthTestServer(t *testing.T, hs *health.Server) string {
+ t.Helper()
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ t.Fatalf("failed to listen: %v", err)
+ }
+ s := grpc.NewServer()
+ if hs != nil {
+ grpc_health_v1.RegisterHealthServer(s, hs)
+ }
+ go func() {
+ _ = s.Serve(lis)
+ }()
+ t.Cleanup(s.Stop)
+ return lis.Addr().String()
+}
+
+func dialTestConn(t *testing.T, addr string) *grpc.ClientConn {
+ t.Helper()
+ conn, err := grpc.NewClient(addr, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ if err != nil {
+ t.Fatalf("failed to dial %s: %v", addr, err)
+ }
+ t.Cleanup(func() { conn.Close() })
+ return conn
+}
+
+func TestWaitForHealthy_Serving(t *testing.T) {
+ hs := health.NewServer()
+ hs.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
+ conn := dialTestConn(t, startHealthTestServer(t, hs))
+
+ if err := waitForHealthy(context.Background(), conn, 5*time.Second); err != nil {
+ t.Fatalf("expected healthy, got %v", err)
+ }
+}
+
+func TestWaitForHealthy_UnimplementedProceeds(t *testing.T) {
+ // Server is up but does not register the health service.
+ conn := dialTestConn(t, startHealthTestServer(t, nil))
+
+ if err := waitForHealthy(context.Background(), conn, 5*time.Second); err != nil {
+ t.Fatalf("expected to proceed when health is unimplemented, got %v", err)
+ }
+}
+
+func TestWaitForHealthy_TimesOut(t *testing.T) {
+ hs := health.NewServer()
+ hs.SetServingStatus("", grpc_health_v1.HealthCheckResponse_NOT_SERVING)
+ conn := dialTestConn(t, startHealthTestServer(t, hs))
+
+ if err := waitForHealthy(context.Background(), conn, 500*time.Millisecond); err == nil {
+ t.Fatal("expected timeout error while NOT_SERVING, got nil")
+ }
+}
+
+func TestWaitForHealthy_StatusChange(t *testing.T) {
+ hs := health.NewServer()
+ hs.SetServingStatus("", grpc_health_v1.HealthCheckResponse_NOT_SERVING)
+ conn := dialTestConn(t, startHealthTestServer(t, hs))
+
+ go func() {
+ time.Sleep(150 * time.Millisecond)
+ hs.SetServingStatus("", grpc_health_v1.HealthCheckResponse_SERVING)
+ }()
+
+ if err := waitForHealthy(context.Background(), conn, 5*time.Second); err != nil {
+ t.Fatalf("expected healthy after status flip, got %v", err)
+ }
+}
+
+func TestWaitForHealthy_ServerDown(t *testing.T) {
+ // Reserve a port then release it so nothing is listening there.
+ lis, err := net.Listen("tcp", "localhost:0")
+ if err != nil {
+ t.Fatalf("failed to listen: %v", err)
+ }
+ addr := lis.Addr().String()
+ lis.Close()
+ conn := dialTestConn(t, addr)
+
+ if err := waitForHealthy(context.Background(), conn, 500*time.Millisecond); err == nil {
+ t.Fatal("expected timeout error when server is down, got nil")
+ }
+}
+
+// newTestSubstrateHarness builds a SubstrateHarness wired to the mock control
+// server and the mock harness server. It constructs the struct directly (rather
+// than via NewSubstrateHarness) so the control client can use insecure
+// credentials instead of the TLS that NewSubstrateHarness hard-codes.
+func newTestSubstrateHarness(t *testing.T, ctrlAddr, harnessAddr string) *SubstrateHarness {
+ t.Helper()
+ _, portStr, err := net.SplitHostPort(harnessAddr)
+ if err != nil {
+ t.Fatalf("bad harness addr %q: %v", harnessAddr, err)
+ }
+ port, err := strconv.Atoi(portStr)
+ if err != nil {
+ t.Fatalf("bad harness port %q: %v", portStr, err)
+ }
+ client, err := ate.NewClient("ax", "antigravity-template", ctrlAddr,
+ grpc.WithTransportCredentials(insecure.NewCredentials()))
+ if err != nil {
+ t.Fatalf("failed to create ate client: %v", err)
+ }
+ return &SubstrateHarness{
+ harnessID: "antigravity",
+ ateClient: client,
+ port: port,
+ dialOpts: []grpc.DialOption{grpc.WithTransportCredentials(insecure.NewCredentials())},
+ }
+}
+
+// Test full SubstrateHarness Start -> Run -> Close flow against the shared
+// in-process mocks (see mocks_test.go).
+// They lock in the wiring that a substrate bump or an ax-side change could silently
+// break: create/resume idempotency, worker-IP extraction, the health gate, the
+// Connect streaming protocol, and suspend-on-close.
+func TestSubstrateHarness_EndToEnd(t *testing.T) {
+ ctrl := &harnesstest.MockControlServer{ResumeIP: "127.0.0.1"}
+ srv := &harnesstest.MockHarnessServer{}
+ h := newTestSubstrateHarness(t, harnesstest.StartControlServer(t, ctrl), harnesstest.StartHarnessServer(t, srv))
+
+ ctx := context.Background()
+ exec, err := h.Start(ctx, "conv-1", substrateHarnessConfig)
+ if err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ if err := exec.Queue(ctx, harnesstest.UserText("hi")); err != nil {
+ t.Fatalf("Queue: %v", err)
+ }
+ handler := &harnesstest.MockHandler{}
+ if err := exec.Run(ctx, handler); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+
+ // The harness server received the start frame with the right identifiers.
+ convID, harnessID, harnessConfig, inputs := srv.Received()
+ if convID != "conv-1" || harnessID != "antigravity" {
+ t.Errorf("server got convID=%q harnessID=%q, want conv-1/antigravity", convID, harnessID)
+ }
+ if !bytes.Equal(harnessConfig, substrateHarnessConfig) {
+ t.Errorf("server got harnessConfig=%q, want %q", harnessConfig, substrateHarnessConfig)
+ }
+ if !slices.Equal(inputs, []string{"hi"}) {
+ t.Errorf("server got inputs=%v, want [hi]", inputs)
+ }
+
+ // The handler streamed the output and completed.
+ if !handler.IsDone() {
+ t.Error("handler did not complete")
+ }
+ if got := handler.Texts(); !slices.Equal(got, []string{"ack: hi"}) {
+ t.Errorf("handler messages=%v, want [ack: hi]", got)
+ }
+
+ // CreateActor then ResumeActor ran for the conversation; no suspend yet.
+ create, resume, suspend := ctrl.Calls()
+ want := []string{"conv-1"}
+ if !slices.Equal(create, want) || !slices.Equal(resume, want) {
+ t.Errorf("create=%v resume=%v, want %v each", create, resume, want)
+ }
+ if len(suspend) != 0 {
+ t.Errorf("suspend called before Close: %v", suspend)
+ }
+
+ // Close suspends the actor.
+ if err := exec.Close(ctx); err != nil {
+ t.Fatalf("Close: %v", err)
+ }
+ if _, _, suspend = ctrl.Calls(); !slices.Equal(suspend, want) {
+ t.Errorf("suspend=%v, want %v", suspend, want)
+ }
+}
+
+func TestSubstrateHarness_CreateAlreadyExistsTolerated(t *testing.T) {
+ ctrl := &harnesstest.MockControlServer{
+ ResumeIP: "127.0.0.1",
+ CreateErr: status.Error(codes.AlreadyExists, "exists"),
+ }
+ h := newTestSubstrateHarness(t, harnesstest.StartControlServer(t, ctrl), harnesstest.StartHarnessServer(t, &harnesstest.MockHarnessServer{}))
+
+ ctx := context.Background()
+ exec, err := h.Start(ctx, "conv-1", substrateHarnessConfig)
+ if err != nil {
+ t.Fatalf("Start should tolerate AlreadyExists: %v", err)
+ }
+ t.Cleanup(func() { _ = exec.Close(ctx) })
+
+ if err := exec.Queue(ctx, harnesstest.UserText("hi")); err != nil {
+ t.Fatalf("Queue: %v", err)
+ }
+ handler := &harnesstest.MockHandler{}
+ if err := exec.Run(ctx, handler); err != nil {
+ t.Fatalf("Run: %v", err)
+ }
+ if !handler.IsDone() {
+ t.Error("handler did not complete")
+ }
+ if _, resume, _ := ctrl.Calls(); !slices.Equal(resume, []string{"conv-1"}) {
+ t.Errorf("resume=%v, want [conv-1]", resume)
+ }
+}
+
+func TestSubstrateHarness_ResumeNoWorkerIP(t *testing.T) {
+ ctrl := &harnesstest.MockControlServer{ResumeIP: ""} // empty AteomPodIp
+ h := newTestSubstrateHarness(t, harnesstest.StartControlServer(t, ctrl), harnesstest.StartHarnessServer(t, &harnesstest.MockHarnessServer{}))
+
+ _, err := h.Start(context.Background(), "conv-1", substrateHarnessConfig)
+ if err == nil {
+ t.Fatal("expected error for empty worker IP, got nil")
+ }
+ if !strings.Contains(err.Error(), "no active worker IP") {
+ t.Errorf("error = %v, want it to mention 'no active worker IP'", err)
+ }
+}
+
+func TestSubstrateHarness_ResumeNilActor(t *testing.T) {
+ ctrl := &harnesstest.MockControlServer{ResumeNilActor: true}
+ h := newTestSubstrateHarness(t, harnesstest.StartControlServer(t, ctrl), harnesstest.StartHarnessServer(t, &harnesstest.MockHarnessServer{}))
+
+ _, err := h.Start(context.Background(), "conv-1", substrateHarnessConfig)
+ if err == nil {
+ t.Fatal("expected error for nil actor, got nil")
+ }
+ if !strings.Contains(err.Error(), "nil actor") {
+ t.Errorf("error = %v, want it to mention 'nil actor'", err)
+ }
+}
+
+func TestSubstrateHarness_HarnessFailedFrame(t *testing.T) {
+ ctrl := &harnesstest.MockControlServer{ResumeIP: "127.0.0.1"}
+ srv := &harnesstest.MockHarnessServer{FailFrame: true, ErrCode: 13, ErrMessage: "boom"}
+ h := newTestSubstrateHarness(t, harnesstest.StartControlServer(t, ctrl), harnesstest.StartHarnessServer(t, srv))
+
+ ctx := context.Background()
+ exec, err := h.Start(ctx, "conv-1", substrateHarnessConfig)
+ if err != nil {
+ t.Fatalf("Start: %v", err)
+ }
+ t.Cleanup(func() { _ = exec.Close(ctx) })
+ if err := exec.Queue(ctx, harnesstest.UserText("hi")); err != nil {
+ t.Fatalf("Queue: %v", err)
+ }
+ if err := exec.Run(ctx, &harnesstest.MockHandler{}); err == nil {
+ t.Fatal("expected error from failed harness frame, got nil")
+ } else if !strings.Contains(err.Error(), "harness failed") {
+ t.Errorf("error = %v, want it to mention 'harness failed'", err)
+ }
+}
diff --git a/internal/historyutil/confirmations.go b/internal/historyutil/confirmations.go
new file mode 100644
index 00000000..65730006
--- /dev/null
+++ b/internal/historyutil/confirmations.go
@@ -0,0 +1,66 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package historyutil
+
+import "github.com/google/ax/proto"
+
+// WaitsForConfirmation returns true if the last message in the history
+// is a confirmation question waiting for user input.
+func WaitsForConfirmation(history []*proto.Message) *proto.Message {
+ if len(history) == 0 {
+ return nil
+ }
+ last := history[len(history)-1]
+ if last.GetContent().GetConfirmation() != nil && last.GetContent().GetConfirmation().Question != "" {
+ return last
+ }
+ return nil
+}
+
+// HasConfirmationAnswer returns true if the last message in the history
+// is a confirmation question waiting for user input.
+func HasConfirmationAnswer(history []*proto.Message) (approved bool, conf *proto.ConfirmationContent) {
+ if len(history) == 0 {
+ return false, nil
+ }
+ last := history[len(history)-1]
+ if last.GetContent().GetConfirmation() == nil {
+ return false, nil
+ }
+ if last.GetContent().GetConfirmation().GetApproval() != nil {
+ conf = last.GetContent().GetConfirmation()
+ approved = true
+ }
+ if last.GetContent().GetConfirmation().GetDecline() != nil {
+ conf = last.GetContent().GetConfirmation()
+ approved = false
+ }
+ return approved, conf
+}
+
+func WaitsForUser(history []*proto.Message) *proto.Message {
+ if len(history) == 0 {
+ return nil
+ }
+ if msg := WaitsForConfirmation(history); msg != nil {
+ return msg
+ }
+
+ last := history[len(history)-1]
+ if last.GetContent().GetConfirmation() != nil && last.GetContent().GetConfirmation().GetDecline() != nil {
+ return last
+ }
+ return nil
+}
diff --git a/internal/k8s/ate/client.go b/internal/k8s/ate/client.go
new file mode 100644
index 00000000..81a5c061
--- /dev/null
+++ b/internal/k8s/ate/client.go
@@ -0,0 +1,109 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package ate provides a client for the Agent Substrate Control API.
+package ate
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/agent-substrate/substrate/pkg/proto/ateapipb"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/status"
+)
+
+type Client struct {
+ namespace string
+ template string
+ conn *grpc.ClientConn
+}
+
+// NewClient creates a new actor client.
+func NewClient(ns, template, target string, opts ...grpc.DialOption) (*Client, error) {
+ if ns == "" {
+ return nil, fmt.Errorf("namespace cannot be empty")
+ }
+ if template == "" {
+ return nil, fmt.Errorf("template cannot be empty")
+ }
+ if target == "" {
+ target = "api.ate-system.svc:443"
+ }
+ if len(opts) == 0 {
+ opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials()))
+ }
+ conn, err := grpc.NewClient(target, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("error when creating Control client: %w", err)
+ }
+ return &Client{
+ namespace: ns,
+ template: template,
+ conn: conn,
+ }, nil
+}
+
+// CreateActor creates a new actor.
+func (c *Client) CreateActor(ctx context.Context, id string) (*ateapipb.CreateActorResponse, error) {
+ client := ateapipb.NewControlClient(c.conn)
+ // TODO(wjjclaud): Configure atespace in manifests instead of reusing the namespace.
+ if _, err := client.CreateAtespace(ctx, &ateapipb.CreateAtespaceRequest{Name: c.namespace}); err != nil && status.Code(err) != codes.AlreadyExists {
+ return nil, fmt.Errorf("error when calling Control.CreateAtespace: %w", err)
+ }
+ resp, err := client.CreateActor(ctx, &ateapipb.CreateActorRequest{
+ ActorRef: &ateapipb.ActorRef{Atespace: c.namespace, Name: id},
+ ActorTemplateNamespace: c.namespace,
+ ActorTemplateName: c.template,
+ })
+ if err != nil {
+ return nil, fmt.Errorf("error when calling Control.CreateActor: %w", err)
+ }
+ return resp, nil
+}
+
+// ResumeActor resumes the actor, scheduling it onto a worker. The returned
+// actor carries the worker IP once it is running.
+func (c *Client) ResumeActor(ctx context.Context, id string) (*ateapipb.ResumeActorResponse, error) {
+ client := ateapipb.NewControlClient(c.conn)
+ resp, err := client.ResumeActor(ctx, &ateapipb.ResumeActorRequest{
+ ActorRef: &ateapipb.ActorRef{Atespace: c.namespace, Name: id},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("error when calling Control.ResumeActor: %w", err)
+ }
+ return resp, nil
+}
+
+// SuspendActor suspends the actor.
+func (c *Client) SuspendActor(ctx context.Context, id string) (*ateapipb.SuspendActorResponse, error) {
+ client := ateapipb.NewControlClient(c.conn)
+ resp, err := client.SuspendActor(ctx, &ateapipb.SuspendActorRequest{
+ ActorRef: &ateapipb.ActorRef{Atespace: c.namespace, Name: id},
+ })
+ if err != nil {
+ return nil, fmt.Errorf("error when calling Control.SuspendActor: %w", err)
+ }
+ return resp, nil
+}
+
+// Close closes the gRPC connection.
+func (c *Client) Close() error {
+ if c.conn != nil {
+ return c.conn.Close()
+ }
+ return nil
+}
diff --git a/internal/pythonsidecar/setup.go b/internal/pythonsidecar/setup.go
new file mode 100644
index 00000000..344189dd
--- /dev/null
+++ b/internal/pythonsidecar/setup.go
@@ -0,0 +1,143 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pythonsidecar
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "io/fs"
+ "os"
+ "os/exec"
+ "path/filepath"
+
+ "github.com/google/ax/internal/config"
+)
+
+// SetupOptions configures asset extraction and environment setup for a Python sidecar.
+type SetupOptions struct {
+ // FS is the embedded filesystem containing Python assets (e.g., python.FS). (Required)
+ FS fs.FS
+}
+
+// Setup extracts the embedded filesystem assets to TargetDir.
+// It returns TargetDir, which can be set as PythonPath in Config.
+func Setup(ctx context.Context, opts SetupOptions) (string, error) {
+ if opts.FS == nil {
+ return "", fmt.Errorf("SetupOptions.FS cannot be nil")
+ }
+ axDir, err := config.AXAssetsDir()
+ if err != nil {
+ return "", err
+ }
+ extractDir := filepath.Join(axDir, "python")
+ reqPath := filepath.Join(extractDir, "antigravity", "requirements.txt")
+
+ pythonPath := axDir + string(os.PathListSeparator) + extractDir
+ updated, err := extractFS(ctx, opts.FS, extractDir)
+ if err != nil {
+ return "", fmt.Errorf("failed to extract embedded assets: %w", err)
+ }
+
+ pkgPath, err := install(ctx, reqPath, updated)
+ if err != nil {
+ return "", err
+ }
+ return pythonPath + string(os.PathListSeparator) + pkgPath, nil
+}
+
+func extractFS(ctx context.Context, filesystem fs.FS, destDir string) (bool, error) {
+ var updated bool
+ err := fs.WalkDir(filesystem, ".", func(path string, d fs.DirEntry, err error) error {
+ if err != nil {
+ return err
+ }
+ if ctx.Err() != nil {
+ return ctx.Err()
+ }
+
+ destPath := filepath.Join(destDir, filepath.FromSlash(path))
+ if d.IsDir() {
+ return os.MkdirAll(destPath, 0755)
+ }
+
+ info, err := d.Info()
+ if err != nil {
+ return err
+ }
+
+ stat, err := os.Stat(destPath)
+ // If the file already exists on disk with the exact same size,
+ // skip copying to preserve its timestamp and avoid false re-installations.
+ if err == nil {
+ if stat.Size() == info.Size() {
+ return nil
+ }
+ }
+
+ if err := os.MkdirAll(filepath.Dir(destPath), 0755); err != nil {
+ return fmt.Errorf("creating directory for %s: %w", destPath, err)
+ }
+
+ // There is at least, one file updated.
+ updated = true
+ src, err := filesystem.Open(path)
+ if err != nil {
+ return fmt.Errorf("opening embedded file %s: %w", path, err)
+ }
+ defer src.Close()
+
+ mode := os.FileMode(0644)
+ if info.Mode().Perm() != 0 {
+ mode = info.Mode().Perm() | 0200
+ }
+ _ = os.Chmod(destPath, 0644)
+ dst, err := os.OpenFile(destPath, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode)
+ if err != nil {
+ return fmt.Errorf("creating destination file %s: %w", destPath, err)
+ }
+
+ if _, err := io.Copy(dst, src); err != nil {
+ _ = dst.Close()
+ return fmt.Errorf("writing file %s: %w", destPath, err)
+ }
+ if err := dst.Close(); err != nil {
+ return fmt.Errorf("closing file %s: %w", destPath, err)
+ }
+ return nil
+ })
+ return updated, err
+}
+
+func install(ctx context.Context, reqPath string, install bool) (string, error) {
+ pkgDir := filepath.Join(filepath.Dir(reqPath), "site-packages")
+ if !install {
+ // Make sure that pip install ran once and we have
+ // the dependencies installed under site-packages.
+ if _, err := os.Stat(pkgDir); err == nil {
+ return pkgDir, nil
+ }
+ }
+ fmt.Println("Setting up Antigravity SDK, this may take a while...")
+
+ // Some corp machines are overriding the indexes, ensure that
+ // simple index is included where Antigravity SDK is distributed from.
+ cmd := exec.CommandContext(ctx, "python3", "-m", "pip", "install", "--extra-index-url", "https://pypi.org/simple", "--target", pkgDir, "-r", reqPath)
+ out, err := cmd.CombinedOutput()
+ if err != nil {
+ return "", fmt.Errorf("pip install failed for %s: %w\nOutput:\n%s", reqPath, err, string(out))
+ }
+ return pkgDir, nil
+}
diff --git a/internal/pythonsidecar/setup_test.go b/internal/pythonsidecar/setup_test.go
new file mode 100644
index 00000000..cfcf3da3
--- /dev/null
+++ b/internal/pythonsidecar/setup_test.go
@@ -0,0 +1,112 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pythonsidecar_test
+
+import (
+ "bytes"
+ "context"
+ "io/fs"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "testing/fstest"
+ "time"
+
+ "github.com/google/ax/internal/pythonsidecar"
+ "github.com/google/ax/python"
+)
+
+func TestSetup_EmbeddedFS(t *testing.T) {
+ if _, err := fs.Stat(python.FS, "antigravity/__pycache__"); err == nil {
+ t.Errorf("expected antigravity/__pycache__ to be ignored when embedding, but it was found")
+ }
+
+ testFS := fstest.MapFS{
+ "antigravity/harness_server.py": &fstest.MapFile{Data: []byte("print('hello')")},
+ "antigravity/requirements.txt": &fstest.MapFile{Data: []byte("# empty requirements for test\n")},
+ "proto/ax_pb2.py": &fstest.MapFile{Data: []byte("print('proto')")},
+ }
+
+ targetDir := filepath.Join(t.TempDir(), "home")
+ axDir := filepath.Join(targetDir, ".ax")
+ t.Setenv("HOME", targetDir)
+
+ opts := pythonsidecar.SetupOptions{
+ FS: testFS,
+ }
+ gotDir, err := pythonsidecar.Setup(context.Background(), opts)
+ if err != nil {
+ t.Fatalf("Setup() failed: %v", err)
+ }
+ if !strings.Contains(gotDir, "python") {
+ t.Errorf("expected gotDir to contain extracted python directory in PYTHONPATH, got %q", gotDir)
+ }
+
+ // Verify files were extracted under TargetDir/python
+ harnessPath := filepath.Join(axDir, "python", "antigravity", "harness_server.py")
+ if _, err := os.Stat(harnessPath); err != nil {
+ t.Errorf("expected file %s to exist, got stat error: %v", harnessPath, err)
+ }
+ protoPath := filepath.Join(axDir, "python", "proto", "ax_pb2.py")
+ if _, err := os.Stat(protoPath); err != nil {
+ t.Errorf("expected file %s to exist, got stat error: %v", protoPath, err)
+ }
+
+ // Verify that subsequent Setup calls when TargetDir exists succeed without re-extracting
+ if _, err := pythonsidecar.Setup(context.Background(), opts); err != nil {
+ t.Fatalf("subsequent Setup() failed when TargetDir exists: %v", err)
+ }
+}
+
+func TestSidecar_PythonPath(t *testing.T) {
+ tmpDir := t.TempDir()
+ customPath := filepath.Join(tmpDir, "custom_python_path")
+ if err := os.MkdirAll(customPath, 0755); err != nil {
+ t.Fatalf("failed to create custom path: %v", err)
+ }
+
+ modulePath := filepath.Join(customPath, "path_check.py")
+ moduleContent := `
+import sys, os
+print("SYSPATH:" + str(sys.path))
+sys.exit(0)
+`
+ if err := os.WriteFile(modulePath, []byte(moduleContent), 0644); err != nil {
+ t.Fatalf("failed to write path_check module: %v", err)
+ }
+
+ var stdout bytes.Buffer
+ cfg := pythonsidecar.Config{
+ Module: "path_check",
+ Stdout: &stdout,
+ }
+
+ s := pythonsidecar.New(cfg)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err := s.Start(ctx, customPath); err != nil {
+ t.Fatalf("Start() failed: %v", err)
+ }
+ if err := s.Wait(); err != nil {
+ t.Fatalf("Wait() failed: %v", err)
+ }
+
+ out := stdout.String()
+ if !strings.Contains(out, customPath) {
+ t.Errorf("expected sys.path to contain customPath, got output:\n%s", out)
+ }
+}
diff --git a/internal/pythonsidecar/sidecar.go b/internal/pythonsidecar/sidecar.go
new file mode 100644
index 00000000..d02799ba
--- /dev/null
+++ b/internal/pythonsidecar/sidecar.go
@@ -0,0 +1,269 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package pythonsidecar provides a mechanism to manage the lifecycle
+// of a Python process as a sidecar component in a Go application.
+package pythonsidecar
+
+import (
+ "context"
+ "fmt"
+ "io"
+ "net"
+ "os"
+ "os/exec"
+ "strings"
+ "sync"
+ "syscall"
+ "time"
+)
+
+// Config holds the configuration parameters for the sidecar lifecycle.
+type Config struct {
+ // Module is the Python module name to run using the "-m" flag. (Required)
+ Module string
+ // Args contains any additional arguments to pass to the module. (Optional)
+ Args []string
+ // Stdout redirects the sidecar's standard output. (Optional)
+ Stdout io.Writer
+ // Stderr redirects the sidecar's standard error. (Optional)
+ Stderr io.Writer
+ // ReadyFunc is an optional function to check if the server is ready to accept requests.
+ // When provided, Start will poll ReadyFunc until it returns nil or the context expires. (Optional)
+ ReadyFunc func(ctx context.Context) error
+}
+
+// TODO: Use /var/ax_agy_harness_service for communication instead of TCP.
+
+// Sidecar manages the lifecycle of the underlying Python process.
+type Sidecar struct {
+ cfg Config
+
+ mu sync.Mutex
+ cmd *exec.Cmd
+ running bool
+ stopping bool
+ exitErr error
+ doneChan chan struct{}
+}
+
+// New creates a new Sidecar instance using the provided configuration struct.
+func New(cfg Config) *Sidecar {
+ return &Sidecar{
+ cfg: cfg,
+ }
+}
+
+// Start launches the Python process and monitors its lifecycle in the background.
+// If ReadyFunc is configured, Start blocks until the server is ready or the context expires.
+// If the process fails to start or become ready, an error is returned immediately.
+func (s *Sidecar) Start(ctx context.Context, pythonPath string) error {
+ s.mu.Lock()
+ if s.running {
+ s.mu.Unlock()
+ return fmt.Errorf("sidecar is already running")
+ }
+
+ if s.cfg.Module == "" {
+ s.mu.Unlock()
+ return fmt.Errorf("Module cannot be empty")
+ }
+
+ // Prepare arguments: python -u -m module [args...]
+ // -u forces unbuffered stdout/stderr so logs stream to Go instantly
+ fullArgs := append([]string{"-u", "-m", s.cfg.Module}, s.cfg.Args...)
+
+ cmd := exec.CommandContext(ctx, "python3", fullArgs...)
+ if pythonPath != "" {
+ env := append([]string(nil), os.Environ()...)
+ var found bool
+ for i, kv := range env {
+ if strings.HasPrefix(kv, "PYTHONPATH=") {
+ existing := strings.TrimPrefix(kv, "PYTHONPATH=")
+ if existing != "" {
+ env[i] = "PYTHONPATH=" + pythonPath + string(os.PathListSeparator) + existing
+ } else {
+ env[i] = "PYTHONPATH=" + pythonPath
+ }
+ found = true
+ break
+ }
+ }
+ if !found {
+ env = append(env, "PYTHONPATH="+pythonPath)
+ }
+ cmd.Env = env
+ }
+
+ if s.cfg.Stdout != nil {
+ cmd.Stdout = s.cfg.Stdout
+ }
+ if s.cfg.Stderr != nil {
+ cmd.Stderr = s.cfg.Stderr
+ }
+
+ if err := cmd.Start(); err != nil {
+ s.mu.Unlock()
+ return fmt.Errorf("failed to start python process: %w", err)
+ }
+
+ s.cmd = cmd
+ s.running = true
+ s.stopping = false
+ s.exitErr = nil
+ s.doneChan = make(chan struct{})
+ s.mu.Unlock()
+
+ // Monitor lifecycle asynchronously
+ go s.monitor()
+
+ // If a readiness probe is configured, wait for the server to become ready
+ if s.cfg.ReadyFunc != nil {
+ if err := s.WaitUntilReady(ctx); err != nil {
+ _ = s.Stop()
+ return fmt.Errorf("server failed to become ready: %w", err)
+ }
+ }
+
+ return nil
+}
+
+// monitor waits for the process to exit and records its exit status.
+func (s *Sidecar) monitor() {
+ err := s.cmd.Wait()
+ s.mu.Lock()
+ s.running = false
+ if err != nil && !s.stopping {
+ s.exitErr = fmt.Errorf("python process exited with error: %w", err)
+ } else {
+ s.exitErr = nil
+ }
+ s.mu.Unlock()
+ close(s.doneChan)
+}
+
+// IsRunning returns true if the Python process is currently active.
+func (s *Sidecar) IsRunning() bool {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.running
+}
+
+// Pid returns the process ID of the running sidecar, or 0 if not running.
+func (s *Sidecar) Pid() int {
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ if !s.running || s.cmd == nil || s.cmd.Process == nil {
+ return 0
+ }
+ return s.cmd.Process.Pid
+}
+
+// WaitUntilReady blocks until ReadyFunc returns nil, the context is canceled, or the process exits prematurely.
+// If no ReadyFunc is configured in Config, this method returns nil immediately.
+func (s *Sidecar) WaitUntilReady(ctx context.Context) error {
+ if s.cfg.ReadyFunc == nil {
+ return nil
+ }
+
+ ticker := time.NewTicker(50 * time.Millisecond)
+ defer ticker.Stop()
+
+ for {
+ // 1. Check if the process exited prematurely
+ s.mu.Lock()
+ running := s.running
+ exitErr := s.exitErr
+ s.mu.Unlock()
+ if !running {
+ if exitErr != nil {
+ return fmt.Errorf("process exited before becoming ready: %w", exitErr)
+ }
+ return fmt.Errorf("process exited unexpectedly before becoming ready")
+ }
+
+ // 2. Try the readiness check
+ if err := s.cfg.ReadyFunc(ctx); err == nil {
+ return nil
+ }
+
+ // 3. Wait for the next ticker or context cancellation
+ select {
+ case <-ctx.Done():
+ return ctx.Err()
+ case <-ticker.C:
+ continue
+ }
+ }
+}
+
+// Wait blocks until the sidecar exits or crashes, returning the exit error if any.
+func (s *Sidecar) Wait() error {
+ s.mu.Lock()
+ done := s.doneChan
+ s.mu.Unlock()
+ if done != nil {
+ <-done
+ }
+ s.mu.Lock()
+ defer s.mu.Unlock()
+ return s.exitErr
+}
+
+// Stop gracefully terminates the Python process using SIGTERM, falling back to SIGKILL if necessary.
+func (s *Sidecar) Stop() error {
+ s.mu.Lock()
+ if !s.running || s.cmd == nil || s.cmd.Process == nil {
+ s.mu.Unlock()
+ return nil
+ }
+ s.stopping = true
+ done := s.doneChan
+ s.mu.Unlock()
+
+ // 1. Send graceful SIGTERM
+ if err := s.cmd.Process.Signal(syscall.SIGTERM); err != nil {
+ _ = s.cmd.Process.Kill()
+ if done != nil {
+ <-done
+ }
+ return nil
+ }
+
+ // 2. Give it a small window to exit gracefully before killing it
+ select {
+ case <-done:
+ return nil
+ case <-time.After(3 * time.Second):
+ // Fallback to force kill
+ _ = s.cmd.Process.Kill()
+ if done != nil {
+ <-done
+ }
+ return nil
+ }
+}
+
+// TCPReady returns a ReadyFunc that attempts to establish a TCP connection to addr (e.g., "127.0.0.1:50053").
+func TCPReady(addr string) func(ctx context.Context) error {
+ return func(ctx context.Context) error {
+ var d net.Dialer
+ conn, err := d.DialContext(ctx, "tcp", addr)
+ if err != nil {
+ return err
+ }
+ _ = conn.Close()
+ return nil
+ }
+}
diff --git a/internal/pythonsidecar/sidecar_test.go b/internal/pythonsidecar/sidecar_test.go
new file mode 100644
index 00000000..3052f414
--- /dev/null
+++ b/internal/pythonsidecar/sidecar_test.go
@@ -0,0 +1,158 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package pythonsidecar_test
+
+import (
+ "bytes"
+ "context"
+ "fmt"
+ "net"
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/google/ax/internal/pythonsidecar"
+)
+
+func getFreePort(t *testing.T) int {
+ t.Helper()
+ l, err := net.Listen("tcp", "127.0.0.1:0")
+ if err != nil {
+ t.Fatalf("failed to get free port: %v", err)
+ }
+ port := l.Addr().(*net.TCPAddr).Port
+ _ = l.Close()
+ return port
+}
+
+func TestSidecar_ConfigValidation(t *testing.T) {
+ ctx := context.Background()
+
+ t.Run("empty Module", func(t *testing.T) {
+ s := pythonsidecar.New(pythonsidecar.Config{})
+ err := s.Start(ctx, "")
+ if err == nil || !strings.Contains(err.Error(), "Module cannot be empty") {
+ t.Fatalf("expected error about empty Module, got %v", err)
+ }
+ })
+}
+
+func TestSidecar_ModuleExecution(t *testing.T) {
+ tmpDir := t.TempDir()
+ modulePath := filepath.Join(tmpDir, "test_module.py")
+ moduleContent := `
+import sys
+print("hello stdout")
+print("hello stderr", file=sys.stderr)
+sys.exit(0)
+`
+ if err := os.WriteFile(modulePath, []byte(moduleContent), 0644); err != nil {
+ t.Fatalf("failed to write module: %v", err)
+ }
+
+ var stdout, stderr bytes.Buffer
+ cfg := pythonsidecar.Config{
+ Module: "test_module",
+ Stdout: &stdout,
+ Stderr: &stderr,
+ }
+
+ s := pythonsidecar.New(cfg)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ if err := s.Start(ctx, tmpDir); err != nil {
+ t.Fatalf("Start() failed: %v", err)
+ }
+
+ if err := s.Wait(); err != nil {
+ t.Fatalf("Wait() failed: %v", err)
+ }
+
+ if !strings.Contains(stdout.String(), "hello stdout") {
+ t.Errorf("stdout expected 'hello stdout', got %q", stdout.String())
+ }
+ if !strings.Contains(stderr.String(), "hello stderr") {
+ t.Errorf("stderr expected 'hello stderr', got %q", stderr.String())
+ }
+ if s.IsRunning() {
+ t.Errorf("expected IsRunning() to be false after exit")
+ }
+}
+
+func TestSidecar_ModuleServerWithTCPReady(t *testing.T) {
+ port := getFreePort(t)
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
+
+ cfg := pythonsidecar.Config{
+ Module: "http.server",
+ Args: []string{strconv.Itoa(port), "--bind", "127.0.0.1"},
+ ReadyFunc: pythonsidecar.TCPReady(addr),
+ }
+
+ s := pythonsidecar.New(cfg)
+ ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
+ defer cancel()
+
+ if err := s.Start(ctx, ""); err != nil {
+ t.Fatalf("Start() with TCPReady failed: %v", err)
+ }
+
+ if !s.IsRunning() {
+ t.Fatalf("expected server sidecar to be running")
+ }
+ if s.Pid() <= 0 {
+ t.Fatalf("expected valid PID, got %d", s.Pid())
+ }
+
+ if err := s.Stop(); err != nil {
+ t.Fatalf("Stop() failed: %v", err)
+ }
+}
+
+func TestSidecar_ReadinessFailureOnPrematureExit(t *testing.T) {
+ tmpDir := t.TempDir()
+ modulePath := filepath.Join(tmpDir, "crash.py")
+ if err := os.WriteFile(modulePath, []byte("import sys; sys.exit(1)\n"), 0644); err != nil {
+ t.Fatalf("failed to write script: %v", err)
+ }
+
+ t.Setenv("PYTHONPATH", tmpDir)
+ port := getFreePort(t)
+ addr := fmt.Sprintf("127.0.0.1:%d", port)
+
+ cfg := pythonsidecar.Config{
+ Module: "crash",
+ ReadyFunc: pythonsidecar.TCPReady(addr),
+ }
+
+ s := pythonsidecar.New(cfg)
+ ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
+ defer cancel()
+
+ err := s.Start(ctx, "")
+ if err == nil {
+ t.Fatalf("expected Start() to fail when process exits prematurely")
+ }
+ if !strings.Contains(err.Error(), "exited before becoming ready") {
+ t.Fatalf("expected 'exited before becoming ready' in error, got: %v", err)
+ }
+ if s.IsRunning() {
+ t.Fatalf("expected IsRunning() to be false")
+ }
+}
diff --git a/internal/server/interceptors.go b/internal/server/interceptors.go
new file mode 100644
index 00000000..45d7732e
--- /dev/null
+++ b/internal/server/interceptors.go
@@ -0,0 +1,79 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "context"
+ "log/slog"
+ "time"
+
+ "google.golang.org/grpc"
+)
+
+type conversationer interface {
+ GetConversationId() string
+}
+
+// LoggingInterceptor logs unary RPC details, including latency, errors, and conversation ID if present.
+func LoggingInterceptor(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
+ start := time.Now()
+ logger := slog.With(slog.String("method", info.FullMethod))
+
+ // Extract conversation_id if the request supports it
+ if conv, ok := req.(conversationer); ok {
+ if id := conv.GetConversationId(); id != "" {
+ logger = logger.With(slog.String("conversation_id", id))
+ }
+ }
+
+ logger.InfoContext(ctx, "Handling unary request")
+ resp, err := handler(ctx, req)
+
+ duration := time.Since(start)
+ if err != nil {
+ logger.ErrorContext(ctx, "Request failed",
+ slog.Duration("duration", duration),
+ slog.Any("error", err),
+ )
+ } else {
+ logger.InfoContext(ctx, "Request completed",
+ slog.Duration("duration", duration),
+ )
+ }
+ return resp, err
+}
+
+// StreamLoggingInterceptor logs stream RPC connection details and latency.
+func StreamLoggingInterceptor(srv any, ss grpc.ServerStream, info *grpc.StreamServerInfo, handler grpc.StreamHandler) error {
+ start := time.Now()
+ ctx := ss.Context()
+ logger := slog.With(slog.String("method", info.FullMethod))
+
+ logger.InfoContext(ctx, "Handling stream request")
+ err := handler(srv, ss)
+
+ duration := time.Since(start)
+ if err != nil {
+ logger.ErrorContext(ctx, "Stream failed",
+ slog.Duration("duration", duration),
+ slog.Any("error", err),
+ )
+ } else {
+ logger.InfoContext(ctx, "Stream completed",
+ slog.Duration("duration", duration),
+ )
+ }
+ return err
+}
diff --git a/internal/server/interceptors_test.go b/internal/server/interceptors_test.go
new file mode 100644
index 00000000..0d02e8ee
--- /dev/null
+++ b/internal/server/interceptors_test.go
@@ -0,0 +1,264 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package server
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "io"
+ "log/slog"
+ "net"
+ "strings"
+ "testing"
+
+ "github.com/google/ax/proto"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/credentials/insecure"
+ "google.golang.org/grpc/status"
+ "google.golang.org/grpc/test/bufconn"
+)
+
+// Mock servers for testing interceptors
+type mockConversationServer struct {
+ proto.UnimplementedConversationServiceServer
+}
+
+func (m *mockConversationServer) DeleteConversation(ctx context.Context, req *proto.DeleteConversationRequest) (*proto.DeleteConversationResponse, error) {
+ if req.ConversationId == "fail" {
+ return nil, status.Error(codes.InvalidArgument, "mock error")
+ }
+ return &proto.DeleteConversationResponse{}, nil
+}
+
+type mockExecutionServer struct {
+ proto.UnimplementedExecutionServiceServer
+}
+
+func (m *mockExecutionServer) Exec(req *proto.ExecRequest, stream proto.ExecutionService_ExecServer) error {
+ if req.ConversationId == "fail" {
+ return status.Error(codes.InvalidArgument, "mock error")
+ }
+ return stream.Send(&proto.ExecResponse{})
+}
+
+const bufSize = 1024 * 1024
+
+func setupTestServer(t *testing.T) (*grpc.ClientConn, func()) {
+ lis := bufconn.Listen(bufSize)
+ s := grpc.NewServer(
+ grpc.ChainUnaryInterceptor(LoggingInterceptor),
+ grpc.ChainStreamInterceptor(StreamLoggingInterceptor),
+ )
+
+ proto.RegisterConversationServiceServer(s, &mockConversationServer{})
+ proto.RegisterExecutionServiceServer(s, &mockExecutionServer{})
+
+ go func() {
+ if err := s.Serve(lis); err != nil && err != grpc.ErrServerStopped {
+ t.Errorf("Server exited with error: %v", err)
+ }
+ }()
+
+ conn, err := grpc.NewClient("passthrough:///bufnet",
+ grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) {
+ return lis.Dial()
+ }),
+ grpc.WithTransportCredentials(insecure.NewCredentials()),
+ )
+ if err != nil {
+ t.Fatalf("Failed to dial bufnet: %v", err)
+ }
+
+ cleanup := func() {
+ conn.Close()
+ s.GracefulStop()
+ lis.Close()
+ }
+
+ return conn, cleanup
+}
+
+type logEntry struct {
+ Time string `json:"time"`
+ Level string `json:"level"`
+ Msg string `json:"msg"`
+ Method string `json:"method"`
+ ConversationID string `json:"conversation_id,omitempty"`
+ Duration int64 `json:"duration,omitempty"` // in nanoseconds
+ Error string `json:"error,omitempty"`
+}
+
+func TestLoggingInterceptors(t *testing.T) {
+ conn, cleanup := setupTestServer(t)
+ defer cleanup()
+
+ // Capture slog output to a buffer
+ var logBuf bytes.Buffer
+ testLogger := slog.New(slog.NewJSONHandler(&logBuf, nil))
+ oldLogger := slog.Default()
+ slog.SetDefault(testLogger)
+ defer slog.SetDefault(oldLogger)
+
+ convClient := proto.NewConversationServiceClient(conn)
+ executionClient := proto.NewExecutionServiceClient(conn)
+
+ t.Run("Unary Success", func(t *testing.T) {
+ logBuf.Reset()
+ ctx := context.Background()
+ _, err := convClient.DeleteConversation(ctx, &proto.DeleteConversationRequest{ConversationId: "conv-123"})
+ if err != nil {
+ t.Fatalf("DeleteConversation failed: %v", err)
+ }
+
+ entries := parseLogs(t, &logBuf)
+ if len(entries) != 2 {
+ t.Fatalf("Expected 2 log entries, got %d", len(entries))
+ }
+
+ // Verify Start Log
+ if entries[0].Msg != "Handling unary request" {
+ t.Errorf("Expected start log msg 'Handling unary request', got %q", entries[0].Msg)
+ }
+ if entries[0].Method != "/ax.ConversationService/DeleteConversation" {
+ t.Errorf("Expected method '/ax.ConversationService/DeleteConversation', got %q", entries[0].Method)
+ }
+ if entries[0].ConversationID != "conv-123" {
+ t.Errorf("Expected conversation_id 'conv-123', got %q", entries[0].ConversationID)
+ }
+
+ // Verify End Log
+ if entries[1].Msg != "Request completed" {
+ t.Errorf("Expected end log msg 'Request completed', got %q", entries[1].Msg)
+ }
+ if entries[1].Level != "INFO" {
+ t.Errorf("Expected Level INFO, got %s", entries[1].Level)
+ }
+ })
+
+ t.Run("Unary Failure", func(t *testing.T) {
+ logBuf.Reset()
+ ctx := context.Background()
+ _, err := convClient.DeleteConversation(ctx, &proto.DeleteConversationRequest{ConversationId: "fail"})
+ if err == nil {
+ t.Fatal("Expected DeleteConversation to fail")
+ }
+
+ entries := parseLogs(t, &logBuf)
+ if len(entries) != 2 {
+ t.Fatalf("Expected 2 log entries, got %d", len(entries))
+ }
+
+ // Verify End Log
+ if entries[1].Msg != "Request failed" {
+ t.Errorf("Expected end log msg 'Request failed', got %q", entries[1].Msg)
+ }
+ if entries[1].Level != "ERROR" {
+ t.Errorf("Expected Level ERROR, got %s", entries[1].Level)
+ }
+ if !strings.Contains(entries[1].Error, "mock error") {
+ t.Errorf("Expected error details to contain 'mock error', got %q", entries[1].Error)
+ }
+ })
+
+ t.Run("Stream Success", func(t *testing.T) {
+ logBuf.Reset()
+ ctx := context.Background()
+ stream, err := executionClient.Exec(ctx, &proto.ExecRequest{ConversationId: "conv-456"})
+ if err != nil {
+ t.Fatalf("Exec stream init failed: %v", err)
+ }
+
+ // Consume stream
+ for {
+ _, err := stream.Recv()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ t.Fatalf("Error receiving from stream: %v", err)
+ }
+ }
+
+ entries := parseLogs(t, &logBuf)
+ if len(entries) != 2 {
+ t.Fatalf("Expected 2 log entries, got %d", len(entries))
+ }
+
+ // Verify Start Log
+ if entries[0].Msg != "Handling stream request" {
+ t.Errorf("Expected start log msg 'Handling stream request', got %q", entries[0].Msg)
+ }
+ if entries[0].Method != "/ax.ExecutionService/Exec" {
+ t.Errorf("Expected method '/ax.ExecutionService/Exec', got %q", entries[0].Method)
+ }
+
+ // Verify End Log
+ if entries[1].Msg != "Stream completed" {
+ t.Errorf("Expected end log msg 'Stream completed', got %q", entries[1].Msg)
+ }
+ if entries[1].Level != "INFO" {
+ t.Errorf("Expected Level INFO, got %s", entries[1].Level)
+ }
+ })
+
+ t.Run("Stream Failure", func(t *testing.T) {
+ logBuf.Reset()
+ ctx := context.Background()
+ stream, err := executionClient.Exec(ctx, &proto.ExecRequest{ConversationId: "fail"})
+ if err != nil {
+ t.Fatalf("Exec stream init failed: %v", err)
+ }
+
+ // Consume stream (should fail)
+ _, err = stream.Recv()
+ if err == nil || err == io.EOF {
+ t.Fatal("Expected stream read to fail")
+ }
+
+ entries := parseLogs(t, &logBuf)
+ if len(entries) != 2 {
+ t.Fatalf("Expected 2 log entries, got %d", len(entries))
+ }
+
+ // Verify End Log
+ if entries[1].Msg != "Stream failed" {
+ t.Errorf("Expected end log msg 'Stream failed', got %q", entries[1].Msg)
+ }
+ if entries[1].Level != "ERROR" {
+ t.Errorf("Expected Level ERROR, got %s", entries[1].Level)
+ }
+ if !strings.Contains(entries[1].Error, "mock error") {
+ t.Errorf("Expected error details to contain 'mock error', got %q", entries[1].Error)
+ }
+ })
+}
+
+func parseLogs(t *testing.T, buf *bytes.Buffer) []logEntry {
+ t.Helper()
+ var entries []logEntry
+ decoder := json.NewDecoder(buf)
+ for {
+ var entry logEntry
+ if err := decoder.Decode(&entry); err == io.EOF {
+ break
+ } else if err != nil {
+ t.Fatalf("Failed to decode log JSON: %v. Raw buffer: %s", err, buf.String())
+ }
+ entries = append(entries, entry)
+ }
+ return entries
+}
diff --git a/internal/server/server.go b/internal/server/server.go
index f9d070b2..267460ff 100644
--- a/internal/server/server.go
+++ b/internal/server/server.go
@@ -12,160 +12,139 @@
// See the License for the specific language governing permissions and
// limitations under the License.
-// Package server implements the gRPC server for GARService,
-// exposing session management and agent registration APIs.
+// Package server implements the gRPC server for AXService,
+// exposing execution management and agent registration APIs.
+
package server
import (
"context"
"fmt"
+ "log/slog"
"net"
+ "sync"
+ "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc"
- "google.golang.org/protobuf/types/known/timestamppb"
+ "google.golang.org/grpc/codes"
+ "google.golang.org/grpc/health"
+ "google.golang.org/grpc/health/grpc_health_v1"
+ "google.golang.org/grpc/status"
- "github.com/google/gar/agent"
- "github.com/google/gar/internal/config"
- "github.com/google/gar/internal/controller"
- "github.com/google/gar/proto"
+ "github.com/google/ax/internal/controller"
+ "github.com/google/ax/proto"
)
-// Server implements the GARService gRPC service.
+// Server implements the AXService gRPC service.
type Server struct {
- proto.UnimplementedGARServiceServer
+ proto.UnimplementedExecutionServiceServer
+ proto.UnimplementedConversationServiceServer
controller *controller.Controller
+ grpcServer *grpc.Server
+ inFlight map[string]struct{}
+ inFlightMu sync.Mutex
}
// New creates a new controller server.
func New(c *controller.Controller) *Server {
return &Server{
controller: c,
+ inFlight: make(map[string]struct{}),
}
}
-// TriggerSession triggers a new agentic loop session with streaming responses.
-func (s *Server) TriggerSession(req *proto.TriggerSessionRequest, stream grpc.ServerStreamingServer[proto.TriggerSessionResponse]) error {
- sessionID := req.SessionId
- inputs := req.Inputs
- checkpointID := req.CheckpointId
-
- if sessionID == "" {
- return fmt.Errorf("session_id is required")
- }
+// Exec executes a new agentic task with streaming responses.
+func (s *Server) Exec(req *proto.ExecRequest, stream grpc.ServerStreamingServer[proto.ExecResponse]) error {
+ ctx := stream.Context()
+ slog.InfoContext(ctx, "Executing request",
+ slog.String("request", req.String()),
+ )
- // TODO(jbd): This state update should be sent directly from the controller.
- if err := stream.Send(&proto.TriggerSessionResponse{
- State: proto.State_STATE_RUNNING,
- SessionId: sessionID,
- }); err != nil {
- return err
+ inFlight, cleanup := s.markInFlight(req.ConversationId)
+ if inFlight {
+ return status.Errorf(codes.FailedPrecondition, "conversation %q is already in flight", req.ConversationId)
}
+ defer cleanup()
- // Create output handler to stream outputs back to client
- outputHandler := agent.OutputHandler(func(content *proto.Content) error {
- return stream.Send(&proto.TriggerSessionResponse{
- SessionId: sessionID,
- State: proto.State_STATE_RUNNING,
- Output: content,
- })
+ outputHandler := controller.ExecHandler(func(resp *proto.ExecResponse) error {
+ return stream.Send(resp)
})
-
- if checkpointID == "" {
- return s.controller.TriggerSession(stream.Context(), sessionID, inputs, outputHandler)
- }
- return s.controller.TriggerForkedSession(stream.Context(), sessionID, checkpointID, inputs, outputHandler)
+ return s.controller.Exec(ctx, req, outputHandler)
}
-// GetSession retrieves session details.
-func (s *Server) GetSession(ctx context.Context, req *proto.GetSessionRequest) (*proto.GetSessionResponse, error) {
- if req.SessionId == "" {
- return nil, fmt.Errorf("session_id is required")
- }
-
- // Load session if not already loaded
- session, err := s.controller.LoadSession(ctx, req.SessionId)
- if err != nil {
- return nil, fmt.Errorf("error loading session: %w", err)
- }
- return &proto.GetSessionResponse{
- Session: &proto.SessionInfo{
- State: session.State(),
- ActiveAgents: session.ActiveAgents(),
- CreatedAt: timestamppb.New(session.CreatedAt()),
- UpdatedAt: timestamppb.New(session.UpdatedAt()),
- CheckpointCount: int32(len(session.CheckpointIDs())),
- },
- }, nil
-}
+func (s *Server) DeleteConversation(ctx context.Context, req *proto.DeleteConversationRequest) (*proto.DeleteConversationResponse, error) {
+ slog.InfoContext(ctx, "Deleting conversation...",
+ slog.String("conversation_id", req.ConversationId))
-// RegisterAgent registers a new remote agent with the controller.
-func (s *Server) RegisterAgent(ctx context.Context, req *proto.RegisterAgentRequest) (*proto.RegisterAgentResponse, error) {
- if req.AgentId == "" {
- return nil, fmt.Errorf("agent_id is required")
+ if req.ConversationId == "" {
+ return nil, status.Errorf(codes.InvalidArgument, "conversation_id is required")
}
-
- if req.Address == "" {
- return nil, fmt.Errorf("address is required for remote agents")
+ inFlight, cleanup := s.markInFlight(req.ConversationId)
+ if inFlight {
+ return nil, status.Errorf(codes.FailedPrecondition, "conversation %q is already in flight", req.ConversationId)
}
+ defer cleanup()
- registry := s.controller.Registry()
-
- // All registered agents are remote
- err := registry.RegisterRemote(config.RemoteAgentConfig{
- ID: req.AgentId,
- Name: req.Name,
- Description: req.Description,
- Address: req.Address,
- Metadata: req.Metadata,
- })
- if err != nil {
- return nil, fmt.Errorf("failed to register agent: %w", err)
+ if err := s.controller.Delete(ctx, req.ConversationId); err != nil {
+ return nil, status.Errorf(codes.Internal, "failed to delete conversation: %v", err)
}
-
- return &proto.RegisterAgentResponse{}, nil
+ return &proto.DeleteConversationResponse{}, nil
}
-// UnregisterAgent removes a remote agent from the controller.
-// Local agents cannot be unregistered via this API.
-func (s *Server) UnregisterAgent(ctx context.Context, req *proto.UnregisterAgentRequest) (*proto.UnregisterAgentResponse, error) {
- if req.AgentId == "" {
- return nil, fmt.Errorf("agent_id is required")
+// Serve starts the gRPC server on the specified address.
+func (s *Server) Serve(address string, opts ...grpc.ServerOption) error {
+ lis, err := net.Listen("tcp", address)
+ if err != nil {
+ return fmt.Errorf("failed to listen: %w", err)
}
- registry := s.controller.Registry()
+ opts = append(opts,
+ grpc.ChainUnaryInterceptor(LoggingInterceptor),
+ grpc.ChainStreamInterceptor(StreamLoggingInterceptor),
+ grpc.StatsHandler(otelgrpc.NewServerHandler()),
+ )
- // Check if the agent is local
- info, err := registry.GetInfo(req.AgentId)
- if err != nil {
- return nil, fmt.Errorf("agent not found: %w", err)
- }
+ s.grpcServer = grpc.NewServer(opts...)
+ proto.RegisterExecutionServiceServer(s.grpcServer, s)
+ proto.RegisterConversationServiceServer(s.grpcServer, s)
- if info.Type == controller.AgentTypeLocal {
- return nil, fmt.Errorf("cannot unregister local agents via API")
- }
+ // Register standard gRPC Health Check server.
+ hs := health.NewServer()
+ hs.SetServingStatus("AX", grpc_health_v1.HealthCheckResponse_SERVING)
+ grpc_health_v1.RegisterHealthServer(s.grpcServer, hs)
- if err := registry.Unregister(req.AgentId); err != nil {
- return nil, fmt.Errorf("failed to unregister agent: %w", err)
+ if err := s.grpcServer.Serve(lis); err != nil {
+ return fmt.Errorf("failed to serve: %w", err)
}
-
- return &proto.UnregisterAgentResponse{}, nil
+ return nil
}
-// Serve starts the gRPC server on the specified address.
-func (s *Server) Serve(address string, opts ...grpc.ServerOption) error {
- lis, err := net.Listen("tcp", address)
- if err != nil {
- return fmt.Errorf("failed to listen: %w", err)
+// GracefulStop stops the gRPC server gracefully.
+func (s *Server) GracefulStop() {
+ slog.Info("Stopping server gracefully...")
+ if s.controller != nil {
+ s.controller.Close()
+ }
+ if s.grpcServer != nil {
+ s.grpcServer.GracefulStop()
}
+}
- grpcServer := grpc.NewServer(opts...)
- proto.RegisterGARServiceServer(grpcServer, s)
+func (s *Server) markInFlight(id string) (exists bool, cleanup func()) {
+ s.inFlightMu.Lock()
+ defer s.inFlightMu.Unlock()
- if err := grpcServer.Serve(lis); err != nil {
- return fmt.Errorf("failed to serve: %w", err)
+ _, ok := s.inFlight[id]
+ if ok {
+ return true, func() {}
}
+ s.inFlight[id] = struct{}{}
- return nil
+ return false, func() {
+ s.inFlightMu.Lock()
+ delete(s.inFlight, id)
+ s.inFlightMu.Unlock()
+ }
}
diff --git a/internal/skills/geminienterprise/client.go b/internal/skills/geminienterprise/client.go
new file mode 100644
index 00000000..93e4f726
--- /dev/null
+++ b/internal/skills/geminienterprise/client.go
@@ -0,0 +1,400 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// This file is the internal Gemini Enterprise Skill Registry transport client: it talks to the
+// Vertex AI v1beta1 REST API (ListSkills / GetSkill / GetSkillRevision /
+// skills:retrieve), fetches skill payloads, and safe-unzips them to disk. The
+// public, config-driven orchestration lives in materialize.go.
+
+package geminienterprise
+
+import (
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log"
+ "net/http"
+ "net/url"
+ "os"
+ "path/filepath"
+ "strings"
+ "time"
+
+ "golang.org/x/oauth2"
+ "golang.org/x/oauth2/google"
+)
+
+// cloudPlatformScope is the OAuth2 scope required to call Vertex AI (read path
+// needs roles/aiplatform.viewer).
+const cloudPlatformScope = "https://www.googleapis.com/auth/cloud-platform"
+
+// apiVersion is the Vertex AI API version the Skill Registry is exposed under.
+const apiVersion = "v1beta1"
+
+// defaultHTTPTimeout bounds a single registry HTTP call.
+const defaultHTTPTimeout = 60 * time.Second
+
+// client fetches a selected set of skills from the Gemini Enterprise Skill Registry and
+// writes them into a target directory as agentskills.io skill folders.
+type client struct {
+ baseURL string // .../v1beta1/projects/{project}/locations/{location}/skills
+ http *http.Client
+ ts oauth2.TokenSource
+ caps unzipCaps
+}
+
+// clientOptions configures a client. newClient fills defaults for optional fields.
+type clientOptions struct {
+ // --- Required ---
+
+ // Project is the Google Cloud project that owns the skills.
+ Project string
+ // Location is the registry region, e.g. "us-central1". Selects the Vertex
+ // host (https://{Location}-aiplatform.googleapis.com) unless Endpoint is set.
+ Location string
+
+ // --- Optional (newClient fills defaults) ---
+
+ // Endpoint overrides the API host (scheme+host, no trailing slash), e.g. a
+ // sandbox host or an httptest.Server URL in unit tests.
+ Endpoint string
+ // HTTPClient is the client used for all registry calls. Defaults to
+ // http.Client{Timeout: 60s}.
+ HTTPClient *http.Client
+ // TokenSource provides the OAuth2 bearer token. If nil, ADC is used
+ // (roles/aiplatform.viewer). Set to a static source in tests.
+ TokenSource oauth2.TokenSource
+ // Caps bounds a defensive local unzip of untrusted payloads.
+ Caps unzipCaps
+}
+
+// newClient constructs a client from options. It validates required fields and
+// fills defaults; it performs no network I/O.
+func newClient(opts clientOptions) (*client, error) {
+ if opts.Project == "" {
+ return nil, errors.New("registry: Project is required")
+ }
+ if opts.Location == "" {
+ return nil, errors.New("registry: Location is required")
+ }
+
+ host := opts.Endpoint
+ if host == "" {
+ host = fmt.Sprintf("https://%s-aiplatform.googleapis.com", opts.Location)
+ }
+ host = strings.TrimSuffix(host, "/")
+ baseURL := fmt.Sprintf("%s/%s/projects/%s/locations/%s/skills",
+ host, apiVersion, opts.Project, opts.Location)
+
+ httpClient := opts.HTTPClient
+ if httpClient == nil {
+ httpClient = &http.Client{Timeout: defaultHTTPTimeout}
+ }
+
+ ts := opts.TokenSource
+ if ts == nil {
+ creds, err := google.FindDefaultCredentials(context.Background(), cloudPlatformScope)
+ if err != nil {
+ return nil, fmt.Errorf("registry: finding application default credentials: %w", err)
+ }
+ ts = creds.TokenSource
+ }
+
+ return &client{
+ baseURL: baseURL,
+ http: httpClient,
+ ts: ts,
+ caps: opts.Caps.withDefaults(),
+ }, nil
+}
+
+// selection describes WHICH skills to fetch. Exactly one of the three modes
+// should be set (all / by-id / by-query).
+type selection struct {
+ // All fetches every skill returned by ListSkills for the project/location.
+ All bool
+ // SkillRefs is an explicit allowlist, each optionally pinned to a revision.
+ SkillRefs []skillRef
+ // Query is a semantic search string; the top matches are fetched. TopK bounds
+ // the result count (<=0 means the server default).
+ Query string
+ TopK int
+}
+
+// skillRef identifies a single skill to fetch, optionally pinned.
+type skillRef struct {
+ SkillID string
+ Revision string // empty => latest default revision
+}
+
+// fetchResult reports the outcome of a fetch call. fetch is fail-safe: a skill
+// that cannot be fetched or unzipped is recorded in skipped.
+type fetchResult struct {
+ materialized []MaterializedSkill
+ skipped []skippedSkill
+}
+
+// skippedSkill records a skill that was intentionally not written, and why.
+type skippedSkill struct {
+ skillID string
+ reason error
+}
+
+// fetch resolves the selection, fetches each skill's payload, and safe-unzips it
+// into targetDir as //... targetDir is created if missing.
+//
+// claimed enforces first-wins across the whole operation: a skill id whose
+// (targetDir, id) was already materialized (by this registry's own selection or
+// an earlier registry sharing the dir) is skipped with a warning rather than
+// overwriting the winner. regIdx is the registry's index, for log context.
+//
+// It is fail-safe: individual skill failures are collected in fetchResult.skipped;
+// fetch returns a non-nil error only for whole-operation failures.
+func (c *client) fetch(ctx context.Context, sel selection, targetDir string, claimed *claimSet, regIdx int) (*fetchResult, error) {
+ refs, err := c.resolveSelection(ctx, sel)
+ if err != nil {
+ return nil, fmt.Errorf("registry: resolving selection: %w", err)
+ }
+ if err := os.MkdirAll(targetDir, 0o755); err != nil {
+ return nil, fmt.Errorf("registry: creating target dir %q: %w", targetDir, err)
+ }
+
+ res := &fetchResult{}
+ for _, ref := range refs {
+ // First-wins: skip (with a warning) any id already materialized into this
+ // dir, rather than overwriting it.
+ if won, byRegistry := claimed.claim(targetDir, ref.SkillID, regIdx); !won {
+ log.Printf("skills: registries[%d] skill %q already materialized into %s by registries[%d]; keeping the first, skipping this one",
+ regIdx, ref.SkillID, targetDir, byRegistry)
+ continue
+ }
+ mat, err := c.fetchAndWrite(ctx, ref, targetDir)
+ if err != nil {
+ res.skipped = append(res.skipped, skippedSkill{skillID: ref.SkillID, reason: err})
+ continue
+ }
+ res.materialized = append(res.materialized, *mat)
+ }
+ return res, nil
+}
+
+// resolveSelection turns a selection into a concrete list of skillRefs to fetch.
+func (c *client) resolveSelection(ctx context.Context, sel selection) ([]skillRef, error) {
+ switch {
+ case len(sel.SkillRefs) > 0:
+ return sel.SkillRefs, nil
+ case sel.Query != "":
+ ids, err := c.retrieveSkillIDs(ctx, sel.Query, sel.TopK)
+ if err != nil {
+ return nil, err
+ }
+ return toRefs(ids), nil
+ case sel.All:
+ ids, err := c.listSkillIDs(ctx)
+ if err != nil {
+ return nil, err
+ }
+ return toRefs(ids), nil
+ default:
+ return nil, errors.New("empty selection: set All, SkillRefs, or Query")
+ }
+}
+
+// fetchAndWrite fetches one skill's payload (latest or pinned) and unzips it.
+func (c *client) fetchAndWrite(ctx context.Context, ref skillRef, targetDir string) (*MaterializedSkill, error) {
+ if ref.SkillID == "" {
+ return nil, errors.New("skill ref has empty SkillID")
+ }
+ payloadB64, revision, err := c.fetchPayload(ctx, ref)
+ if err != nil {
+ return nil, err
+ }
+ zipped, err := base64.StdEncoding.DecodeString(payloadB64)
+ if err != nil {
+ return nil, fmt.Errorf("decoding payload: %w", err)
+ }
+ skillDir := filepath.Join(targetDir, ref.SkillID)
+ // Replace any prior materialization of this skill so stale files don't linger.
+ if err := os.RemoveAll(skillDir); err != nil {
+ return nil, fmt.Errorf("clearing %q: %w", skillDir, err)
+ }
+ if err := safeUnzip(zipped, skillDir, c.caps); err != nil {
+ // Leave no partial dir behind on failure.
+ _ = os.RemoveAll(skillDir)
+ return nil, fmt.Errorf("unzipping: %w", err)
+ }
+ return &MaterializedSkill{SkillID: ref.SkillID, Revision: revision, Dir: skillDir}, nil
+}
+
+// fetchPayload returns the base64 zippedFilesystem and the concrete revision id
+// for a skill ref (GetSkill for latest, GetSkillRevision when pinned).
+func (c *client) fetchPayload(ctx context.Context, ref skillRef) (payloadB64, revision string, err error) {
+ if ref.Revision != "" {
+ // GetSkillRevision: payload is nested under "skill".
+ var resp skillRevisionResponse
+ if err := c.getJSON(ctx, c.baseURL+"/"+ref.SkillID+"/revisions/"+ref.Revision, &resp); err != nil {
+ return "", "", err
+ }
+ if resp.Skill.ZippedFilesystem == "" {
+ return "", "", errors.New("GetSkillRevision: empty zippedFilesystem")
+ }
+ return resp.Skill.ZippedFilesystem, ref.Revision, nil
+ }
+ // GetSkill: payload at top level.
+ var resp skillResponse
+ if err := c.getJSON(ctx, c.baseURL+"/"+ref.SkillID, &resp); err != nil {
+ return "", "", err
+ }
+ if resp.ZippedFilesystem == "" {
+ return "", "", errors.New("GetSkill: empty zippedFilesystem")
+ }
+ return resp.ZippedFilesystem, resp.currentRevision(), nil
+}
+
+// listSkillIDs pages through ListSkills and returns all skill ids.
+func (c *client) listSkillIDs(ctx context.Context) ([]string, error) {
+ var ids []string
+ pageToken := ""
+ for {
+ u := c.baseURL
+ if pageToken != "" {
+ u += "?pageToken=" + url.QueryEscape(pageToken)
+ }
+ var resp listSkillsResponse
+ if err := c.getJSON(ctx, u, &resp); err != nil {
+ return nil, err
+ }
+ for _, s := range resp.Skills {
+ if id := s.id(); id != "" {
+ ids = append(ids, id)
+ }
+ }
+ if resp.NextPageToken == "" {
+ return ids, nil
+ }
+ pageToken = resp.NextPageToken
+ }
+}
+
+// retrieveSkillIDs runs semantic search (skills:retrieve) and returns skill ids.
+func (c *client) retrieveSkillIDs(ctx context.Context, query string, topK int) ([]string, error) {
+ u := c.baseURL + ":retrieve?query=" + url.QueryEscape(query)
+ if topK > 0 {
+ u += fmt.Sprintf("&topK=%d", topK)
+ }
+ var resp retrieveSkillsResponse
+ if err := c.getJSON(ctx, u, &resp); err != nil {
+ return nil, err
+ }
+ var ids []string
+ for _, r := range resp.RetrievedSkills {
+ if id := lastSegment(r.SkillName); id != "" {
+ ids = append(ids, id)
+ }
+ }
+ return ids, nil
+}
+
+// getJSON issues an authenticated GET and decodes the JSON body into out.
+func (c *client) getJSON(ctx context.Context, rawURL string, out any) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
+ if err != nil {
+ return err
+ }
+ tok, err := c.ts.Token()
+ if err != nil {
+ return fmt.Errorf("obtaining token: %w", err)
+ }
+ tok.SetAuthHeader(req)
+ req.Header.Set("Content-Type", "application/json")
+
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusOK {
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, truncate(string(body), 512))
+ }
+ if err := json.Unmarshal(body, out); err != nil {
+ return fmt.Errorf("decoding response: %w", err)
+ }
+ return nil
+}
+
+// --- Wire types (AIP-standard shapes; see the design doc appendix). ---
+
+type listSkillsResponse struct {
+ Skills []skillResponse `json:"skills"`
+ NextPageToken string `json:"nextPageToken"`
+}
+
+// retrieveSkillsResponse is a skills:retrieve result. Each hit carries the skill
+// resource name in a flat "skillName" field (not a nested skill object).
+type retrieveSkillsResponse struct {
+ RetrievedSkills []struct {
+ SkillName string `json:"skillName"` // projects/.../skills/{id}
+ } `json:"retrievedSkills"`
+}
+
+// skillResponse is a Skill resource. GetSkill returns the payload at top level.
+type skillResponse struct {
+ Name string `json:"name"` // projects/.../skills/{id}
+ DisplayName string `json:"displayName"`
+ Description string `json:"description"`
+ State string `json:"state"`
+ DefaultRevision string `json:"defaultRevision"`
+ ZippedFilesystem string `json:"zippedFilesystem"`
+}
+
+// skillRevisionResponse is a GetSkillRevision result; the payload is nested
+// under "skill".
+type skillRevisionResponse struct {
+ Name string `json:"name"` // projects/.../skills/{id}/revisions/{rev}
+ Skill skillResponse `json:"skill"`
+}
+
+// id extracts the trailing skill id from a resource name.
+func (s skillResponse) id() string { return lastSegment(s.Name) }
+
+// currentRevision reports the skill's default revision id, if the server provided
+// one (best-effort; may be empty for "latest").
+func (s skillResponse) currentRevision() string { return lastSegment(s.DefaultRevision) }
+
+func lastSegment(resourceName string) string {
+ if resourceName == "" {
+ return ""
+ }
+ parts := strings.Split(resourceName, "/")
+ return parts[len(parts)-1]
+}
+
+func toRefs(ids []string) []skillRef {
+ refs := make([]skillRef, 0, len(ids))
+ for _, id := range ids {
+ refs = append(refs, skillRef{SkillID: id})
+ }
+ return refs
+}
+
+func truncate(s string, n int) string {
+ if len(s) <= n {
+ return s
+ }
+ return s[:n] + "...(truncated)"
+}
diff --git a/internal/skills/geminienterprise/client_test.go b/internal/skills/geminienterprise/client_test.go
new file mode 100644
index 00000000..39285b5c
--- /dev/null
+++ b/internal/skills/geminienterprise/client_test.go
@@ -0,0 +1,414 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package geminienterprise
+
+import (
+ "archive/zip"
+ "bytes"
+ "context"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+
+ "golang.org/x/oauth2"
+)
+
+// zipFixtureB64 builds an in-memory zip from name->content and returns the base64
+// string the registry would return in `zippedFilesystem`.
+func zipFixtureB64(t *testing.T, files map[string]string) string {
+ t.Helper()
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ for name, content := range files {
+ w, err := zw.Create(name)
+ if err != nil {
+ t.Fatalf("zip create %q: %v", name, err)
+ }
+ if _, err := w.Write([]byte(content)); err != nil {
+ t.Fatalf("zip write %q: %v", name, err)
+ }
+ }
+ if err := zw.Close(); err != nil {
+ t.Fatalf("zip close: %v", err)
+ }
+ return base64.StdEncoding.EncodeToString(buf.Bytes())
+}
+
+// rawZipFixture returns the raw zip bytes (for direct safeUnzip tests).
+func rawZipFixture(t *testing.T, files map[string]string) []byte {
+ t.Helper()
+ b, err := base64.StdEncoding.DecodeString(zipFixtureB64(t, files))
+ if err != nil {
+ t.Fatalf("decode fixture: %v", err)
+ }
+ return b
+}
+
+// newFakeClient returns a client pointed at an httptest.Server whose handler is
+// provided by the caller, with a no-op token source.
+func newFakeClient(t *testing.T, handler http.HandlerFunc) *client {
+ t.Helper()
+ srv := httptest.NewServer(handler)
+ t.Cleanup(srv.Close)
+ c, err := newClient(clientOptions{
+ Project: "test-project",
+ Location: "us-central1",
+ Endpoint: srv.URL,
+ TokenSource: oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "fake"}),
+ })
+ if err != nil {
+ t.Fatalf("newClient: %v", err)
+ }
+ return c
+}
+
+func TestFetchByID_Latest(t *testing.T) {
+ payload := zipFixtureB64(t, map[string]string{
+ "SKILL.md": "---\nname: emoji\n---\n# do stuff",
+ "scripts/x.sh": "echo hi",
+ })
+ c := newFakeClient(t, func(w http.ResponseWriter, r *http.Request) {
+ // GetSkill: .../skills/emoji -> zippedFilesystem at top level.
+ if !strings.HasSuffix(r.URL.Path, "/skills/emoji") {
+ http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ writeJSON(t, w, skillResponse{
+ Name: "projects/p/locations/us-central1/skills/emoji",
+ DefaultRevision: "projects/p/locations/us-central1/skills/emoji/revisions/rev-7",
+ ZippedFilesystem: payload,
+ })
+ })
+
+ dir := t.TempDir()
+ res, err := c.fetch(context.Background(),
+ selection{SkillRefs: []skillRef{{SkillID: "emoji"}}}, dir, newClaimSet(), 0)
+ if err != nil {
+ t.Fatalf("fetch: %v", err)
+ }
+ if len(res.skipped) != 0 {
+ t.Fatalf("unexpected skipped: %+v", res.skipped)
+ }
+ if len(res.materialized) != 1 {
+ t.Fatalf("materialized = %d, want 1", len(res.materialized))
+ }
+ got := res.materialized[0]
+ if got.SkillID != "emoji" || got.Revision != "rev-7" {
+ t.Errorf("got %+v, want SkillID=emoji Revision=rev-7", got)
+ }
+ assertFile(t, filepath.Join(dir, "emoji", "SKILL.md"), "---\nname: emoji\n---\n# do stuff")
+ assertFile(t, filepath.Join(dir, "emoji", "scripts", "x.sh"), "echo hi")
+}
+
+func TestFetchByID_PinnedRevision(t *testing.T) {
+ payload := zipFixtureB64(t, map[string]string{"SKILL.md": "pinned"})
+ c := newFakeClient(t, func(w http.ResponseWriter, r *http.Request) {
+ // GetSkillRevision: .../skills/emoji/revisions/rev-3 -> nested under "skill".
+ if !strings.HasSuffix(r.URL.Path, "/skills/emoji/revisions/rev-3") {
+ http.Error(w, "unexpected path "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ writeJSON(t, w, skillRevisionResponse{
+ Name: "projects/p/locations/us-central1/skills/emoji/revisions/rev-3",
+ Skill: skillResponse{ZippedFilesystem: payload},
+ })
+ })
+
+ dir := t.TempDir()
+ res, err := c.fetch(context.Background(),
+ selection{SkillRefs: []skillRef{{SkillID: "emoji", Revision: "rev-3"}}}, dir, newClaimSet(), 0)
+ if err != nil {
+ t.Fatalf("fetch: %v", err)
+ }
+ if len(res.materialized) != 1 || res.materialized[0].Revision != "rev-3" {
+ t.Fatalf("got %+v, want one skill pinned at rev-3", res.materialized)
+ }
+ assertFile(t, filepath.Join(dir, "emoji", "SKILL.md"), "pinned")
+}
+
+func TestFetchAll_Paginated(t *testing.T) {
+ pa := zipFixtureB64(t, map[string]string{"SKILL.md": "a"})
+ pb := zipFixtureB64(t, map[string]string{"SKILL.md": "b"})
+ c := newFakeClient(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/skills") && r.URL.Query().Get("pageToken") == "":
+ writeJSON(t, w, listSkillsResponse{
+ Skills: []skillResponse{{Name: ".../skills/a"}},
+ NextPageToken: "TOK",
+ })
+ case strings.HasSuffix(r.URL.Path, "/skills") && r.URL.Query().Get("pageToken") == "TOK":
+ writeJSON(t, w, listSkillsResponse{Skills: []skillResponse{{Name: ".../skills/b"}}})
+ case strings.HasSuffix(r.URL.Path, "/skills/a"):
+ writeJSON(t, w, skillResponse{Name: ".../skills/a", ZippedFilesystem: pa})
+ case strings.HasSuffix(r.URL.Path, "/skills/b"):
+ writeJSON(t, w, skillResponse{Name: ".../skills/b", ZippedFilesystem: pb})
+ default:
+ http.Error(w, "unexpected "+r.URL.String(), http.StatusNotFound)
+ }
+ })
+
+ dir := t.TempDir()
+ res, err := c.fetch(context.Background(), selection{All: true}, dir, newClaimSet(), 0)
+ if err != nil {
+ t.Fatalf("fetch: %v", err)
+ }
+ if len(res.materialized) != 2 {
+ t.Fatalf("materialized = %d, want 2 (paged)", len(res.materialized))
+ }
+ assertFile(t, filepath.Join(dir, "a", "SKILL.md"), "a")
+ assertFile(t, filepath.Join(dir, "b", "SKILL.md"), "b")
+}
+
+func TestFetchByQuery(t *testing.T) {
+ payload := zipFixtureB64(t, map[string]string{"SKILL.md": "found"})
+ c := newFakeClient(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.Contains(r.URL.Path, "/skills:retrieve"):
+ if got := r.URL.Query().Get("query"); got != "emoji stuff" {
+ t.Errorf("query = %q, want %q", got, "emoji stuff")
+ }
+ writeJSON(t, w, retrieveSkillsResponse{
+ RetrievedSkills: []struct {
+ SkillName string `json:"skillName"`
+ }{{SkillName: ".../skills/emoji"}},
+ })
+ case strings.HasSuffix(r.URL.Path, "/skills/emoji"):
+ writeJSON(t, w, skillResponse{Name: ".../skills/emoji", ZippedFilesystem: payload})
+ default:
+ http.Error(w, "unexpected "+r.URL.String(), http.StatusNotFound)
+ }
+ })
+
+ dir := t.TempDir()
+ res, err := c.fetch(context.Background(),
+ selection{Query: "emoji stuff", TopK: 3}, dir, newClaimSet(), 0)
+ if err != nil {
+ t.Fatalf("fetch: %v", err)
+ }
+ if len(res.materialized) != 1 {
+ t.Fatalf("materialized = %d, want 1", len(res.materialized))
+ }
+ assertFile(t, filepath.Join(dir, "emoji", "SKILL.md"), "found")
+}
+
+func TestFetchFailSafe_OneBadSkillSkipped(t *testing.T) {
+ good := zipFixtureB64(t, map[string]string{"SKILL.md": "ok"})
+ c := newFakeClient(t, func(w http.ResponseWriter, r *http.Request) {
+ switch {
+ case strings.HasSuffix(r.URL.Path, "/skills/good"):
+ writeJSON(t, w, skillResponse{Name: ".../skills/good", ZippedFilesystem: good})
+ case strings.HasSuffix(r.URL.Path, "/skills/bad"):
+ http.Error(w, "boom", http.StatusInternalServerError)
+ default:
+ http.Error(w, "unexpected", http.StatusNotFound)
+ }
+ })
+
+ dir := t.TempDir()
+ res, err := c.fetch(context.Background(),
+ selection{SkillRefs: []skillRef{{SkillID: "good"}, {SkillID: "bad"}}}, dir, newClaimSet(), 0)
+ if err != nil {
+ t.Fatalf("fetch returned whole-op error, want fail-safe: %v", err)
+ }
+ if len(res.materialized) != 1 || res.materialized[0].SkillID != "good" {
+ t.Errorf("materialized = %+v, want only 'good'", res.materialized)
+ }
+ if len(res.skipped) != 1 || res.skipped[0].skillID != "bad" {
+ t.Errorf("skipped = %+v, want only 'bad'", res.skipped)
+ }
+ // The bad skill left no directory behind.
+ if _, err := os.Stat(filepath.Join(dir, "bad")); !os.IsNotExist(err) {
+ t.Errorf("expected no dir for 'bad', stat err = %v", err)
+ }
+}
+
+func TestFetch_FirstWinsAcrossSharedClaimSet(t *testing.T) {
+ // Two fetches (simulating two registries) into the SAME dir with a SHARED
+ // claim set. Both offer skill id "dup"; only the first should be written.
+ payloadA := zipFixtureB64(t, map[string]string{"SKILL.md": "from-A"})
+ payloadB := zipFixtureB64(t, map[string]string{"SKILL.md": "from-B"})
+
+ makeClient := func(payload string) *client {
+ return newFakeClient(t, func(w http.ResponseWriter, r *http.Request) {
+ if !strings.HasSuffix(r.URL.Path, "/skills/dup") {
+ http.Error(w, "unexpected "+r.URL.Path, http.StatusNotFound)
+ return
+ }
+ writeJSON(t, w, skillResponse{Name: ".../skills/dup", ZippedFilesystem: payload})
+ })
+ }
+
+ dir := t.TempDir()
+ claimed := newClaimSet()
+ sel := selection{SkillRefs: []skillRef{{SkillID: "dup"}}}
+
+ // Registry 0 wins.
+ res0, err := makeClient(payloadA).fetch(context.Background(), sel, dir, claimed, 0)
+ if err != nil {
+ t.Fatalf("fetch #0: %v", err)
+ }
+ if len(res0.materialized) != 1 {
+ t.Fatalf("registry 0 materialized %d, want 1", len(res0.materialized))
+ }
+
+ // Registry 1 offers the same id into the same dir -> skipped (not written).
+ res1, err := makeClient(payloadB).fetch(context.Background(), sel, dir, claimed, 1)
+ if err != nil {
+ t.Fatalf("fetch #1: %v", err)
+ }
+ if len(res1.materialized) != 0 {
+ t.Errorf("registry 1 materialized %d, want 0 (first-wins)", len(res1.materialized))
+ }
+ // The content on disk must be the FIRST writer's.
+ assertFile(t, filepath.Join(dir, "dup", "SKILL.md"), "from-A")
+}
+
+func TestNewClientValidation(t *testing.T) {
+ if _, err := newClient(clientOptions{Location: "us-central1"}); err == nil {
+ t.Error("expected error for missing Project")
+ }
+ if _, err := newClient(clientOptions{Project: "p"}); err == nil {
+ t.Error("expected error for missing Location")
+ }
+}
+
+// --- safeUnzip unit tests ---
+
+func TestSafeUnzip_Basic(t *testing.T) {
+ z := rawZipFixture(t, map[string]string{
+ "SKILL.md": "hi",
+ "scripts/tool.sh": "run",
+ "references/a.txt": "ref",
+ })
+ dir := t.TempDir()
+ if err := safeUnzip(z, filepath.Join(dir, "s"), unzipCaps{}.withDefaults()); err != nil {
+ t.Fatalf("safeUnzip: %v", err)
+ }
+ assertFile(t, filepath.Join(dir, "s", "SKILL.md"), "hi")
+ assertFile(t, filepath.Join(dir, "s", "scripts", "tool.sh"), "run")
+}
+
+func TestSafeUnzip_PreservesExecutableMode(t *testing.T) {
+ // Build a zip with an executable script (0755) and a normal file (0644),
+ // setting per-entry modes via CreateHeader.
+ var buf bytes.Buffer
+ zw := zip.NewWriter(&buf)
+ writeEntry := func(name, content string, mode os.FileMode) {
+ hdr := &zip.FileHeader{Name: name, Method: zip.Deflate}
+ hdr.SetMode(mode)
+ w, err := zw.CreateHeader(hdr)
+ if err != nil {
+ t.Fatalf("CreateHeader %q: %v", name, err)
+ }
+ if _, err := w.Write([]byte(content)); err != nil {
+ t.Fatalf("write %q: %v", name, err)
+ }
+ }
+ writeEntry("scripts/tool.sh", "#!/bin/sh\necho hi\n", 0o755)
+ writeEntry("SKILL.md", "hi", 0o644)
+ if err := zw.Close(); err != nil {
+ t.Fatalf("zip close: %v", err)
+ }
+
+ dir := t.TempDir()
+ if err := safeUnzip(buf.Bytes(), filepath.Join(dir, "s"), unzipCaps{}.withDefaults()); err != nil {
+ t.Fatalf("safeUnzip: %v", err)
+ }
+
+ scriptInfo, err := os.Stat(filepath.Join(dir, "s", "scripts", "tool.sh"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if scriptInfo.Mode().Perm()&0o100 == 0 {
+ t.Errorf("script mode = %v, want owner-execute bit set (0755 preserved)", scriptInfo.Mode().Perm())
+ }
+ mdInfo, err := os.Stat(filepath.Join(dir, "s", "SKILL.md"))
+ if err != nil {
+ t.Fatal(err)
+ }
+ if mdInfo.Mode().Perm()&0o111 != 0 {
+ t.Errorf("SKILL.md mode = %v, want no execute bits (0644)", mdInfo.Mode().Perm())
+ }
+}
+
+func TestSafeUnzip_ZipSlipRejected(t *testing.T) {
+ for _, bad := range []string{"../evil.txt", "a/../../evil.txt", "/etc/evil"} {
+ z := rawZipFixture(t, map[string]string{bad: "x"})
+ dir := t.TempDir()
+ err := safeUnzip(z, filepath.Join(dir, "s"), unzipCaps{}.withDefaults())
+ if err == nil {
+ t.Errorf("entry %q: expected rejection, got nil", bad)
+ continue
+ }
+ if _, statErr := os.Stat(filepath.Join(dir, "evil.txt")); !os.IsNotExist(statErr) {
+ t.Errorf("entry %q: file escaped destination", bad)
+ }
+ }
+}
+
+func TestSafeUnzip_TooManyFiles(t *testing.T) {
+ files := map[string]string{}
+ for i := 0; i < 5; i++ {
+ files[fmt.Sprintf("f%d.txt", i)] = "x"
+ }
+ z := rawZipFixture(t, files)
+ err := safeUnzip(z, t.TempDir(), unzipCaps{MaxFiles: 3}.withDefaults())
+ if err == nil || !strings.Contains(err.Error(), "exceeds cap") {
+ t.Fatalf("expected MaxFiles cap error, got %v", err)
+ }
+}
+
+func TestSafeUnzip_TooLarge(t *testing.T) {
+ z := rawZipFixture(t, map[string]string{"big.txt": strings.Repeat("A", 1000)})
+ err := safeUnzip(z, t.TempDir(), unzipCaps{MaxTotalUnzippedBytes: 100}.withDefaults())
+ if err == nil || !strings.Contains(err.Error(), "size") {
+ t.Fatalf("expected size cap error, got %v", err)
+ }
+}
+
+func TestSafeUnzip_TooDeep(t *testing.T) {
+ z := rawZipFixture(t, map[string]string{"a/b/c/d/e/f/g/h/i/j/deep.txt": "x"})
+ err := safeUnzip(z, t.TempDir(), unzipCaps{MaxDepth: 3}.withDefaults())
+ if err == nil || !strings.Contains(err.Error(), "depth") {
+ t.Fatalf("expected depth cap error, got %v", err)
+ }
+}
+
+// --- helpers ---
+
+func writeJSON(t *testing.T, w http.ResponseWriter, v any) {
+ t.Helper()
+ w.Header().Set("Content-Type", "application/json")
+ if err := json.NewEncoder(w).Encode(v); err != nil {
+ t.Fatalf("encode response: %v", err)
+ }
+}
+
+func assertFile(t *testing.T, path, want string) {
+ t.Helper()
+ got, err := os.ReadFile(path)
+ if err != nil {
+ t.Fatalf("reading %q: %v", path, err)
+ }
+ if string(got) != want {
+ t.Errorf("%q = %q, want %q", path, got, want)
+ }
+}
diff --git a/internal/skills/geminienterprise/materialize.go b/internal/skills/geminienterprise/materialize.go
new file mode 100644
index 00000000..9c2f9421
--- /dev/null
+++ b/internal/skills/geminienterprise/materialize.go
@@ -0,0 +1,173 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package geminienterprise turns a harness's skills configuration into on-disk
+// skill folders. It sources agentskills.io skills from the Gemini Enterprise
+// Skill Registry (a managed, versioned catalog exposed over the Vertex AI
+// v1beta1 REST API) and writes each skill to //.
+//
+// It is harness-agnostic: it only writes files and reports what it wrote (see
+// Result). It knows nothing about specific harnesses, SKILLS_DIR, or discovery
+// pointers — callers decide how a given harness is told where its skills are.
+//
+// Scope: read-only. This package never creates, updates, or deletes registry
+// skills (authoring is out of scope).
+package geminienterprise
+
+import (
+ "context"
+ "log"
+ "os"
+ "strings"
+
+ "github.com/google/ax/internal/config"
+)
+
+// Environment fallbacks for project/location (the registry target_dir is a
+// required config field, so it has no env fallback).
+const (
+ envCloudProject = "GOOGLE_CLOUD_PROJECT"
+ envCloudLocation = "GOOGLE_CLOUD_LOCATION"
+
+ defaultRegistryLocation = "us-central1"
+)
+
+// Result reports what Materialize wrote: one Written entry per registry that
+// produced skills, grouping the skills with the directory they landed in.
+type Result struct {
+ Written []Written
+}
+
+// Written groups the skills materialized from one registry with the directory
+// they were written into (each skill at //).
+type Written struct {
+ Dir string
+ Skills []MaterializedSkill
+}
+
+// Empty reports whether nothing was materialized.
+func (r Result) Empty() bool { return len(r.Written) == 0 }
+
+// MaterializedSkill records one skill written to disk.
+type MaterializedSkill struct {
+ SkillID string
+ Revision string
+ Dir string // path of the written skill folder (//)
+}
+
+// Materialize materializes every enabled registry in sc into its configured
+// target_dir (each skill at //) and reports what it wrote.
+//
+// target_dir is a required, validated config field (see config.SkillsConfig), so
+// this does not fall back to any env var or the working directory. It is
+// fail-safe: disabled registries are skipped, and any registry error is logged
+// and swallowed so a skill problem never blocks harness creation. When the same
+// skill id would be written into the same dir more than once (within one
+// registry's selection, or across registries sharing a dir), the FIRST writer
+// wins and later duplicates are skipped with a warning. Substrate/pod
+// materialization is a separate, later path; this wires the local flow only.
+func Materialize(ctx context.Context, sc config.SkillsConfig) Result {
+ var res Result
+ // claimed tracks (target_dir, skill-id) pairs already written across all
+ // registries so the FIRST writer of an id into a dir wins; later duplicates
+ // (within one registry's selection, or across registries sharing a dir) are
+ // skipped with a warning instead of silently overwriting.
+ claimed := newClaimSet()
+ // TODO: fetches are sequential (both here and per-skill in client.fetch);
+ // consider bounded-concurrent download/unzip to speed up materialization.
+ // TODO: bound overall materialization with a deadline (only per-call HTTP
+ // timeouts exist today); e.g. a default ~120s that users can override.
+ for i := range sc.Registries {
+ rc := sc.Registries[i]
+ if !rc.Enabled {
+ continue
+ }
+ project := firstNonEmpty(rc.Project, os.Getenv(envCloudProject))
+ if project == "" {
+ log.Printf("skills: registries[%d] enabled but no project (config or %s); skipping", i, envCloudProject)
+ continue
+ }
+ location := firstNonEmpty(rc.Location, os.Getenv(envCloudLocation), defaultRegistryLocation)
+
+ c, err := newClient(clientOptions{Project: project, Location: location})
+ if err != nil {
+ log.Printf("skills: registries[%d] client init failed: %v; skipping", i, err)
+ continue
+ }
+ out, err := c.fetch(ctx, selectionFromConfig(rc), rc.TargetDir, claimed, i)
+ if err != nil {
+ log.Printf("skills: registries[%d] fetch failed: %v; continuing", i, err)
+ continue
+ }
+ for _, s := range out.skipped {
+ log.Printf("skills: registries[%d] skipped %q: %v", i, s.skillID, s.reason)
+ }
+ if len(out.materialized) == 0 {
+ continue
+ }
+ log.Printf("skills: registries[%d] materialized %d skill(s) into %s (skipped %d)",
+ i, len(out.materialized), rc.TargetDir, len(out.skipped))
+ res.Written = append(res.Written, Written{Dir: rc.TargetDir, Skills: out.materialized})
+ }
+ return res
+}
+
+// claimSet tracks which (dir, skill-id) pairs have already been materialized, so
+// the first writer of a given id into a given dir wins.
+type claimSet struct {
+ seen map[string]int // key -> registry index that first claimed it
+}
+
+func newClaimSet() *claimSet { return &claimSet{seen: map[string]int{}} }
+
+func claimKey(dir, id string) string { return dir + "\x00" + id }
+
+// claim records (dir, id) as owned by registry regIdx and returns true if this
+// is the first claim. If already claimed, it returns false and the index of the
+// registry that won.
+func (c *claimSet) claim(dir, id string, regIdx int) (won bool, byRegistry int) {
+ key := claimKey(dir, id)
+ if prev, ok := c.seen[key]; ok {
+ return false, prev
+ }
+ c.seen[key] = regIdx
+ return true, regIdx
+}
+
+// selectionFromConfig maps a SkillsRegistryConfig to a selection:
+// - explicit Skills list => by-id (with optional revision pin)
+// - else Query => by-query
+// - else => all
+func selectionFromConfig(rc config.SkillsRegistryConfig) selection {
+ if len(rc.Skills) > 0 {
+ refs := make([]skillRef, 0, len(rc.Skills))
+ for _, s := range rc.Skills {
+ refs = append(refs, skillRef{SkillID: s.ID, Revision: s.Revision})
+ }
+ return selection{SkillRefs: refs}
+ }
+ if rc.Query != nil && strings.TrimSpace(rc.Query.Text) != "" {
+ return selection{Query: rc.Query.Text, TopK: rc.Query.TopK}
+ }
+ return selection{All: true}
+}
+
+func firstNonEmpty(vals ...string) string {
+ for _, v := range vals {
+ if v != "" {
+ return v
+ }
+ }
+ return ""
+}
diff --git a/internal/skills/geminienterprise/materialize_test.go b/internal/skills/geminienterprise/materialize_test.go
new file mode 100644
index 00000000..2779a377
--- /dev/null
+++ b/internal/skills/geminienterprise/materialize_test.go
@@ -0,0 +1,100 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package geminienterprise
+
+import (
+ "context"
+ "testing"
+
+ "github.com/google/ax/internal/config"
+)
+
+func TestSelectionFromConfig(t *testing.T) {
+ t.Run("explicit skills take precedence", func(t *testing.T) {
+ rc := config.SkillsRegistryConfig{
+ Skills: []config.SkillRefConfig{{ID: "emoji"}, {ID: "lowercase", Revision: "rev-3"}},
+ Query: &config.SkillsQueryConfig{Text: "ignored"},
+ }
+ sel := selectionFromConfig(rc)
+ if sel.All || sel.Query != "" {
+ t.Fatalf("got %+v, want by-id", sel)
+ }
+ if len(sel.SkillRefs) != 2 ||
+ sel.SkillRefs[0].SkillID != "emoji" || sel.SkillRefs[0].Revision != "" ||
+ sel.SkillRefs[1].SkillID != "lowercase" || sel.SkillRefs[1].Revision != "rev-3" {
+ t.Fatalf("refs = %+v", sel.SkillRefs)
+ }
+ })
+
+ t.Run("query when no explicit skills", func(t *testing.T) {
+ sel := selectionFromConfig(config.SkillsRegistryConfig{
+ Query: &config.SkillsQueryConfig{Text: "find gcp", TopK: 5},
+ })
+ if sel.Query != "find gcp" || sel.TopK != 5 {
+ t.Fatalf("got %+v, want query=find gcp topK=5", sel)
+ }
+ })
+
+ t.Run("empty query text falls through to all", func(t *testing.T) {
+ sel := selectionFromConfig(config.SkillsRegistryConfig{Query: &config.SkillsQueryConfig{Text: " "}})
+ if !sel.All {
+ t.Fatalf("got %+v, want All (blank query text)", sel)
+ }
+ })
+
+ t.Run("all when nothing set", func(t *testing.T) {
+ if sel := selectionFromConfig(config.SkillsRegistryConfig{}); !sel.All {
+ t.Fatalf("got %+v, want All", sel)
+ }
+ })
+}
+
+func TestClaimSet_FirstWins(t *testing.T) {
+ cs := newClaimSet()
+ if won, by := cs.claim("/dir", "s1", 0); !won || by != 0 {
+ t.Fatalf("first claim = (%v,%d), want (true,0)", won, by)
+ }
+ // Same (dir,id) again -> loses, reports the first registry (0).
+ if won, by := cs.claim("/dir", "s1", 2); won || by != 0 {
+ t.Fatalf("dup claim = (%v,%d), want (false,0)", won, by)
+ }
+ // Same id, different dir -> independent, wins.
+ if won, _ := cs.claim("/other", "s1", 1); !won {
+ t.Fatal("same id in different dir should win")
+ }
+ // Different id, same dir -> wins.
+ if won, _ := cs.claim("/dir", "s2", 1); !won {
+ t.Fatal("different id in same dir should win")
+ }
+}
+
+func TestMaterialize_DisabledIsEmpty(t *testing.T) {
+ // Disabled config => no-op, empty result, no error/panic.
+ if res := Materialize(context.Background(), config.SkillsConfig{}); !res.Empty() {
+ t.Errorf("disabled Materialize = %+v, want empty", res)
+ }
+}
+
+func TestMaterialize_EnabledNoProjectIsEmpty(t *testing.T) {
+ // Enabled with a target_dir but no project (config empty, GOOGLE_CLOUD_PROJECT
+ // unset) => fail-safe empty result (no panic, no materialization).
+ t.Setenv(envCloudProject, "")
+ sc := config.SkillsConfig{Registries: []config.SkillsRegistryConfig{
+ {Enabled: true, TargetDir: t.TempDir()},
+ }}
+ if res := Materialize(context.Background(), sc); !res.Empty() {
+ t.Errorf("no-project Materialize = %+v, want empty", res)
+ }
+}
diff --git a/internal/skills/geminienterprise/unzip.go b/internal/skills/geminienterprise/unzip.go
new file mode 100644
index 00000000..df1046b9
--- /dev/null
+++ b/internal/skills/geminienterprise/unzip.go
@@ -0,0 +1,202 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package geminienterprise
+
+import (
+ "archive/zip"
+ "bytes"
+ "fmt"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+)
+
+// unzipCaps bounds a defensive local unzip of an untrusted skill payload. Even
+// though the registry validates payloads server-side, the local unzip must
+// independently guard against Zip-Slip (entries with "..", absolute paths, or
+// symlinks) and resource exhaustion.
+type unzipCaps struct {
+ // MaxFiles caps the number of entries per skill archive.
+ MaxFiles int
+ // MaxTotalUnzippedBytes caps the total unzipped size per skill archive.
+ MaxTotalUnzippedBytes int64
+ // MaxDepth caps directory nesting depth within a skill archive.
+ MaxDepth int
+}
+
+// The registry's documented payload caps, used as defaults.
+const (
+ defaultMaxFiles = 10_000
+ defaultMaxTotalBytes = 500 << 20 // 500 MiB
+ defaultMaxDepth = 8
+)
+
+// withDefaults fills any zero-valued cap with the registry's documented limit.
+func (c unzipCaps) withDefaults() unzipCaps {
+ if c.MaxFiles <= 0 {
+ c.MaxFiles = defaultMaxFiles
+ }
+ if c.MaxTotalUnzippedBytes <= 0 {
+ c.MaxTotalUnzippedBytes = defaultMaxTotalBytes
+ }
+ if c.MaxDepth <= 0 {
+ c.MaxDepth = defaultMaxDepth
+ }
+ return c
+}
+
+// safeUnzip extracts a zip archive into destDir, defensively rejecting unsafe
+// entries and enforcing caps. destDir must not exist yet or must be safe to
+// write into; callers clear it beforehand.
+//
+// Guards:
+// - Zip-Slip: every entry must resolve to a path inside destDir.
+// - Absolute paths and paths containing ".." are rejected.
+// - Symlinks (and any non-regular, non-dir mode) are rejected.
+// - MaxFiles / MaxDepth / MaxTotalUnzippedBytes are enforced; the running
+// total is checked while copying so a lying uncompressed-size can't be used
+// to blow past the cap.
+func safeUnzip(archive []byte, destDir string, caps unzipCaps) error {
+ zr, err := zip.NewReader(bytes.NewReader(archive), int64(len(archive)))
+ if err != nil {
+ return fmt.Errorf("opening zip: %w", err)
+ }
+ if len(zr.File) > caps.MaxFiles {
+ return fmt.Errorf("archive has %d entries, exceeds cap %d", len(zr.File), caps.MaxFiles)
+ }
+
+ // Resolve destDir to an absolute, clean base for containment checks.
+ absDest, err := filepath.Abs(destDir)
+ if err != nil {
+ return fmt.Errorf("resolving dest: %w", err)
+ }
+ if err := os.MkdirAll(absDest, 0o755); err != nil {
+ return fmt.Errorf("creating dest: %w", err)
+ }
+
+ var total int64
+ for _, f := range zr.File {
+ if err := validateEntryName(f.Name, caps.MaxDepth); err != nil {
+ return err
+ }
+ // Reject symlinks and any special modes; only dirs and regular files.
+ mode := f.Mode()
+ if mode&os.ModeSymlink != 0 {
+ return fmt.Errorf("entry %q is a symlink (rejected)", f.Name)
+ }
+ if !mode.IsDir() && !mode.IsRegular() {
+ return fmt.Errorf("entry %q has unsupported mode %v (rejected)", f.Name, mode)
+ }
+
+ target := filepath.Join(absDest, filepath.FromSlash(f.Name))
+ // Containment: target must be within absDest.
+ if !withinBase(absDest, target) {
+ return fmt.Errorf("entry %q escapes destination (zip-slip)", f.Name)
+ }
+
+ if f.FileInfo().IsDir() {
+ if err := os.MkdirAll(target, 0o755); err != nil {
+ return fmt.Errorf("creating dir %q: %w", f.Name, err)
+ }
+ continue
+ }
+
+ if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
+ return fmt.Errorf("creating parent of %q: %w", f.Name, err)
+ }
+ written, err := writeCappedFile(f, target, caps.MaxTotalUnzippedBytes-total)
+ if err != nil {
+ return err
+ }
+ total += written
+ if total > caps.MaxTotalUnzippedBytes {
+ return fmt.Errorf("archive exceeds unzipped size cap %d bytes", caps.MaxTotalUnzippedBytes)
+ }
+ }
+ return nil
+}
+
+// validateEntryName rejects absolute paths, "..", and over-deep nesting.
+func validateEntryName(name string, maxDepth int) error {
+ if name == "" {
+ return fmt.Errorf("empty entry name")
+ }
+ // Reject Windows-style and POSIX absolute paths.
+ if filepath.IsAbs(name) || strings.HasPrefix(name, "/") || strings.HasPrefix(name, `\`) {
+ return fmt.Errorf("entry %q is an absolute path (rejected)", name)
+ }
+ slashed := strings.ReplaceAll(name, `\`, "/")
+ for _, seg := range strings.Split(slashed, "/") {
+ if seg == ".." {
+ return fmt.Errorf("entry %q contains '..' (rejected)", name)
+ }
+ }
+ depth := 0
+ for _, seg := range strings.Split(strings.Trim(slashed, "/"), "/") {
+ if seg != "" && seg != "." {
+ depth++
+ }
+ }
+ if depth > maxDepth {
+ return fmt.Errorf("entry %q nesting depth %d exceeds cap %d", name, depth, maxDepth)
+ }
+ return nil
+}
+
+// withinBase reports whether target is inside base (after cleaning).
+func withinBase(base, target string) bool {
+ rel, err := filepath.Rel(base, target)
+ if err != nil {
+ return false
+ }
+ return rel != ".." && !strings.HasPrefix(rel, ".."+string(os.PathSeparator))
+}
+
+// writeCappedFile copies one zip entry to disk, refusing to write more than
+// remaining bytes (so a mismatched declared size can't overrun the cap).
+func writeCappedFile(f *zip.File, target string, remaining int64) (int64, error) {
+ if remaining < 0 {
+ return 0, fmt.Errorf("unzipped size cap exceeded before %q", f.Name)
+ }
+ rc, err := f.Open()
+ if err != nil {
+ return 0, fmt.Errorf("opening entry %q: %w", f.Name, err)
+ }
+ defer rc.Close()
+
+ // Preserve the archive entry's permission bits so executable skill scripts
+ // (e.g. under scripts/) stay executable after extraction. Chmod after create
+ // so the mode sticks regardless of the process umask.
+ perm := f.Mode().Perm()
+ out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, perm)
+ if err != nil {
+ return 0, fmt.Errorf("creating file %q: %w", f.Name, err)
+ }
+ defer out.Close()
+ if err := out.Chmod(perm); err != nil {
+ return 0, fmt.Errorf("setting mode on %q: %w", f.Name, err)
+ }
+
+ // Limit the copy to remaining+1 so we can detect overrun deterministically.
+ n, err := io.Copy(out, io.LimitReader(rc, remaining+1))
+ if err != nil {
+ return n, fmt.Errorf("writing %q: %w", f.Name, err)
+ }
+ if n > remaining {
+ return n, fmt.Errorf("entry %q exceeds remaining unzipped size budget", f.Name)
+ }
+ return n, nil
+}
diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go
new file mode 100644
index 00000000..ac0da9ea
--- /dev/null
+++ b/internal/telemetry/telemetry.go
@@ -0,0 +1,106 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package telemetry
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "cloud.google.com/go/compute/metadata"
+ "go.opentelemetry.io/otel"
+ "go.opentelemetry.io/otel/attribute"
+ "go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
+ "go.opentelemetry.io/otel/propagation"
+ "go.opentelemetry.io/otel/sdk/resource"
+ sdktrace "go.opentelemetry.io/otel/sdk/trace"
+ semconv "go.opentelemetry.io/otel/semconv/v1.26.0"
+ oauth2google "golang.org/x/oauth2/google"
+ "google.golang.org/grpc"
+ "google.golang.org/grpc/credentials/google"
+)
+
+const defaultEndpoint = "telemetry.googleapis.com"
+
+// WithGCPCredentials returns an option that configures the OTLP exporter to use Google Cloud credentials.
+func WithGCPCredentials() otlptracegrpc.Option {
+ bundle := google.NewDefaultCredentials()
+ return otlptracegrpc.WithDialOption(
+ grpc.WithTransportCredentials(bundle.TransportCredentials()),
+ grpc.WithPerRPCCredentials(bundle.PerRPCCredentials()),
+ )
+}
+
+// SetTraceProvider initializes the OpenTelemetry SDK.
+// It returns a shutdown function that should be called when the application exits.
+func SetTraceProvider(ctx context.Context, service string, opts ...otlptracegrpc.Option) (func(context.Context) error, error) {
+ // 1. Set global propagator. This is crucial for context propagation over gRPC/HTTP.
+ otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
+ propagation.TraceContext{},
+ propagation.Baggage{},
+ ))
+
+ // 2. Create OTLP Exporter.
+ exporter, err := otlptracegrpc.New(ctx, opts...)
+ if err != nil {
+ return nil, fmt.Errorf("failed to create OTLP trace exporter: %w", err)
+ }
+
+ // 3. Define Resource.
+ attrs := []attribute.KeyValue{
+ semconv.ServiceNameKey.String(service),
+ }
+ if projectID := detectGCPProjectID(ctx); projectID != "" {
+ attrs = append(attrs, attribute.String("gcp.project_id", projectID))
+ }
+
+ res, err := resource.New(ctx,
+ resource.WithAttributes(attrs...),
+ resource.WithProcess(),
+ resource.WithTelemetrySDK(),
+ )
+ if err != nil {
+ return nil, fmt.Errorf("failed to create resource: %w", err)
+ }
+
+ // 4. Create TracerProvider.
+ bsp := sdktrace.NewBatchSpanProcessor(exporter)
+ tp := sdktrace.NewTracerProvider(
+ sdktrace.WithSampler(sdktrace.AlwaysSample()),
+ sdktrace.WithResource(res),
+ sdktrace.WithSpanProcessor(bsp),
+ )
+
+ otel.SetTracerProvider(tp)
+
+ return func(shutdownCtx context.Context) error {
+ return tp.Shutdown(shutdownCtx)
+ }, nil
+}
+
+func detectGCPProjectID(ctx context.Context) string {
+ if proj := os.Getenv("GOOGLE_CLOUD_PROJECT"); proj != "" {
+ return proj
+ }
+ if metadata.OnGCEWithContext(ctx) {
+ if proj, err := metadata.ProjectIDWithContext(ctx); err == nil {
+ return proj
+ }
+ }
+ if creds, err := oauth2google.FindDefaultCredentials(ctx); err == nil && creds.ProjectID != "" {
+ return creds.ProjectID
+ }
+ return ""
+}
diff --git a/manifests/README.md b/manifests/README.md
new file mode 100644
index 00000000..fe3678de
--- /dev/null
+++ b/manifests/README.md
@@ -0,0 +1,193 @@
+# AX Harness Deployment on Kubernetes
+
+> [!WARNING]
+>
+> This path is experimental and incomplete: the manifests, scripts, and
+> runtime behavior will change and may break without notice.
+
+This directory contains Kubernetes manifests and configurations to deploy
+and verify the AX on Kubernetes using Agent Substrate.
+
+The target Kubernetes cluster is assumed to have
+[Agent Substrate](https://github.com/agent-substrate/substrate) installed.
+
+---
+
+## 🚀 Deploying to Agent Substrate
+
+### 1. Build and Deploy
+
+> [!NOTE]
+> Do not manually edit `manifests/ax-deployment.yaml`. The installation script automatically injects your `${GEMINI_API_KEY}`, `${AX_SNAPSHOTS_BUCKET}`, and the built `${AX_IMAGE}`, `${AX_ANTIGRAVITY_IMAGE}`, and `${ATEOM_IMAGE}` references during deployment.
+
+The installation script builds the required images and applies the resolved
+manifests to your cluster:
+
+- the **ax** image (the Go `ax` binary only), built from the `ax`
+ target of `cmd/ax/Dockerfile`; used by the ax-server and the Go interactions
+ harness,
+- the **ax-antigravity** image (the Antigravity Python
+ sidecar: SDK, localharness, agent), built from the `antigravity` target of
+ `cmd/ax/Dockerfile`; used by the antigravity harness actor,
+- the **ateom-gvisor** worker image, built with `ko` from the `go.mod` pinned
+ substrate module.
+
+#### Build prerequisites
+
+The ax-antigravity image bundles the antigravity SDK, installed from PyPI at
+build time. Both images target the cluster's **linux/amd64**
+nodes and are built with `--platform linux/amd64`.
+
+You also need a container engine to build and push the ax images. The script
+auto-detects one (preferring a **running** docker, then podman); force a choice
+with `CONTAINER_ENGINE=docker` or `CONTAINER_ENGINE=podman`:
+
+- **Docker** — Docker Desktop (macOS; cross-builds linux/amd64 via emulation) or
+ Docker Engine (Linux; native).
+- **Podman** — on macOS, start a machine first with `podman machine init &&
+ podman machine start` (cross-builds linux/amd64 via emulation); on Linux it
+ runs natively (podman/buildah >= 4.0).
+
+#### Registry authentication
+
+`GOOGLE_CLOUD_PROJECT` sets `AX_IMAGE_REPO=gcr.io/$GOOGLE_CLOUD_PROJECT`. The deploy pushes three
+images — the **ax** and **ax-antigravity** images (via your container engine) and
+the **ateom** image (via `ko`) — and all authenticate through the gcloud
+credential helper:
+
+```bash
+gcloud auth login # authenticate gcloud
+gcloud auth configure-docker # set up the gcr.io credential helper
+```
+
+#### Deploy
+
+The event log is stored in Postgres. By default ax-server connects to an
+**existing** Postgres that you provide via the `AX_EVENTLOG_DSN` env var (bring your own database). Pass `--deploy-postgres` to also
+create a **bundled** Postgres in-cluster instead (for testing).
+
+```bash
+export GOOGLE_CLOUD_PROJECT="ax-substrate" # Your GCP project ID
+export GEMINI_API_KEY="your-api-key"
+export AX_SNAPSHOTS_BUCKET="snapshot-substrate-test-$GOOGLE_CLOUD_PROJECT"
+
+# Connect to your existing Postgres:
+export AX_EVENTLOG_DSN="postgres://user:pass@host:5432/db?sslmode=require"
+./hack/install-ax.sh --deploy-ax-server
+
+# Or deploy a bundled Postgres for testing:
+./hack/install-ax.sh --deploy-ax-server --deploy-postgres
+```
+
+The bundled Postgres uses an auto-generated password. To get its DSN:
+
+```bash
+kubectl get secret ax-eventlog-postgres -n ax -o go-template='{{.data.dsn | base64decode}}'
+```
+
+#### Vertex AI access for the Antigravity Interactions harness
+
+> [!NOTE]
+> You may skip this if you only use the default harness.
+
+The Antigravity **Interactions** harness (`ax harness antigravity-interactions`,
+ActorTemplate `ax-harness-interactions-template`) calls the Vertex AI GenAI API
+and authenticates with the actor's Google Cloud credentials — unlike the default
+Antigravity harness, which uses `GEMINI_API_KEY`.
+
+The worker pods (WorkerPool `ax-harness-workerpool`, namespace `ax`) have no GSA
+annotation, so with Workload Identity the actor authenticates **directly as the
+Kubernetes ServiceAccount principal** `ax/default`. Grant that principal
+`roles/aiplatform.user` on the project the harness reads from `GOOGLE_CLOUD_PROJECT`, or
+Vertex calls fail with `PermissionDenied (403)`. IAM changes can take a minute or two to propagate.
+
+```bash
+PROJECT_NUMBER="$(gcloud projects describe "${GOOGLE_CLOUD_PROJECT}" --format='value(projectNumber)')"
+
+gcloud projects add-iam-policy-binding "${GOOGLE_CLOUD_PROJECT}" \
+ --role=roles/aiplatform.user \
+ --member="principal://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/${GOOGLE_CLOUD_PROJECT}.svc.id.goog/subject/ns/ax/sa/default" \
+ --condition=None
+```
+
+### 2. Port-Forward Services
+
+```bash
+# Port-forward the ax-server ReplicaSet
+kubectl port-forward -n ax rs/ax-server 8494:8494
+```
+
+### 3. Test End-to-End
+
+Run an execution targeting the port-forwarded server.
+
+```bash
+ax exec --server=localhost:8494 --input="hello, who are you?"
+```
+
+The server should respond with something like:
+```text
+Conversation: fb344a18-3720-4c4f-8a6e-2ce34db975b3
+
+⏺ hello, who are you?
+
+I am a helpful assistant. How can I help you today?
+```
+*The request is served by the antigravity harness actor running on Substrate.*
+
+## 🧹 How to Uninstall
+
+To remove the AX server and its components, run:
+
+```bash
+./hack/install-ax.sh --delete-ax-server
+```
+
+> [!NOTE]
+> The event-log database is preserved by default. If you want to
+> delete everything including the data, after the command above, be careful and
+> run:
+>
+> ```bash
+> kubectl delete namespace ax
+> ```
+
+---
+
+## 🛠️ Inspection & Diagnostics
+
+Use the **`kubectl ate`** CLI tool to inspect the live states of
+active actors and allocated standby worker pool instances:
+
+```bash
+kubectl ate get actors -a ax
+
+kubectl ate get workers
+```
+
+List the pods running in the `ax` namespace:
+
+```bash
+# Add `-o wide` to see node/IP assignments, or `-w` to watch status changes.
+kubectl get pods -n ax
+```
+
+## Substrate compatibility
+
+AX pins [Agent Substrate](https://github.com/agent-substrate/substrate) in
+`go.mod`, and the **ateom** worker image is built from that pinned version. The
+cluster's substrate **CRDs and control plane** must be compatible with the
+manifest AX applies.
+
+When installing substrate, keep three things aligned: the ax `go.mod` pin = your
+local substrate checkout = the cluster's installed substrate.
+
+```bash
+# Get AX's pinned substrate commit:
+commit=$(go list -m -f '{{.Version}}' github.com/agent-substrate/substrate | sed 's/.*-//')
+echo "$commit" # e.g. fe93d160a1df
+
+# Check it out on a normal branch in your substrate clone (avoids a detached HEAD):
+git -C fetch origin
+git -C switch -C ax-pinned "$commit"
+```
diff --git a/manifests/ax-deployment.yaml b/manifests/ax-deployment.yaml
new file mode 100644
index 00000000..fda42868
--- /dev/null
+++ b/manifests/ax-deployment.yaml
@@ -0,0 +1,154 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# TODO(jbd): This yaml will eventually become the ax-deployment.yaml.tmpl.
+
+# ---------------------------------------------------------------------------
+# Namespaces
+# ---------------------------------------------------------------------------
+# Namespace "ax" is reserved for built-in harnesses. Users who bring their own
+# harness images should add their own namespaces.
+apiVersion: v1
+kind: Namespace
+metadata:
+ name: ax
+---
+# ---------------------------------------------------------------------------
+# Built-in harnesses
+# ---------------------------------------------------------------------------
+apiVersion: ate.dev/v1alpha1
+kind: WorkerPool
+metadata:
+ name: ax-harness-workerpool
+ namespace: ax
+ labels:
+ workload: ax-harness
+spec:
+ replicas: 5
+ ateomImage: ${ATEOM_IMAGE}
+---
+apiVersion: ate.dev/v1alpha1
+kind: ActorTemplate
+metadata:
+ name: ax-harness-antigravity-template
+ namespace: ax
+spec:
+ workerSelector:
+ matchLabels:
+ workload: ax-harness
+ pauseImage: "gcr.io/gke-release/pause@sha256:bcbd57ba5653580ec647b16d8163cdd1112df3609129b01f912a8032e48265da"
+ containers:
+ - name: "axantigravity"
+ image: ${AX_ANTIGRAVITY_IMAGE}
+ # Substrate ignores the image CMD/ENV/WORKDIR, so the harness command and
+ # environment are specified here. `ax harness antigravity` forks the
+ # Antigravity Python sidecar, which serves on port 80 because substrate
+ # DNATs workerPodIP:80.
+ command: ["/ax-app/ax", "harness", "antigravity",
+ "--host", "0.0.0.0", "--port", "80"]
+ env:
+ - name: GEMINI_API_KEY
+ value: "${GEMINI_API_KEY}"
+ - name: PYTHONPATH
+ value: "/ax-app:/ax-app/python"
+ - name: PYTHONUNBUFFERED
+ value: "1"
+ - name: SKILLS_DIR
+ value: "/ax/skills"
+ - name: AX_HARNESS_WORKDIR
+ value: "/workspace"
+ readyz:
+ httpGet:
+ path: /readyz
+ port: 8081
+ snapshotsConfig:
+ location: gs://${AX_SNAPSHOTS_BUCKET}/axantigravity/
+---
+apiVersion: ate.dev/v1alpha1
+kind: ActorTemplate
+metadata:
+ name: ax-harness-interactions-template
+ namespace: ax
+spec:
+ workerSelector:
+ matchLabels:
+ workload: ax-harness
+ pauseImage: "gcr.io/gke-release/pause@sha256:bcbd57ba5653580ec647b16d8163cdd1112df3609129b01f912a8032e48265da"
+ containers:
+ - name: "axinteractions"
+ image: ${AX_IMAGE}
+ # Substrate ignores the image CMD/ENV/WORKDIR, so the harness command and
+ # environment are specified here. `ax harness antigravity-interactions` runs
+ # the Go Interactions harness on port 80 because substrate DNATs workerPodIP:80.
+ command: ["/ax-app/ax", "harness", "antigravity-interactions",
+ "--host", "0.0.0.0", "--port", "80"]
+ env:
+ - name: GOOGLE_CLOUD_PROJECT
+ value: "${GOOGLE_CLOUD_PROJECT}"
+ - name: AX_HARNESS_WORKDIR
+ value: "/workspace"
+ # Content of manifests/ax.yaml
+ - name: AX_CONFIG_CONTENT
+ value: "${AX_CONFIG_CONTENT}"
+ readyz:
+ httpGet:
+ path: /readyz
+ port: 8081
+ snapshotsConfig:
+ location: gs://${AX_SNAPSHOTS_BUCKET}/axinteractions/
+---
+
+# ---------------------------------------------------------------------------
+# AX Server
+# ---------------------------------------------------------------------------
+apiVersion: apps/v1
+kind: ReplicaSet
+metadata:
+ name: ax-server
+ namespace: ax
+ labels:
+ app: ax-server
+spec:
+ replicas: 3
+ selector:
+ matchLabels:
+ app: ax-server
+ template:
+ metadata:
+ labels:
+ app: ax-server
+ spec:
+ containers:
+ - name: ax-server
+ image: ${AX_IMAGE}
+ command: ["/ax-app/ax", "serve", "--config", "/etc/ax/ax.yaml"]
+ ports:
+ - containerPort: 8494
+ env:
+ - name: GEMINI_API_KEY
+ value: "${GEMINI_API_KEY}"
+ - name: AX_SUBSTRATE
+ value: "1"
+ - name: AX_EVENTLOG_DSN
+ valueFrom:
+ secretKeyRef:
+ name: ax-eventlog-postgres
+ key: dsn
+ volumeMounts:
+ - name: ax-config
+ mountPath: /etc/ax
+ volumes:
+ - name: ax-config
+ configMap:
+ name: ax-server-config
diff --git a/manifests/ax-postgres.yaml b/manifests/ax-postgres.yaml
new file mode 100644
index 00000000..3009e2e2
--- /dev/null
+++ b/manifests/ax-postgres.yaml
@@ -0,0 +1,87 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# ---------------------------------------------------------------------------
+# Event log storage (PostgreSQL)
+#
+# Applied by hack/install-ax.sh only when --deploy-postgres is passed. By default
+# ax-server connects to an existing Postgres via AX_EVENTLOG_DSN and these
+# resources are not deployed. The ax-eventlog-postgres Secret (holding the
+# password used below and the DSN) is created by install-ax.sh.
+# ---------------------------------------------------------------------------
+apiVersion: v1
+kind: Service
+metadata:
+ name: ax-eventlog-postgres
+ namespace: ax
+ labels:
+ app: ax-eventlog-postgres
+spec:
+ selector:
+ app: ax-eventlog-postgres
+ ports:
+ - port: 5432
+ targetPort: 5432
+---
+apiVersion: apps/v1
+kind: StatefulSet
+metadata:
+ name: ax-eventlog-postgres
+ namespace: ax
+ labels:
+ app: ax-eventlog-postgres
+spec:
+ serviceName: ax-eventlog-postgres
+ replicas: 1
+ selector:
+ matchLabels:
+ app: ax-eventlog-postgres
+ template:
+ metadata:
+ labels:
+ app: ax-eventlog-postgres
+ spec:
+ containers:
+ - name: postgres
+ image: postgres:16
+ ports:
+ - containerPort: 5432
+ env:
+ - name: POSTGRES_DB
+ value: axeventlog
+ - name: POSTGRES_USER
+ value: axuser
+ - name: POSTGRES_PASSWORD
+ valueFrom:
+ secretKeyRef:
+ name: ax-eventlog-postgres
+ key: password
+ - name: PGDATA
+ value: /var/lib/postgresql/data/pgdata
+ volumeMounts:
+ - name: data
+ mountPath: /var/lib/postgresql/data
+ readinessProbe:
+ exec:
+ command: ["pg_isready", "-U", "axuser", "-d", "axeventlog"]
+ initialDelaySeconds: 5
+ periodSeconds: 5
+ volumeClaimTemplates:
+ - metadata:
+ name: data
+ spec:
+ accessModes: ["ReadWriteOnce"]
+ resources:
+ requests:
+ storage: 10Gi
diff --git a/manifests/ax.yaml b/manifests/ax.yaml
new file mode 100644
index 00000000..8c243fef
--- /dev/null
+++ b/manifests/ax.yaml
@@ -0,0 +1,30 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Deploy configuration for AX on Agent Substrate. install-ax.sh renders this
+# single source into two places:
+# - the ax-server-config ConfigMap (mounted at /etc/ax/ax.yaml), read by the
+# controller (`ax serve`); and
+# - the AX_CONFIG_CONTENT env (base64 of this file) on each ActorTemplate,
+# so a substrate actor can read its config.
+version: v1alpha
+
+eventlog:
+ postgres:
+ dsn: ${AX_EVENTLOG_DSN}
+
+harnesses:
+ antigravity:
+ default: true
+ antigravity-interactions: {}
diff --git a/proto/ax.pb.go b/proto/ax.pb.go
new file mode 100644
index 00000000..94b1ffe2
--- /dev/null
+++ b/proto/ax.pb.go
@@ -0,0 +1,1112 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.11
+// protoc v5.28.2
+// source: proto/ax.proto
+
+package proto
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ structpb "google.golang.org/protobuf/types/known/structpb"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// State represents the state of an execution
+type State int32
+
+const (
+ State_STATE_UNSPECIFIED State = 0 // Unspecified state
+ State_STATE_PENDING State = 1
+ State_STATE_FAILED State = 2
+ State_STATE_COMPLETED State = 3
+ State_STATE_CANCELED State = 4
+)
+
+// Enum value maps for State.
+var (
+ State_name = map[int32]string{
+ 0: "STATE_UNSPECIFIED",
+ 1: "STATE_PENDING",
+ 2: "STATE_FAILED",
+ 3: "STATE_COMPLETED",
+ 4: "STATE_CANCELED",
+ }
+ State_value = map[string]int32{
+ "STATE_UNSPECIFIED": 0,
+ "STATE_PENDING": 1,
+ "STATE_FAILED": 2,
+ "STATE_COMPLETED": 3,
+ "STATE_CANCELED": 4,
+ }
+)
+
+func (x State) Enum() *State {
+ p := new(State)
+ *p = x
+ return p
+}
+
+func (x State) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (State) Descriptor() protoreflect.EnumDescriptor {
+ return file_proto_ax_proto_enumTypes[0].Descriptor()
+}
+
+func (State) Type() protoreflect.EnumType {
+ return &file_proto_ax_proto_enumTypes[0]
+}
+
+func (x State) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use State.Descriptor instead.
+func (State) EnumDescriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{0}
+}
+
+// CancelReason explains why an in-flight execution is canceled.
+type CancelReason int32
+
+const (
+ CancelReason_CANCEL_REASON_UNSPECIFIED CancelReason = 0
+ CancelReason_CANCEL_REASON_USER_REQUESTED CancelReason = 1
+ CancelReason_CANCEL_REASON_TIMEOUT CancelReason = 2
+ CancelReason_CANCEL_REASON_INTERNAL_ERROR CancelReason = 3
+)
+
+// Enum value maps for CancelReason.
+var (
+ CancelReason_name = map[int32]string{
+ 0: "CANCEL_REASON_UNSPECIFIED",
+ 1: "CANCEL_REASON_USER_REQUESTED",
+ 2: "CANCEL_REASON_TIMEOUT",
+ 3: "CANCEL_REASON_INTERNAL_ERROR",
+ }
+ CancelReason_value = map[string]int32{
+ "CANCEL_REASON_UNSPECIFIED": 0,
+ "CANCEL_REASON_USER_REQUESTED": 1,
+ "CANCEL_REASON_TIMEOUT": 2,
+ "CANCEL_REASON_INTERNAL_ERROR": 3,
+ }
+)
+
+func (x CancelReason) Enum() *CancelReason {
+ p := new(CancelReason)
+ *p = x
+ return p
+}
+
+func (x CancelReason) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (CancelReason) Descriptor() protoreflect.EnumDescriptor {
+ return file_proto_ax_proto_enumTypes[1].Descriptor()
+}
+
+func (CancelReason) Type() protoreflect.EnumType {
+ return &file_proto_ax_proto_enumTypes[1]
+}
+
+func (x CancelReason) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use CancelReason.Descriptor instead.
+func (CancelReason) EnumDescriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{1}
+}
+
+// Message is a message in the history.
+type Message struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Role string `protobuf:"bytes,1,opt,name=role,proto3" json:"role,omitempty"` // user, assistant, or model
+ Content *Content `protobuf:"bytes,2,opt,name=content,proto3" json:"content,omitempty"` // content of the message
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Message) Reset() {
+ *x = Message{}
+ mi := &file_proto_ax_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Message) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Message) ProtoMessage() {}
+
+func (x *Message) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Message.ProtoReflect.Descriptor instead.
+func (*Message) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *Message) GetRole() string {
+ if x != nil {
+ return x.Role
+ }
+ return ""
+}
+
+func (x *Message) GetContent() *Content {
+ if x != nil {
+ return x.Content
+ }
+ return nil
+}
+
+// A conversation is the historical session that consist of
+// a number of execution. A conversation cannot be continued
+// before the last execution is completed or failed.
+type ConversationEvent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"`
+ Seq int32 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"`
+ ExecId string `protobuf:"bytes,3,opt,name=exec_id,json=execId,proto3" json:"exec_id,omitempty"`
+ HarnessId string `protobuf:"bytes,4,opt,name=harness_id,json=harnessId,proto3" json:"harness_id,omitempty"`
+ HarnessConfig *structpb.Struct `protobuf:"bytes,5,opt,name=harness_config,json=harnessConfig,proto3" json:"harness_config,omitempty"`
+ Messages []*Message `protobuf:"bytes,6,rep,name=messages,proto3" json:"messages,omitempty"`
+ State State `protobuf:"varint,7,opt,name=state,proto3,enum=ax.State" json:"state,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ConversationEvent) Reset() {
+ *x = ConversationEvent{}
+ mi := &file_proto_ax_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ConversationEvent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ConversationEvent) ProtoMessage() {}
+
+func (x *ConversationEvent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ConversationEvent.ProtoReflect.Descriptor instead.
+func (*ConversationEvent) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ConversationEvent) GetConversationId() string {
+ if x != nil {
+ return x.ConversationId
+ }
+ return ""
+}
+
+func (x *ConversationEvent) GetSeq() int32 {
+ if x != nil {
+ return x.Seq
+ }
+ return 0
+}
+
+func (x *ConversationEvent) GetExecId() string {
+ if x != nil {
+ return x.ExecId
+ }
+ return ""
+}
+
+func (x *ConversationEvent) GetHarnessId() string {
+ if x != nil {
+ return x.HarnessId
+ }
+ return ""
+}
+
+func (x *ConversationEvent) GetHarnessConfig() *structpb.Struct {
+ if x != nil {
+ return x.HarnessConfig
+ }
+ return nil
+}
+
+func (x *ConversationEvent) GetMessages() []*Message {
+ if x != nil {
+ return x.Messages
+ }
+ return nil
+}
+
+func (x *ConversationEvent) GetState() State {
+ if x != nil {
+ return x.State
+ }
+ return State_STATE_UNSPECIFIED
+}
+
+type HarnessStart struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Per-execution harness configuration.
+ HarnessConfig []byte `protobuf:"bytes,1,opt,name=harness_config,json=harnessConfig,proto3" json:"harness_config,omitempty"`
+ Messages []*Message `protobuf:"bytes,2,rep,name=messages,proto3" json:"messages,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *HarnessStart) Reset() {
+ *x = HarnessStart{}
+ mi := &file_proto_ax_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *HarnessStart) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HarnessStart) ProtoMessage() {}
+
+func (x *HarnessStart) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use HarnessStart.ProtoReflect.Descriptor instead.
+func (*HarnessStart) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *HarnessStart) GetHarnessConfig() []byte {
+ if x != nil {
+ return x.HarnessConfig
+ }
+ return nil
+}
+
+func (x *HarnessStart) GetMessages() []*Message {
+ if x != nil {
+ return x.Messages
+ }
+ return nil
+}
+
+// HarnessCancel aborts the in-flight harness execution.
+type HarnessCancel struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Reason CancelReason `protobuf:"varint,1,opt,name=reason,proto3,enum=ax.CancelReason" json:"reason,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *HarnessCancel) Reset() {
+ *x = HarnessCancel{}
+ mi := &file_proto_ax_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *HarnessCancel) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HarnessCancel) ProtoMessage() {}
+
+func (x *HarnessCancel) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[3]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use HarnessCancel.ProtoReflect.Descriptor instead.
+func (*HarnessCancel) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *HarnessCancel) GetReason() CancelReason {
+ if x != nil {
+ return x.Reason
+ }
+ return CancelReason_CANCEL_REASON_UNSPECIFIED
+}
+
+type HarnessRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"`
+ HarnessId string `protobuf:"bytes,2,opt,name=harness_id,json=harnessId,proto3" json:"harness_id,omitempty"`
+ // Types that are valid to be assigned to Type:
+ //
+ // *HarnessRequest_Start
+ // *HarnessRequest_Cancel
+ Type isHarnessRequest_Type `protobuf_oneof:"type"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *HarnessRequest) Reset() {
+ *x = HarnessRequest{}
+ mi := &file_proto_ax_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *HarnessRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HarnessRequest) ProtoMessage() {}
+
+func (x *HarnessRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[4]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use HarnessRequest.ProtoReflect.Descriptor instead.
+func (*HarnessRequest) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *HarnessRequest) GetConversationId() string {
+ if x != nil {
+ return x.ConversationId
+ }
+ return ""
+}
+
+func (x *HarnessRequest) GetHarnessId() string {
+ if x != nil {
+ return x.HarnessId
+ }
+ return ""
+}
+
+func (x *HarnessRequest) GetType() isHarnessRequest_Type {
+ if x != nil {
+ return x.Type
+ }
+ return nil
+}
+
+func (x *HarnessRequest) GetStart() *HarnessStart {
+ if x != nil {
+ if x, ok := x.Type.(*HarnessRequest_Start); ok {
+ return x.Start
+ }
+ }
+ return nil
+}
+
+func (x *HarnessRequest) GetCancel() *HarnessCancel {
+ if x != nil {
+ if x, ok := x.Type.(*HarnessRequest_Cancel); ok {
+ return x.Cancel
+ }
+ }
+ return nil
+}
+
+type isHarnessRequest_Type interface {
+ isHarnessRequest_Type()
+}
+
+type HarnessRequest_Start struct {
+ Start *HarnessStart `protobuf:"bytes,3,opt,name=start,proto3,oneof"`
+}
+
+type HarnessRequest_Cancel struct {
+ Cancel *HarnessCancel `protobuf:"bytes,4,opt,name=cancel,proto3,oneof"`
+}
+
+func (*HarnessRequest_Start) isHarnessRequest_Type() {}
+
+func (*HarnessRequest_Cancel) isHarnessRequest_Type() {}
+
+type HarnessOutputs struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Messages []*Message `protobuf:"bytes,1,rep,name=messages,proto3" json:"messages,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *HarnessOutputs) Reset() {
+ *x = HarnessOutputs{}
+ mi := &file_proto_ax_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *HarnessOutputs) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HarnessOutputs) ProtoMessage() {}
+
+func (x *HarnessOutputs) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[5]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use HarnessOutputs.ProtoReflect.Descriptor instead.
+func (*HarnessOutputs) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *HarnessOutputs) GetMessages() []*Message {
+ if x != nil {
+ return x.Messages
+ }
+ return nil
+}
+
+type Error struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // A status code, corresponds to google.golang.org/grpc/codes.
+ Code int32 `protobuf:"varint,1,opt,name=code,proto3" json:"code,omitempty"`
+ // A description of the error.
+ Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Error) Reset() {
+ *x = Error{}
+ mi := &file_proto_ax_proto_msgTypes[6]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Error) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Error) ProtoMessage() {}
+
+func (x *Error) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[6]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Error.ProtoReflect.Descriptor instead.
+func (*Error) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *Error) GetCode() int32 {
+ if x != nil {
+ return x.Code
+ }
+ return 0
+}
+
+func (x *Error) GetDescription() string {
+ if x != nil {
+ return x.Description
+ }
+ return ""
+}
+
+type HarnessEnd struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Terminal state for the harness execution.
+ State State `protobuf:"varint,1,opt,name=state,proto3,enum=ax.State" json:"state,omitempty"`
+ // Optional error details.
+ Error *Error `protobuf:"bytes,2,opt,name=error,proto3" json:"error,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *HarnessEnd) Reset() {
+ *x = HarnessEnd{}
+ mi := &file_proto_ax_proto_msgTypes[7]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *HarnessEnd) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HarnessEnd) ProtoMessage() {}
+
+func (x *HarnessEnd) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[7]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use HarnessEnd.ProtoReflect.Descriptor instead.
+func (*HarnessEnd) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *HarnessEnd) GetState() State {
+ if x != nil {
+ return x.State
+ }
+ return State_STATE_UNSPECIFIED
+}
+
+func (x *HarnessEnd) GetError() *Error {
+ if x != nil {
+ return x.Error
+ }
+ return nil
+}
+
+type HarnessResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"`
+ // Types that are valid to be assigned to Type:
+ //
+ // *HarnessResponse_Outputs
+ // *HarnessResponse_End
+ Type isHarnessResponse_Type `protobuf_oneof:"type"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *HarnessResponse) Reset() {
+ *x = HarnessResponse{}
+ mi := &file_proto_ax_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *HarnessResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*HarnessResponse) ProtoMessage() {}
+
+func (x *HarnessResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[8]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use HarnessResponse.ProtoReflect.Descriptor instead.
+func (*HarnessResponse) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *HarnessResponse) GetConversationId() string {
+ if x != nil {
+ return x.ConversationId
+ }
+ return ""
+}
+
+func (x *HarnessResponse) GetType() isHarnessResponse_Type {
+ if x != nil {
+ return x.Type
+ }
+ return nil
+}
+
+func (x *HarnessResponse) GetOutputs() *HarnessOutputs {
+ if x != nil {
+ if x, ok := x.Type.(*HarnessResponse_Outputs); ok {
+ return x.Outputs
+ }
+ }
+ return nil
+}
+
+func (x *HarnessResponse) GetEnd() *HarnessEnd {
+ if x != nil {
+ if x, ok := x.Type.(*HarnessResponse_End); ok {
+ return x.End
+ }
+ }
+ return nil
+}
+
+type isHarnessResponse_Type interface {
+ isHarnessResponse_Type()
+}
+
+type HarnessResponse_Outputs struct {
+ Outputs *HarnessOutputs `protobuf:"bytes,2,opt,name=outputs,proto3,oneof"`
+}
+
+type HarnessResponse_End struct {
+ End *HarnessEnd `protobuf:"bytes,3,opt,name=end,proto3,oneof"`
+}
+
+func (*HarnessResponse_Outputs) isHarnessResponse_Type() {}
+
+func (*HarnessResponse_End) isHarnessResponse_Type() {}
+
+// ExecRequest for executing.
+type ExecRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"` // Unique conversation identifier
+ Inputs []*Message `protobuf:"bytes,2,rep,name=inputs,proto3" json:"inputs,omitempty"` // New inputs
+ LastSeq int32 `protobuf:"varint,3,opt,name=last_seq,json=lastSeq,proto3" json:"last_seq,omitempty"` // Last sequence number seen by the client
+ HarnessId string `protobuf:"bytes,4,opt,name=harness_id,json=harnessId,proto3" json:"harness_id,omitempty"` // Harness ID, empty selects the default harness
+ HarnessConfig []byte `protobuf:"bytes,5,opt,name=harness_config,json=harnessConfig,proto3" json:"harness_config,omitempty"` // Per-request harness configuration (opaque JSON), if any
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ExecRequest) Reset() {
+ *x = ExecRequest{}
+ mi := &file_proto_ax_proto_msgTypes[9]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ExecRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ExecRequest) ProtoMessage() {}
+
+func (x *ExecRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[9]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ExecRequest.ProtoReflect.Descriptor instead.
+func (*ExecRequest) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *ExecRequest) GetConversationId() string {
+ if x != nil {
+ return x.ConversationId
+ }
+ return ""
+}
+
+func (x *ExecRequest) GetInputs() []*Message {
+ if x != nil {
+ return x.Inputs
+ }
+ return nil
+}
+
+func (x *ExecRequest) GetLastSeq() int32 {
+ if x != nil {
+ return x.LastSeq
+ }
+ return 0
+}
+
+func (x *ExecRequest) GetHarnessId() string {
+ if x != nil {
+ return x.HarnessId
+ }
+ return ""
+}
+
+func (x *ExecRequest) GetHarnessConfig() []byte {
+ if x != nil {
+ return x.HarnessConfig
+ }
+ return nil
+}
+
+// ExecResponse contains the result of an execution.
+type ExecResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Outputs []*Message `protobuf:"bytes,1,rep,name=outputs,proto3" json:"outputs,omitempty"` // Output content
+ Seq int32 `protobuf:"varint,2,opt,name=seq,proto3" json:"seq,omitempty"` // Seq of the outputs
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ExecResponse) Reset() {
+ *x = ExecResponse{}
+ mi := &file_proto_ax_proto_msgTypes[10]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ExecResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ExecResponse) ProtoMessage() {}
+
+func (x *ExecResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[10]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ExecResponse.ProtoReflect.Descriptor instead.
+func (*ExecResponse) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *ExecResponse) GetOutputs() []*Message {
+ if x != nil {
+ return x.Outputs
+ }
+ return nil
+}
+
+func (x *ExecResponse) GetSeq() int32 {
+ if x != nil {
+ return x.Seq
+ }
+ return 0
+}
+
+type DeleteConversationRequest struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ ConversationId string `protobuf:"bytes,1,opt,name=conversation_id,json=conversationId,proto3" json:"conversation_id,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DeleteConversationRequest) Reset() {
+ *x = DeleteConversationRequest{}
+ mi := &file_proto_ax_proto_msgTypes[11]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DeleteConversationRequest) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DeleteConversationRequest) ProtoMessage() {}
+
+func (x *DeleteConversationRequest) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[11]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DeleteConversationRequest.ProtoReflect.Descriptor instead.
+func (*DeleteConversationRequest) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{11}
+}
+
+func (x *DeleteConversationRequest) GetConversationId() string {
+ if x != nil {
+ return x.ConversationId
+ }
+ return ""
+}
+
+type DeleteConversationResponse struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DeleteConversationResponse) Reset() {
+ *x = DeleteConversationResponse{}
+ mi := &file_proto_ax_proto_msgTypes[12]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DeleteConversationResponse) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DeleteConversationResponse) ProtoMessage() {}
+
+func (x *DeleteConversationResponse) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_ax_proto_msgTypes[12]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DeleteConversationResponse.ProtoReflect.Descriptor instead.
+func (*DeleteConversationResponse) Descriptor() ([]byte, []int) {
+ return file_proto_ax_proto_rawDescGZIP(), []int{12}
+}
+
+var File_proto_ax_proto protoreflect.FileDescriptor
+
+const file_proto_ax_proto_rawDesc = "" +
+ "\n" +
+ "\x0eproto/ax.proto\x12\x02ax\x1a\x1cgoogle/protobuf/struct.proto\x1a\x13proto/content.proto\"D\n" +
+ "\aMessage\x12\x12\n" +
+ "\x04role\x18\x01 \x01(\tR\x04role\x12%\n" +
+ "\acontent\x18\x02 \x01(\v2\v.ax.ContentR\acontent\"\x90\x02\n" +
+ "\x11ConversationEvent\x12'\n" +
+ "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x10\n" +
+ "\x03seq\x18\x02 \x01(\x05R\x03seq\x12\x17\n" +
+ "\aexec_id\x18\x03 \x01(\tR\x06execId\x12\x1d\n" +
+ "\n" +
+ "harness_id\x18\x04 \x01(\tR\tharnessId\x12>\n" +
+ "\x0eharness_config\x18\x05 \x01(\v2\x17.google.protobuf.StructR\rharnessConfig\x12'\n" +
+ "\bmessages\x18\x06 \x03(\v2\v.ax.MessageR\bmessages\x12\x1f\n" +
+ "\x05state\x18\a \x01(\x0e2\t.ax.StateR\x05state\"^\n" +
+ "\fHarnessStart\x12%\n" +
+ "\x0eharness_config\x18\x01 \x01(\fR\rharnessConfig\x12'\n" +
+ "\bmessages\x18\x02 \x03(\v2\v.ax.MessageR\bmessages\"9\n" +
+ "\rHarnessCancel\x12(\n" +
+ "\x06reason\x18\x01 \x01(\x0e2\x10.ax.CancelReasonR\x06reason\"\xb7\x01\n" +
+ "\x0eHarnessRequest\x12'\n" +
+ "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12\x1d\n" +
+ "\n" +
+ "harness_id\x18\x02 \x01(\tR\tharnessId\x12(\n" +
+ "\x05start\x18\x03 \x01(\v2\x10.ax.HarnessStartH\x00R\x05start\x12+\n" +
+ "\x06cancel\x18\x04 \x01(\v2\x11.ax.HarnessCancelH\x00R\x06cancelB\x06\n" +
+ "\x04type\"9\n" +
+ "\x0eHarnessOutputs\x12'\n" +
+ "\bmessages\x18\x01 \x03(\v2\v.ax.MessageR\bmessages\"=\n" +
+ "\x05Error\x12\x12\n" +
+ "\x04code\x18\x01 \x01(\x05R\x04code\x12 \n" +
+ "\vdescription\x18\x02 \x01(\tR\vdescription\"N\n" +
+ "\n" +
+ "HarnessEnd\x12\x1f\n" +
+ "\x05state\x18\x01 \x01(\x0e2\t.ax.StateR\x05state\x12\x1f\n" +
+ "\x05error\x18\x02 \x01(\v2\t.ax.ErrorR\x05error\"\x96\x01\n" +
+ "\x0fHarnessResponse\x12'\n" +
+ "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12.\n" +
+ "\aoutputs\x18\x02 \x01(\v2\x12.ax.HarnessOutputsH\x00R\aoutputs\x12\"\n" +
+ "\x03end\x18\x03 \x01(\v2\x0e.ax.HarnessEndH\x00R\x03endB\x06\n" +
+ "\x04type\"\xbc\x01\n" +
+ "\vExecRequest\x12'\n" +
+ "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\x12#\n" +
+ "\x06inputs\x18\x02 \x03(\v2\v.ax.MessageR\x06inputs\x12\x19\n" +
+ "\blast_seq\x18\x03 \x01(\x05R\alastSeq\x12\x1d\n" +
+ "\n" +
+ "harness_id\x18\x04 \x01(\tR\tharnessId\x12%\n" +
+ "\x0eharness_config\x18\x05 \x01(\fR\rharnessConfig\"G\n" +
+ "\fExecResponse\x12%\n" +
+ "\aoutputs\x18\x01 \x03(\v2\v.ax.MessageR\aoutputs\x12\x10\n" +
+ "\x03seq\x18\x02 \x01(\x05R\x03seq\"D\n" +
+ "\x19DeleteConversationRequest\x12'\n" +
+ "\x0fconversation_id\x18\x01 \x01(\tR\x0econversationId\"\x1c\n" +
+ "\x1aDeleteConversationResponse*l\n" +
+ "\x05State\x12\x15\n" +
+ "\x11STATE_UNSPECIFIED\x10\x00\x12\x11\n" +
+ "\rSTATE_PENDING\x10\x01\x12\x10\n" +
+ "\fSTATE_FAILED\x10\x02\x12\x13\n" +
+ "\x0fSTATE_COMPLETED\x10\x03\x12\x12\n" +
+ "\x0eSTATE_CANCELED\x10\x04*\x8c\x01\n" +
+ "\fCancelReason\x12\x1d\n" +
+ "\x19CANCEL_REASON_UNSPECIFIED\x10\x00\x12 \n" +
+ "\x1cCANCEL_REASON_USER_REQUESTED\x10\x01\x12\x19\n" +
+ "\x15CANCEL_REASON_TIMEOUT\x10\x02\x12 \n" +
+ "\x1cCANCEL_REASON_INTERNAL_ERROR\x10\x032H\n" +
+ "\x0eHarnessService\x126\n" +
+ "\aConnect\x12\x12.ax.HarnessRequest\x1a\x13.ax.HarnessResponse(\x010\x012?\n" +
+ "\x10ExecutionService\x12+\n" +
+ "\x04Exec\x12\x0f.ax.ExecRequest\x1a\x10.ax.ExecResponse0\x012j\n" +
+ "\x13ConversationService\x12S\n" +
+ "\x12DeleteConversation\x12\x1d.ax.DeleteConversationRequest\x1a\x1e.ax.DeleteConversationResponseB\x1cZ\x1agithub.com/google/ax/protob\x06proto3"
+
+var (
+ file_proto_ax_proto_rawDescOnce sync.Once
+ file_proto_ax_proto_rawDescData []byte
+)
+
+func file_proto_ax_proto_rawDescGZIP() []byte {
+ file_proto_ax_proto_rawDescOnce.Do(func() {
+ file_proto_ax_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_ax_proto_rawDesc), len(file_proto_ax_proto_rawDesc)))
+ })
+ return file_proto_ax_proto_rawDescData
+}
+
+var file_proto_ax_proto_enumTypes = make([]protoimpl.EnumInfo, 2)
+var file_proto_ax_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
+var file_proto_ax_proto_goTypes = []any{
+ (State)(0), // 0: ax.State
+ (CancelReason)(0), // 1: ax.CancelReason
+ (*Message)(nil), // 2: ax.Message
+ (*ConversationEvent)(nil), // 3: ax.ConversationEvent
+ (*HarnessStart)(nil), // 4: ax.HarnessStart
+ (*HarnessCancel)(nil), // 5: ax.HarnessCancel
+ (*HarnessRequest)(nil), // 6: ax.HarnessRequest
+ (*HarnessOutputs)(nil), // 7: ax.HarnessOutputs
+ (*Error)(nil), // 8: ax.Error
+ (*HarnessEnd)(nil), // 9: ax.HarnessEnd
+ (*HarnessResponse)(nil), // 10: ax.HarnessResponse
+ (*ExecRequest)(nil), // 11: ax.ExecRequest
+ (*ExecResponse)(nil), // 12: ax.ExecResponse
+ (*DeleteConversationRequest)(nil), // 13: ax.DeleteConversationRequest
+ (*DeleteConversationResponse)(nil), // 14: ax.DeleteConversationResponse
+ (*Content)(nil), // 15: ax.Content
+ (*structpb.Struct)(nil), // 16: google.protobuf.Struct
+}
+var file_proto_ax_proto_depIdxs = []int32{
+ 15, // 0: ax.Message.content:type_name -> ax.Content
+ 16, // 1: ax.ConversationEvent.harness_config:type_name -> google.protobuf.Struct
+ 2, // 2: ax.ConversationEvent.messages:type_name -> ax.Message
+ 0, // 3: ax.ConversationEvent.state:type_name -> ax.State
+ 2, // 4: ax.HarnessStart.messages:type_name -> ax.Message
+ 1, // 5: ax.HarnessCancel.reason:type_name -> ax.CancelReason
+ 4, // 6: ax.HarnessRequest.start:type_name -> ax.HarnessStart
+ 5, // 7: ax.HarnessRequest.cancel:type_name -> ax.HarnessCancel
+ 2, // 8: ax.HarnessOutputs.messages:type_name -> ax.Message
+ 0, // 9: ax.HarnessEnd.state:type_name -> ax.State
+ 8, // 10: ax.HarnessEnd.error:type_name -> ax.Error
+ 7, // 11: ax.HarnessResponse.outputs:type_name -> ax.HarnessOutputs
+ 9, // 12: ax.HarnessResponse.end:type_name -> ax.HarnessEnd
+ 2, // 13: ax.ExecRequest.inputs:type_name -> ax.Message
+ 2, // 14: ax.ExecResponse.outputs:type_name -> ax.Message
+ 6, // 15: ax.HarnessService.Connect:input_type -> ax.HarnessRequest
+ 11, // 16: ax.ExecutionService.Exec:input_type -> ax.ExecRequest
+ 13, // 17: ax.ConversationService.DeleteConversation:input_type -> ax.DeleteConversationRequest
+ 10, // 18: ax.HarnessService.Connect:output_type -> ax.HarnessResponse
+ 12, // 19: ax.ExecutionService.Exec:output_type -> ax.ExecResponse
+ 14, // 20: ax.ConversationService.DeleteConversation:output_type -> ax.DeleteConversationResponse
+ 18, // [18:21] is the sub-list for method output_type
+ 15, // [15:18] is the sub-list for method input_type
+ 15, // [15:15] is the sub-list for extension type_name
+ 15, // [15:15] is the sub-list for extension extendee
+ 0, // [0:15] is the sub-list for field type_name
+}
+
+func init() { file_proto_ax_proto_init() }
+func file_proto_ax_proto_init() {
+ if File_proto_ax_proto != nil {
+ return
+ }
+ file_proto_content_proto_init()
+ file_proto_ax_proto_msgTypes[4].OneofWrappers = []any{
+ (*HarnessRequest_Start)(nil),
+ (*HarnessRequest_Cancel)(nil),
+ }
+ file_proto_ax_proto_msgTypes[8].OneofWrappers = []any{
+ (*HarnessResponse_Outputs)(nil),
+ (*HarnessResponse_End)(nil),
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_ax_proto_rawDesc), len(file_proto_ax_proto_rawDesc)),
+ NumEnums: 2,
+ NumMessages: 13,
+ NumExtensions: 0,
+ NumServices: 3,
+ },
+ GoTypes: file_proto_ax_proto_goTypes,
+ DependencyIndexes: file_proto_ax_proto_depIdxs,
+ EnumInfos: file_proto_ax_proto_enumTypes,
+ MessageInfos: file_proto_ax_proto_msgTypes,
+ }.Build()
+ File_proto_ax_proto = out.File
+ file_proto_ax_proto_goTypes = nil
+ file_proto_ax_proto_depIdxs = nil
+}
diff --git a/proto/ax.proto b/proto/ax.proto
new file mode 100644
index 00000000..675fb885
--- /dev/null
+++ b/proto/ax.proto
@@ -0,0 +1,149 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package ax;
+
+import "google/protobuf/struct.proto";
+import "proto/content.proto";
+
+option go_package = "github.com/google/ax/proto";
+
+// WARNING: This file is in active development and
+// significant changes can be made with breaking changes.
+
+// Message is a message in the history.
+message Message {
+ string role = 1; // user, assistant, or model
+ Content content = 2; // content of the message
+}
+
+// A conversation is the historical session that consist of
+// a number of execution. A conversation cannot be continued
+// before the last execution is completed or failed.
+message ConversationEvent {
+ string conversation_id = 1;
+ int32 seq = 2;
+ string exec_id = 3;
+ string harness_id = 4;
+ google.protobuf.Struct harness_config = 5;
+ repeated Message messages = 6;
+ State state = 7;
+}
+
+message HarnessStart {
+ // Per-execution harness configuration.
+ bytes harness_config = 1;
+ repeated Message messages = 2;
+}
+
+// HarnessCancel aborts the in-flight harness execution.
+message HarnessCancel {
+ CancelReason reason = 1;
+}
+
+message HarnessRequest {
+ string conversation_id = 1;
+ string harness_id = 2;
+ oneof type {
+ HarnessStart start = 3;
+ HarnessCancel cancel = 4;
+ }
+}
+
+message HarnessOutputs {
+ repeated Message messages = 1;
+}
+
+message Error {
+ // A status code, corresponds to google.golang.org/grpc/codes.
+ int32 code = 1;
+ // A description of the error.
+ string description = 2;
+}
+
+message HarnessEnd {
+ // Terminal state for the harness execution.
+ State state = 1;
+ // Optional error details.
+ Error error = 2;
+}
+
+message HarnessResponse {
+ string conversation_id = 1;
+ oneof type {
+ HarnessOutputs outputs = 2;
+ HarnessEnd end = 3;
+ }
+}
+
+service HarnessService {
+ // Connect drives one harness execution. The client sends
+ // HarnessRequest{start} (and may send one HarnessRequest{cancel}
+ // mid-stream), and the server streams zero or more HarnessResponse{outputs}
+ // frames terminated by exactly one HarnessResponse{end}.
+ rpc Connect(stream HarnessRequest) returns (stream HarnessResponse);
+}
+
+// State represents the state of an execution
+enum State {
+ STATE_UNSPECIFIED = 0; // Unspecified state
+ STATE_PENDING = 1;
+ STATE_FAILED = 2;
+ STATE_COMPLETED = 3;
+ STATE_CANCELED = 4;
+}
+
+// CancelReason explains why an in-flight execution is canceled.
+enum CancelReason {
+ CANCEL_REASON_UNSPECIFIED = 0;
+ CANCEL_REASON_USER_REQUESTED = 1;
+ CANCEL_REASON_TIMEOUT = 2;
+ CANCEL_REASON_INTERNAL_ERROR = 3;
+}
+
+// ExecRequest for executing.
+message ExecRequest {
+ string conversation_id = 1; // Unique conversation identifier
+ repeated Message inputs = 2; // New inputs
+ int32 last_seq = 3; // Last sequence number seen by the client
+
+ string harness_id = 4; // Harness ID, empty selects the default harness
+ bytes harness_config = 5; // Per-request harness configuration (opaque JSON), if any
+}
+
+// ExecResponse contains the result of an execution.
+message ExecResponse {
+ repeated Message outputs = 1; // Output content
+ int32 seq = 2; // Seq of the outputs
+}
+
+service ExecutionService {
+ // Exec executes an agentic task or resumes an existing one with streaming responses
+ // If the conversation_id already exists, it will be resumed.
+ rpc Exec(ExecRequest) returns (stream ExecResponse);
+}
+
+message DeleteConversationRequest {
+ string conversation_id = 1;
+}
+
+message DeleteConversationResponse {}
+
+service ConversationService {
+ // Deletes conversational events and all event log resources
+ // for its children executions.
+ rpc DeleteConversation(DeleteConversationRequest) returns (DeleteConversationResponse);
+}
diff --git a/proto/ax_grpc.pb.go b/proto/ax_grpc.pb.go
new file mode 100644
index 00000000..dc1e0b81
--- /dev/null
+++ b/proto/ax_grpc.pb.go
@@ -0,0 +1,352 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
+// versions:
+// - protoc-gen-go-grpc v1.5.1
+// - protoc v5.28.2
+// source: proto/ax.proto
+
+package proto
+
+import (
+ context "context"
+ grpc "google.golang.org/grpc"
+ codes "google.golang.org/grpc/codes"
+ status "google.golang.org/grpc/status"
+)
+
+// This is a compile-time assertion to ensure that this generated file
+// is compatible with the grpc package it is being compiled against.
+// Requires gRPC-Go v1.64.0 or later.
+const _ = grpc.SupportPackageIsVersion9
+
+const (
+ HarnessService_Connect_FullMethodName = "/ax.HarnessService/Connect"
+)
+
+// HarnessServiceClient is the client API for HarnessService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type HarnessServiceClient interface {
+ // Connect drives one harness execution. The client sends
+ // HarnessRequest{start} (and may send one HarnessRequest{cancel}
+ // mid-stream), and the server streams zero or more HarnessResponse{outputs}
+ // frames terminated by exactly one HarnessResponse{end}.
+ Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[HarnessRequest, HarnessResponse], error)
+}
+
+type harnessServiceClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewHarnessServiceClient(cc grpc.ClientConnInterface) HarnessServiceClient {
+ return &harnessServiceClient{cc}
+}
+
+func (c *harnessServiceClient) Connect(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[HarnessRequest, HarnessResponse], error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ stream, err := c.cc.NewStream(ctx, &HarnessService_ServiceDesc.Streams[0], HarnessService_Connect_FullMethodName, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &grpc.GenericClientStream[HarnessRequest, HarnessResponse]{ClientStream: stream}
+ return x, nil
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type HarnessService_ConnectClient = grpc.BidiStreamingClient[HarnessRequest, HarnessResponse]
+
+// HarnessServiceServer is the server API for HarnessService service.
+// All implementations must embed UnimplementedHarnessServiceServer
+// for forward compatibility.
+type HarnessServiceServer interface {
+ // Connect drives one harness execution. The client sends
+ // HarnessRequest{start} (and may send one HarnessRequest{cancel}
+ // mid-stream), and the server streams zero or more HarnessResponse{outputs}
+ // frames terminated by exactly one HarnessResponse{end}.
+ Connect(grpc.BidiStreamingServer[HarnessRequest, HarnessResponse]) error
+ mustEmbedUnimplementedHarnessServiceServer()
+}
+
+// UnimplementedHarnessServiceServer must be embedded to have
+// forward compatible implementations.
+//
+// NOTE: this should be embedded by value instead of pointer to avoid a nil
+// pointer dereference when methods are called.
+type UnimplementedHarnessServiceServer struct{}
+
+func (UnimplementedHarnessServiceServer) Connect(grpc.BidiStreamingServer[HarnessRequest, HarnessResponse]) error {
+ return status.Errorf(codes.Unimplemented, "method Connect not implemented")
+}
+func (UnimplementedHarnessServiceServer) mustEmbedUnimplementedHarnessServiceServer() {}
+func (UnimplementedHarnessServiceServer) testEmbeddedByValue() {}
+
+// UnsafeHarnessServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to HarnessServiceServer will
+// result in compilation errors.
+type UnsafeHarnessServiceServer interface {
+ mustEmbedUnimplementedHarnessServiceServer()
+}
+
+func RegisterHarnessServiceServer(s grpc.ServiceRegistrar, srv HarnessServiceServer) {
+ // If the following call pancis, it indicates UnimplementedHarnessServiceServer was
+ // embedded by pointer and is nil. This will cause panics if an
+ // unimplemented method is ever invoked, so we test this at initialization
+ // time to prevent it from happening at runtime later due to I/O.
+ if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
+ t.testEmbeddedByValue()
+ }
+ s.RegisterService(&HarnessService_ServiceDesc, srv)
+}
+
+func _HarnessService_Connect_Handler(srv interface{}, stream grpc.ServerStream) error {
+ return srv.(HarnessServiceServer).Connect(&grpc.GenericServerStream[HarnessRequest, HarnessResponse]{ServerStream: stream})
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type HarnessService_ConnectServer = grpc.BidiStreamingServer[HarnessRequest, HarnessResponse]
+
+// HarnessService_ServiceDesc is the grpc.ServiceDesc for HarnessService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var HarnessService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "ax.HarnessService",
+ HandlerType: (*HarnessServiceServer)(nil),
+ Methods: []grpc.MethodDesc{},
+ Streams: []grpc.StreamDesc{
+ {
+ StreamName: "Connect",
+ Handler: _HarnessService_Connect_Handler,
+ ServerStreams: true,
+ ClientStreams: true,
+ },
+ },
+ Metadata: "proto/ax.proto",
+}
+
+const (
+ ExecutionService_Exec_FullMethodName = "/ax.ExecutionService/Exec"
+)
+
+// ExecutionServiceClient is the client API for ExecutionService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type ExecutionServiceClient interface {
+ // Exec executes an agentic task or resumes an existing one with streaming responses
+ // If the conversation_id already exists, it will be resumed.
+ Exec(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecResponse], error)
+}
+
+type executionServiceClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewExecutionServiceClient(cc grpc.ClientConnInterface) ExecutionServiceClient {
+ return &executionServiceClient{cc}
+}
+
+func (c *executionServiceClient) Exec(ctx context.Context, in *ExecRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ExecResponse], error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ stream, err := c.cc.NewStream(ctx, &ExecutionService_ServiceDesc.Streams[0], ExecutionService_Exec_FullMethodName, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ x := &grpc.GenericClientStream[ExecRequest, ExecResponse]{ClientStream: stream}
+ if err := x.ClientStream.SendMsg(in); err != nil {
+ return nil, err
+ }
+ if err := x.ClientStream.CloseSend(); err != nil {
+ return nil, err
+ }
+ return x, nil
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type ExecutionService_ExecClient = grpc.ServerStreamingClient[ExecResponse]
+
+// ExecutionServiceServer is the server API for ExecutionService service.
+// All implementations must embed UnimplementedExecutionServiceServer
+// for forward compatibility.
+type ExecutionServiceServer interface {
+ // Exec executes an agentic task or resumes an existing one with streaming responses
+ // If the conversation_id already exists, it will be resumed.
+ Exec(*ExecRequest, grpc.ServerStreamingServer[ExecResponse]) error
+ mustEmbedUnimplementedExecutionServiceServer()
+}
+
+// UnimplementedExecutionServiceServer must be embedded to have
+// forward compatible implementations.
+//
+// NOTE: this should be embedded by value instead of pointer to avoid a nil
+// pointer dereference when methods are called.
+type UnimplementedExecutionServiceServer struct{}
+
+func (UnimplementedExecutionServiceServer) Exec(*ExecRequest, grpc.ServerStreamingServer[ExecResponse]) error {
+ return status.Errorf(codes.Unimplemented, "method Exec not implemented")
+}
+func (UnimplementedExecutionServiceServer) mustEmbedUnimplementedExecutionServiceServer() {}
+func (UnimplementedExecutionServiceServer) testEmbeddedByValue() {}
+
+// UnsafeExecutionServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to ExecutionServiceServer will
+// result in compilation errors.
+type UnsafeExecutionServiceServer interface {
+ mustEmbedUnimplementedExecutionServiceServer()
+}
+
+func RegisterExecutionServiceServer(s grpc.ServiceRegistrar, srv ExecutionServiceServer) {
+ // If the following call pancis, it indicates UnimplementedExecutionServiceServer was
+ // embedded by pointer and is nil. This will cause panics if an
+ // unimplemented method is ever invoked, so we test this at initialization
+ // time to prevent it from happening at runtime later due to I/O.
+ if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
+ t.testEmbeddedByValue()
+ }
+ s.RegisterService(&ExecutionService_ServiceDesc, srv)
+}
+
+func _ExecutionService_Exec_Handler(srv interface{}, stream grpc.ServerStream) error {
+ m := new(ExecRequest)
+ if err := stream.RecvMsg(m); err != nil {
+ return err
+ }
+ return srv.(ExecutionServiceServer).Exec(m, &grpc.GenericServerStream[ExecRequest, ExecResponse]{ServerStream: stream})
+}
+
+// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
+type ExecutionService_ExecServer = grpc.ServerStreamingServer[ExecResponse]
+
+// ExecutionService_ServiceDesc is the grpc.ServiceDesc for ExecutionService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var ExecutionService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "ax.ExecutionService",
+ HandlerType: (*ExecutionServiceServer)(nil),
+ Methods: []grpc.MethodDesc{},
+ Streams: []grpc.StreamDesc{
+ {
+ StreamName: "Exec",
+ Handler: _ExecutionService_Exec_Handler,
+ ServerStreams: true,
+ },
+ },
+ Metadata: "proto/ax.proto",
+}
+
+const (
+ ConversationService_DeleteConversation_FullMethodName = "/ax.ConversationService/DeleteConversation"
+)
+
+// ConversationServiceClient is the client API for ConversationService service.
+//
+// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
+type ConversationServiceClient interface {
+ // Deletes conversational events and all event log resources
+ // for its children executions.
+ DeleteConversation(ctx context.Context, in *DeleteConversationRequest, opts ...grpc.CallOption) (*DeleteConversationResponse, error)
+}
+
+type conversationServiceClient struct {
+ cc grpc.ClientConnInterface
+}
+
+func NewConversationServiceClient(cc grpc.ClientConnInterface) ConversationServiceClient {
+ return &conversationServiceClient{cc}
+}
+
+func (c *conversationServiceClient) DeleteConversation(ctx context.Context, in *DeleteConversationRequest, opts ...grpc.CallOption) (*DeleteConversationResponse, error) {
+ cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
+ out := new(DeleteConversationResponse)
+ err := c.cc.Invoke(ctx, ConversationService_DeleteConversation_FullMethodName, in, out, cOpts...)
+ if err != nil {
+ return nil, err
+ }
+ return out, nil
+}
+
+// ConversationServiceServer is the server API for ConversationService service.
+// All implementations must embed UnimplementedConversationServiceServer
+// for forward compatibility.
+type ConversationServiceServer interface {
+ // Deletes conversational events and all event log resources
+ // for its children executions.
+ DeleteConversation(context.Context, *DeleteConversationRequest) (*DeleteConversationResponse, error)
+ mustEmbedUnimplementedConversationServiceServer()
+}
+
+// UnimplementedConversationServiceServer must be embedded to have
+// forward compatible implementations.
+//
+// NOTE: this should be embedded by value instead of pointer to avoid a nil
+// pointer dereference when methods are called.
+type UnimplementedConversationServiceServer struct{}
+
+func (UnimplementedConversationServiceServer) DeleteConversation(context.Context, *DeleteConversationRequest) (*DeleteConversationResponse, error) {
+ return nil, status.Errorf(codes.Unimplemented, "method DeleteConversation not implemented")
+}
+func (UnimplementedConversationServiceServer) mustEmbedUnimplementedConversationServiceServer() {}
+func (UnimplementedConversationServiceServer) testEmbeddedByValue() {}
+
+// UnsafeConversationServiceServer may be embedded to opt out of forward compatibility for this service.
+// Use of this interface is not recommended, as added methods to ConversationServiceServer will
+// result in compilation errors.
+type UnsafeConversationServiceServer interface {
+ mustEmbedUnimplementedConversationServiceServer()
+}
+
+func RegisterConversationServiceServer(s grpc.ServiceRegistrar, srv ConversationServiceServer) {
+ // If the following call pancis, it indicates UnimplementedConversationServiceServer was
+ // embedded by pointer and is nil. This will cause panics if an
+ // unimplemented method is ever invoked, so we test this at initialization
+ // time to prevent it from happening at runtime later due to I/O.
+ if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
+ t.testEmbeddedByValue()
+ }
+ s.RegisterService(&ConversationService_ServiceDesc, srv)
+}
+
+func _ConversationService_DeleteConversation_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
+ in := new(DeleteConversationRequest)
+ if err := dec(in); err != nil {
+ return nil, err
+ }
+ if interceptor == nil {
+ return srv.(ConversationServiceServer).DeleteConversation(ctx, in)
+ }
+ info := &grpc.UnaryServerInfo{
+ Server: srv,
+ FullMethod: ConversationService_DeleteConversation_FullMethodName,
+ }
+ handler := func(ctx context.Context, req interface{}) (interface{}, error) {
+ return srv.(ConversationServiceServer).DeleteConversation(ctx, req.(*DeleteConversationRequest))
+ }
+ return interceptor(ctx, in, info, handler)
+}
+
+// ConversationService_ServiceDesc is the grpc.ServiceDesc for ConversationService service.
+// It's only intended for direct use with grpc.RegisterService,
+// and not to be introspected or modified (even as a copy)
+var ConversationService_ServiceDesc = grpc.ServiceDesc{
+ ServiceName: "ax.ConversationService",
+ HandlerType: (*ConversationServiceServer)(nil),
+ Methods: []grpc.MethodDesc{
+ {
+ MethodName: "DeleteConversation",
+ Handler: _ConversationService_DeleteConversation_Handler,
+ },
+ },
+ Streams: []grpc.StreamDesc{},
+ Metadata: "proto/ax.proto",
+}
diff --git a/proto/content.pb.go b/proto/content.pb.go
new file mode 100644
index 00000000..1cae4f60
--- /dev/null
+++ b/proto/content.pb.go
@@ -0,0 +1,1868 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Code generated by protoc-gen-go. DO NOT EDIT.
+// versions:
+// protoc-gen-go v1.36.11
+// protoc v5.28.2
+// source: proto/content.proto
+
+package proto
+
+import (
+ protoreflect "google.golang.org/protobuf/reflect/protoreflect"
+ protoimpl "google.golang.org/protobuf/runtime/protoimpl"
+ structpb "google.golang.org/protobuf/types/known/structpb"
+ reflect "reflect"
+ sync "sync"
+ unsafe "unsafe"
+)
+
+const (
+ // Verify that this generated code is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
+ // Verify that runtime/protoimpl is sufficiently up-to-date.
+ _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
+)
+
+// Resolution for input media (images/video).
+type MediaResolution int32
+
+const (
+ MediaResolution_MEDIA_RESOLUTION_UNSPECIFIED MediaResolution = 0
+ MediaResolution_LOW MediaResolution = 1
+ MediaResolution_MEDIUM MediaResolution = 2
+ MediaResolution_HIGH MediaResolution = 3
+ MediaResolution_ULTRA_HIGH MediaResolution = 4
+)
+
+// Enum value maps for MediaResolution.
+var (
+ MediaResolution_name = map[int32]string{
+ 0: "MEDIA_RESOLUTION_UNSPECIFIED",
+ 1: "LOW",
+ 2: "MEDIUM",
+ 3: "HIGH",
+ 4: "ULTRA_HIGH",
+ }
+ MediaResolution_value = map[string]int32{
+ "MEDIA_RESOLUTION_UNSPECIFIED": 0,
+ "LOW": 1,
+ "MEDIUM": 2,
+ "HIGH": 3,
+ "ULTRA_HIGH": 4,
+ }
+)
+
+func (x MediaResolution) Enum() *MediaResolution {
+ p := new(MediaResolution)
+ *p = x
+ return p
+}
+
+func (x MediaResolution) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (MediaResolution) Descriptor() protoreflect.EnumDescriptor {
+ return file_proto_content_proto_enumTypes[0].Descriptor()
+}
+
+func (MediaResolution) Type() protoreflect.EnumType {
+ return &file_proto_content_proto_enumTypes[0]
+}
+
+func (x MediaResolution) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use MediaResolution.Descriptor instead.
+func (MediaResolution) EnumDescriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{0}
+}
+
+type ImageContent_MimeType int32
+
+const (
+ ImageContent_TYPE_UNSPECIFIED ImageContent_MimeType = 0
+ ImageContent_TYPE_PNG ImageContent_MimeType = 1 // image/png
+ ImageContent_TYPE_JPEG ImageContent_MimeType = 2 // image/jpeg
+ ImageContent_TYPE_WEBP ImageContent_MimeType = 3 // image/webp
+ ImageContent_TYPE_HEIC ImageContent_MimeType = 4 // image/heic
+ ImageContent_TYPE_HEIF ImageContent_MimeType = 5 // image/heif
+ ImageContent_TYPE_GIF ImageContent_MimeType = 7 // image/gif
+ ImageContent_TYPE_BMP ImageContent_MimeType = 8 // image/bmp
+ ImageContent_TYPE_TIFF ImageContent_MimeType = 9 // image/tiff
+)
+
+// Enum value maps for ImageContent_MimeType.
+var (
+ ImageContent_MimeType_name = map[int32]string{
+ 0: "TYPE_UNSPECIFIED",
+ 1: "TYPE_PNG",
+ 2: "TYPE_JPEG",
+ 3: "TYPE_WEBP",
+ 4: "TYPE_HEIC",
+ 5: "TYPE_HEIF",
+ 7: "TYPE_GIF",
+ 8: "TYPE_BMP",
+ 9: "TYPE_TIFF",
+ }
+ ImageContent_MimeType_value = map[string]int32{
+ "TYPE_UNSPECIFIED": 0,
+ "TYPE_PNG": 1,
+ "TYPE_JPEG": 2,
+ "TYPE_WEBP": 3,
+ "TYPE_HEIC": 4,
+ "TYPE_HEIF": 5,
+ "TYPE_GIF": 7,
+ "TYPE_BMP": 8,
+ "TYPE_TIFF": 9,
+ }
+)
+
+func (x ImageContent_MimeType) Enum() *ImageContent_MimeType {
+ p := new(ImageContent_MimeType)
+ *p = x
+ return p
+}
+
+func (x ImageContent_MimeType) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (ImageContent_MimeType) Descriptor() protoreflect.EnumDescriptor {
+ return file_proto_content_proto_enumTypes[1].Descriptor()
+}
+
+func (ImageContent_MimeType) Type() protoreflect.EnumType {
+ return &file_proto_content_proto_enumTypes[1]
+}
+
+func (x ImageContent_MimeType) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use ImageContent_MimeType.Descriptor instead.
+func (ImageContent_MimeType) EnumDescriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{10, 0}
+}
+
+type AudioContent_MimeType int32
+
+const (
+ AudioContent_TYPE_UNSPECIFIED AudioContent_MimeType = 0
+ AudioContent_TYPE_WAV AudioContent_MimeType = 1 // audio/wav
+ AudioContent_TYPE_MP3 AudioContent_MimeType = 2 // audio/mp3
+ AudioContent_TYPE_AIFF AudioContent_MimeType = 3 // audio/aiff
+ AudioContent_TYPE_AAC AudioContent_MimeType = 4 // audio/aac
+ AudioContent_TYPE_OGG AudioContent_MimeType = 5 // audio/ogg
+ AudioContent_TYPE_FLAC AudioContent_MimeType = 6 // audio/flac
+ AudioContent_TYPE_MPEG AudioContent_MimeType = 7 // audio/mpeg
+ AudioContent_TYPE_M4A AudioContent_MimeType = 8 // audio/m4a
+ AudioContent_TYPE_L16 AudioContent_MimeType = 9 // audio/l16
+ AudioContent_TYPE_S16LE AudioContent_MimeType = 10 // audio/s16le
+ AudioContent_TYPE_OPUS AudioContent_MimeType = 11 // audio/opus
+ AudioContent_TYPE_ALAW AudioContent_MimeType = 12 // audio/alaw
+ AudioContent_TYPE_MULAW AudioContent_MimeType = 13 // audio/mulaw
+)
+
+// Enum value maps for AudioContent_MimeType.
+var (
+ AudioContent_MimeType_name = map[int32]string{
+ 0: "TYPE_UNSPECIFIED",
+ 1: "TYPE_WAV",
+ 2: "TYPE_MP3",
+ 3: "TYPE_AIFF",
+ 4: "TYPE_AAC",
+ 5: "TYPE_OGG",
+ 6: "TYPE_FLAC",
+ 7: "TYPE_MPEG",
+ 8: "TYPE_M4A",
+ 9: "TYPE_L16",
+ 10: "TYPE_S16LE",
+ 11: "TYPE_OPUS",
+ 12: "TYPE_ALAW",
+ 13: "TYPE_MULAW",
+ }
+ AudioContent_MimeType_value = map[string]int32{
+ "TYPE_UNSPECIFIED": 0,
+ "TYPE_WAV": 1,
+ "TYPE_MP3": 2,
+ "TYPE_AIFF": 3,
+ "TYPE_AAC": 4,
+ "TYPE_OGG": 5,
+ "TYPE_FLAC": 6,
+ "TYPE_MPEG": 7,
+ "TYPE_M4A": 8,
+ "TYPE_L16": 9,
+ "TYPE_S16LE": 10,
+ "TYPE_OPUS": 11,
+ "TYPE_ALAW": 12,
+ "TYPE_MULAW": 13,
+ }
+)
+
+func (x AudioContent_MimeType) Enum() *AudioContent_MimeType {
+ p := new(AudioContent_MimeType)
+ *p = x
+ return p
+}
+
+func (x AudioContent_MimeType) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (AudioContent_MimeType) Descriptor() protoreflect.EnumDescriptor {
+ return file_proto_content_proto_enumTypes[2].Descriptor()
+}
+
+func (AudioContent_MimeType) Type() protoreflect.EnumType {
+ return &file_proto_content_proto_enumTypes[2]
+}
+
+func (x AudioContent_MimeType) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use AudioContent_MimeType.Descriptor instead.
+func (AudioContent_MimeType) EnumDescriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{11, 0}
+}
+
+type DocumentContent_MimeType int32
+
+const (
+ DocumentContent_TYPE_UNSPECIFIED DocumentContent_MimeType = 0
+ DocumentContent_TYPE_PDF DocumentContent_MimeType = 1 // application/pdf
+ DocumentContent_TYPE_JSON DocumentContent_MimeType = 2 // application/json
+ DocumentContent_TYPE_PYTHON DocumentContent_MimeType = 3 // text/x-python
+)
+
+// Enum value maps for DocumentContent_MimeType.
+var (
+ DocumentContent_MimeType_name = map[int32]string{
+ 0: "TYPE_UNSPECIFIED",
+ 1: "TYPE_PDF",
+ 2: "TYPE_JSON",
+ 3: "TYPE_PYTHON",
+ }
+ DocumentContent_MimeType_value = map[string]int32{
+ "TYPE_UNSPECIFIED": 0,
+ "TYPE_PDF": 1,
+ "TYPE_JSON": 2,
+ "TYPE_PYTHON": 3,
+ }
+)
+
+func (x DocumentContent_MimeType) Enum() *DocumentContent_MimeType {
+ p := new(DocumentContent_MimeType)
+ *p = x
+ return p
+}
+
+func (x DocumentContent_MimeType) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (DocumentContent_MimeType) Descriptor() protoreflect.EnumDescriptor {
+ return file_proto_content_proto_enumTypes[3].Descriptor()
+}
+
+func (DocumentContent_MimeType) Type() protoreflect.EnumType {
+ return &file_proto_content_proto_enumTypes[3]
+}
+
+func (x DocumentContent_MimeType) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use DocumentContent_MimeType.Descriptor instead.
+func (DocumentContent_MimeType) EnumDescriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{12, 0}
+}
+
+type VideoContent_MimeType int32
+
+const (
+ VideoContent_TYPE_UNSPECIFIED VideoContent_MimeType = 0
+ VideoContent_TYPE_MP4 VideoContent_MimeType = 1 // video/mp4
+ VideoContent_TYPE_MPEG VideoContent_MimeType = 2 // video/mpeg
+ VideoContent_TYPE_MPG VideoContent_MimeType = 3 // video/mpg
+ VideoContent_TYPE_MOV VideoContent_MimeType = 4 // video/mov
+ VideoContent_TYPE_AVI VideoContent_MimeType = 5 // video/avi
+ VideoContent_TYPE_X_FLV VideoContent_MimeType = 6 // video/x-flv
+ VideoContent_TYPE_WEBM VideoContent_MimeType = 7 // video/webm
+ VideoContent_TYPE_WMV VideoContent_MimeType = 8 // video/wmv
+ VideoContent_TYPE_3GPP VideoContent_MimeType = 9 // video/3gpp
+)
+
+// Enum value maps for VideoContent_MimeType.
+var (
+ VideoContent_MimeType_name = map[int32]string{
+ 0: "TYPE_UNSPECIFIED",
+ 1: "TYPE_MP4",
+ 2: "TYPE_MPEG",
+ 3: "TYPE_MPG",
+ 4: "TYPE_MOV",
+ 5: "TYPE_AVI",
+ 6: "TYPE_X_FLV",
+ 7: "TYPE_WEBM",
+ 8: "TYPE_WMV",
+ 9: "TYPE_3GPP",
+ }
+ VideoContent_MimeType_value = map[string]int32{
+ "TYPE_UNSPECIFIED": 0,
+ "TYPE_MP4": 1,
+ "TYPE_MPEG": 2,
+ "TYPE_MPG": 3,
+ "TYPE_MOV": 4,
+ "TYPE_AVI": 5,
+ "TYPE_X_FLV": 6,
+ "TYPE_WEBM": 7,
+ "TYPE_WMV": 8,
+ "TYPE_3GPP": 9,
+ }
+)
+
+func (x VideoContent_MimeType) Enum() *VideoContent_MimeType {
+ p := new(VideoContent_MimeType)
+ *p = x
+ return p
+}
+
+func (x VideoContent_MimeType) String() string {
+ return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
+}
+
+func (VideoContent_MimeType) Descriptor() protoreflect.EnumDescriptor {
+ return file_proto_content_proto_enumTypes[4].Descriptor()
+}
+
+func (VideoContent_MimeType) Type() protoreflect.EnumType {
+ return &file_proto_content_proto_enumTypes[4]
+}
+
+func (x VideoContent_MimeType) Number() protoreflect.EnumNumber {
+ return protoreflect.EnumNumber(x)
+}
+
+// Deprecated: Use VideoContent_MimeType.Descriptor instead.
+func (VideoContent_MimeType) EnumDescriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{13, 0}
+}
+
+// TextContent represents a text content.
+type TextContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Text string `protobuf:"bytes,3,opt,name=text,proto3" json:"text,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *TextContent) Reset() {
+ *x = TextContent{}
+ mi := &file_proto_content_proto_msgTypes[0]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *TextContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*TextContent) ProtoMessage() {}
+
+func (x *TextContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[0]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use TextContent.ProtoReflect.Descriptor instead.
+func (*TextContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{0}
+}
+
+func (x *TextContent) GetText() string {
+ if x != nil {
+ return x.Text
+ }
+ return ""
+}
+
+type ApprovalDecision struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Approved bool `protobuf:"varint,1,opt,name=approved,proto3" json:"approved,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ApprovalDecision) Reset() {
+ *x = ApprovalDecision{}
+ mi := &file_proto_content_proto_msgTypes[1]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ApprovalDecision) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ApprovalDecision) ProtoMessage() {}
+
+func (x *ApprovalDecision) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[1]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ApprovalDecision.ProtoReflect.Descriptor instead.
+func (*ApprovalDecision) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{1}
+}
+
+func (x *ApprovalDecision) GetApproved() bool {
+ if x != nil {
+ return x.Approved
+ }
+ return false
+}
+
+type DeclineDecision struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Declined bool `protobuf:"varint,1,opt,name=declined,proto3" json:"declined,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DeclineDecision) Reset() {
+ *x = DeclineDecision{}
+ mi := &file_proto_content_proto_msgTypes[2]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DeclineDecision) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DeclineDecision) ProtoMessage() {}
+
+func (x *DeclineDecision) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[2]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DeclineDecision.ProtoReflect.Descriptor instead.
+func (*DeclineDecision) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{2}
+}
+
+func (x *DeclineDecision) GetDeclined() bool {
+ if x != nil {
+ return x.Declined
+ }
+ return false
+}
+
+type ConfirmationContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"`
+ Question string `protobuf:"bytes,4,opt,name=question,proto3" json:"question,omitempty"`
+ // Types that are valid to be assigned to Decision:
+ //
+ // *ConfirmationContent_Approval
+ // *ConfirmationContent_Decline
+ Decision isConfirmationContent_Decision `protobuf_oneof:"decision"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ConfirmationContent) Reset() {
+ *x = ConfirmationContent{}
+ mi := &file_proto_content_proto_msgTypes[3]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ConfirmationContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ConfirmationContent) ProtoMessage() {}
+
+func (x *ConfirmationContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[3]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ConfirmationContent.ProtoReflect.Descriptor instead.
+func (*ConfirmationContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{3}
+}
+
+func (x *ConfirmationContent) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *ConfirmationContent) GetQuestion() string {
+ if x != nil {
+ return x.Question
+ }
+ return ""
+}
+
+func (x *ConfirmationContent) GetDecision() isConfirmationContent_Decision {
+ if x != nil {
+ return x.Decision
+ }
+ return nil
+}
+
+func (x *ConfirmationContent) GetApproval() *ApprovalDecision {
+ if x != nil {
+ if x, ok := x.Decision.(*ConfirmationContent_Approval); ok {
+ return x.Approval
+ }
+ }
+ return nil
+}
+
+func (x *ConfirmationContent) GetDecline() *DeclineDecision {
+ if x != nil {
+ if x, ok := x.Decision.(*ConfirmationContent_Decline); ok {
+ return x.Decline
+ }
+ }
+ return nil
+}
+
+type isConfirmationContent_Decision interface {
+ isConfirmationContent_Decision()
+}
+
+type ConfirmationContent_Approval struct {
+ Approval *ApprovalDecision `protobuf:"bytes,5,opt,name=approval,proto3,oneof"`
+}
+
+type ConfirmationContent_Decline struct {
+ Decline *DeclineDecision `protobuf:"bytes,6,opt,name=decline,proto3,oneof"`
+}
+
+func (*ConfirmationContent_Approval) isConfirmationContent_Decision() {}
+
+func (*ConfirmationContent_Decline) isConfirmationContent_Decision() {}
+
+type ThoughtSummaryContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Type:
+ //
+ // *ThoughtSummaryContent_Text
+ Type isThoughtSummaryContent_Type `protobuf_oneof:"type"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ThoughtSummaryContent) Reset() {
+ *x = ThoughtSummaryContent{}
+ mi := &file_proto_content_proto_msgTypes[4]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ThoughtSummaryContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ThoughtSummaryContent) ProtoMessage() {}
+
+func (x *ThoughtSummaryContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[4]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ThoughtSummaryContent.ProtoReflect.Descriptor instead.
+func (*ThoughtSummaryContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{4}
+}
+
+func (x *ThoughtSummaryContent) GetType() isThoughtSummaryContent_Type {
+ if x != nil {
+ return x.Type
+ }
+ return nil
+}
+
+func (x *ThoughtSummaryContent) GetText() *TextContent {
+ if x != nil {
+ if x, ok := x.Type.(*ThoughtSummaryContent_Text); ok {
+ return x.Text
+ }
+ }
+ return nil
+}
+
+type isThoughtSummaryContent_Type interface {
+ isThoughtSummaryContent_Type()
+}
+
+type ThoughtSummaryContent_Text struct {
+ Text *TextContent `protobuf:"bytes,1,opt,name=text,proto3,oneof"`
+}
+
+func (*ThoughtSummaryContent_Text) isThoughtSummaryContent_Type() {}
+
+type ThoughtContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Signature []byte `protobuf:"bytes,7,opt,name=signature,proto3" json:"signature,omitempty"`
+ Summary []*ThoughtSummaryContent `protobuf:"bytes,9,rep,name=summary,proto3" json:"summary,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ThoughtContent) Reset() {
+ *x = ThoughtContent{}
+ mi := &file_proto_content_proto_msgTypes[5]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ThoughtContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ThoughtContent) ProtoMessage() {}
+
+func (x *ThoughtContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[5]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ThoughtContent.ProtoReflect.Descriptor instead.
+func (*ThoughtContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{5}
+}
+
+func (x *ThoughtContent) GetSignature() []byte {
+ if x != nil {
+ return x.Signature
+ }
+ return nil
+}
+
+func (x *ThoughtContent) GetSummary() []*ThoughtSummaryContent {
+ if x != nil {
+ return x.Summary
+ }
+ return nil
+}
+
+type ToolCallContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` // A unique ID for this specific tool call.
+ Signature []byte `protobuf:"bytes,9,opt,name=signature,proto3" json:"signature,omitempty"` // A signature hash for backend validation.
+ // Types that are valid to be assigned to Type:
+ //
+ // *ToolCallContent_FunctionCall
+ Type isToolCallContent_Type `protobuf_oneof:"type"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ToolCallContent) Reset() {
+ *x = ToolCallContent{}
+ mi := &file_proto_content_proto_msgTypes[6]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ToolCallContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ToolCallContent) ProtoMessage() {}
+
+func (x *ToolCallContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[6]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ToolCallContent.ProtoReflect.Descriptor instead.
+func (*ToolCallContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{6}
+}
+
+func (x *ToolCallContent) GetId() string {
+ if x != nil {
+ return x.Id
+ }
+ return ""
+}
+
+func (x *ToolCallContent) GetSignature() []byte {
+ if x != nil {
+ return x.Signature
+ }
+ return nil
+}
+
+func (x *ToolCallContent) GetType() isToolCallContent_Type {
+ if x != nil {
+ return x.Type
+ }
+ return nil
+}
+
+func (x *ToolCallContent) GetFunctionCall() *FunctionCallContent {
+ if x != nil {
+ if x, ok := x.Type.(*ToolCallContent_FunctionCall); ok {
+ return x.FunctionCall
+ }
+ }
+ return nil
+}
+
+type isToolCallContent_Type interface {
+ isToolCallContent_Type()
+}
+
+type ToolCallContent_FunctionCall struct {
+ FunctionCall *FunctionCallContent `protobuf:"bytes,2,opt,name=function_call,json=functionCall,proto3,oneof"`
+}
+
+func (*ToolCallContent_FunctionCall) isToolCallContent_Type() {}
+
+type ToolResultContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ CallId string `protobuf:"bytes,8,opt,name=call_id,json=callId,proto3" json:"call_id,omitempty"` // ID to match the ID from the function call block.
+ Signature []byte `protobuf:"bytes,9,opt,name=signature,proto3" json:"signature,omitempty"` // A signature hash for backend validation.
+ // Types that are valid to be assigned to Type:
+ //
+ // *ToolResultContent_FunctionResult
+ Type isToolResultContent_Type `protobuf_oneof:"type"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ToolResultContent) Reset() {
+ *x = ToolResultContent{}
+ mi := &file_proto_content_proto_msgTypes[7]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ToolResultContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ToolResultContent) ProtoMessage() {}
+
+func (x *ToolResultContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[7]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ToolResultContent.ProtoReflect.Descriptor instead.
+func (*ToolResultContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{7}
+}
+
+func (x *ToolResultContent) GetCallId() string {
+ if x != nil {
+ return x.CallId
+ }
+ return ""
+}
+
+func (x *ToolResultContent) GetSignature() []byte {
+ if x != nil {
+ return x.Signature
+ }
+ return nil
+}
+
+func (x *ToolResultContent) GetType() isToolResultContent_Type {
+ if x != nil {
+ return x.Type
+ }
+ return nil
+}
+
+func (x *ToolResultContent) GetFunctionResult() *FunctionResultContent {
+ if x != nil {
+ if x, ok := x.Type.(*ToolResultContent_FunctionResult); ok {
+ return x.FunctionResult
+ }
+ }
+ return nil
+}
+
+type isToolResultContent_Type interface {
+ isToolResultContent_Type()
+}
+
+type ToolResultContent_FunctionResult struct {
+ FunctionResult *FunctionResultContent `protobuf:"bytes,2,opt,name=function_result,json=functionResult,proto3,oneof"`
+}
+
+func (*ToolResultContent_FunctionResult) isToolResultContent_Type() {}
+
+type FunctionCallContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"`
+ Arguments *structpb.Struct `protobuf:"bytes,4,opt,name=arguments,proto3" json:"arguments,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FunctionCallContent) Reset() {
+ *x = FunctionCallContent{}
+ mi := &file_proto_content_proto_msgTypes[8]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FunctionCallContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FunctionCallContent) ProtoMessage() {}
+
+func (x *FunctionCallContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[8]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FunctionCallContent.ProtoReflect.Descriptor instead.
+func (*FunctionCallContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{8}
+}
+
+func (x *FunctionCallContent) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *FunctionCallContent) GetArguments() *structpb.Struct {
+ if x != nil {
+ return x.Arguments
+ }
+ return nil
+}
+
+type FunctionResultContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ Name string `protobuf:"bytes,8,opt,name=name,proto3" json:"name,omitempty"`
+ // Types that are valid to be assigned to Result:
+ //
+ // *FunctionResultContent_Response
+ Result isFunctionResultContent_Result `protobuf_oneof:"result"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *FunctionResultContent) Reset() {
+ *x = FunctionResultContent{}
+ mi := &file_proto_content_proto_msgTypes[9]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *FunctionResultContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*FunctionResultContent) ProtoMessage() {}
+
+func (x *FunctionResultContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[9]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use FunctionResultContent.ProtoReflect.Descriptor instead.
+func (*FunctionResultContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{9}
+}
+
+func (x *FunctionResultContent) GetName() string {
+ if x != nil {
+ return x.Name
+ }
+ return ""
+}
+
+func (x *FunctionResultContent) GetResult() isFunctionResultContent_Result {
+ if x != nil {
+ return x.Result
+ }
+ return nil
+}
+
+func (x *FunctionResultContent) GetResponse() *structpb.Struct {
+ if x != nil {
+ if x, ok := x.Result.(*FunctionResultContent_Response); ok {
+ return x.Response
+ }
+ }
+ return nil
+}
+
+type isFunctionResultContent_Result interface {
+ isFunctionResultContent_Result()
+}
+
+type FunctionResultContent_Response struct {
+ Response *structpb.Struct `protobuf:"bytes,3,opt,name=response,proto3,oneof"`
+}
+
+func (*FunctionResultContent_Response) isFunctionResultContent_Result() {}
+
+// An image content block.
+type ImageContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ MimeType ImageContent_MimeType `protobuf:"varint,1,opt,name=mime_type,json=mimeType,proto3,enum=ax.ImageContent_MimeType" json:"mime_type,omitempty"`
+ // Types that are valid to be assigned to DataOrUri:
+ //
+ // *ImageContent_Data
+ // *ImageContent_Uri
+ DataOrUri isImageContent_DataOrUri `protobuf_oneof:"data_or_uri"`
+ Resolution MediaResolution `protobuf:"varint,5,opt,name=resolution,proto3,enum=ax.MediaResolution" json:"resolution,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *ImageContent) Reset() {
+ *x = ImageContent{}
+ mi := &file_proto_content_proto_msgTypes[10]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *ImageContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*ImageContent) ProtoMessage() {}
+
+func (x *ImageContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[10]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use ImageContent.ProtoReflect.Descriptor instead.
+func (*ImageContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{10}
+}
+
+func (x *ImageContent) GetMimeType() ImageContent_MimeType {
+ if x != nil {
+ return x.MimeType
+ }
+ return ImageContent_TYPE_UNSPECIFIED
+}
+
+func (x *ImageContent) GetDataOrUri() isImageContent_DataOrUri {
+ if x != nil {
+ return x.DataOrUri
+ }
+ return nil
+}
+
+func (x *ImageContent) GetData() []byte {
+ if x != nil {
+ if x, ok := x.DataOrUri.(*ImageContent_Data); ok {
+ return x.Data
+ }
+ }
+ return nil
+}
+
+func (x *ImageContent) GetUri() string {
+ if x != nil {
+ if x, ok := x.DataOrUri.(*ImageContent_Uri); ok {
+ return x.Uri
+ }
+ }
+ return ""
+}
+
+func (x *ImageContent) GetResolution() MediaResolution {
+ if x != nil {
+ return x.Resolution
+ }
+ return MediaResolution_MEDIA_RESOLUTION_UNSPECIFIED
+}
+
+type isImageContent_DataOrUri interface {
+ isImageContent_DataOrUri()
+}
+
+type ImageContent_Data struct {
+ Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"`
+}
+
+type ImageContent_Uri struct {
+ Uri string `protobuf:"bytes,6,opt,name=uri,proto3,oneof"`
+}
+
+func (*ImageContent_Data) isImageContent_DataOrUri() {}
+
+func (*ImageContent_Uri) isImageContent_DataOrUri() {}
+
+// An audio content block.
+type AudioContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ MimeType AudioContent_MimeType `protobuf:"varint,1,opt,name=mime_type,json=mimeType,proto3,enum=ax.AudioContent_MimeType" json:"mime_type,omitempty"`
+ // Types that are valid to be assigned to DataOrUri:
+ //
+ // *AudioContent_Data
+ // *AudioContent_Uri
+ DataOrUri isAudioContent_DataOrUri `protobuf_oneof:"data_or_uri"`
+ Channels int32 `protobuf:"varint,7,opt,name=channels,proto3" json:"channels,omitempty"`
+ SampleRate int32 `protobuf:"varint,8,opt,name=sample_rate,json=sampleRate,proto3" json:"sample_rate,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *AudioContent) Reset() {
+ *x = AudioContent{}
+ mi := &file_proto_content_proto_msgTypes[11]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *AudioContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*AudioContent) ProtoMessage() {}
+
+func (x *AudioContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[11]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use AudioContent.ProtoReflect.Descriptor instead.
+func (*AudioContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{11}
+}
+
+func (x *AudioContent) GetMimeType() AudioContent_MimeType {
+ if x != nil {
+ return x.MimeType
+ }
+ return AudioContent_TYPE_UNSPECIFIED
+}
+
+func (x *AudioContent) GetDataOrUri() isAudioContent_DataOrUri {
+ if x != nil {
+ return x.DataOrUri
+ }
+ return nil
+}
+
+func (x *AudioContent) GetData() []byte {
+ if x != nil {
+ if x, ok := x.DataOrUri.(*AudioContent_Data); ok {
+ return x.Data
+ }
+ }
+ return nil
+}
+
+func (x *AudioContent) GetUri() string {
+ if x != nil {
+ if x, ok := x.DataOrUri.(*AudioContent_Uri); ok {
+ return x.Uri
+ }
+ }
+ return ""
+}
+
+func (x *AudioContent) GetChannels() int32 {
+ if x != nil {
+ return x.Channels
+ }
+ return 0
+}
+
+func (x *AudioContent) GetSampleRate() int32 {
+ if x != nil {
+ return x.SampleRate
+ }
+ return 0
+}
+
+type isAudioContent_DataOrUri interface {
+ isAudioContent_DataOrUri()
+}
+
+type AudioContent_Data struct {
+ Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"`
+}
+
+type AudioContent_Uri struct {
+ Uri string `protobuf:"bytes,5,opt,name=uri,proto3,oneof"`
+}
+
+func (*AudioContent_Data) isAudioContent_DataOrUri() {}
+
+func (*AudioContent_Uri) isAudioContent_DataOrUri() {}
+
+// A document content block.
+type DocumentContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ MimeType DocumentContent_MimeType `protobuf:"varint,1,opt,name=mime_type,json=mimeType,proto3,enum=ax.DocumentContent_MimeType" json:"mime_type,omitempty"`
+ // Types that are valid to be assigned to DataOrUri:
+ //
+ // *DocumentContent_Data
+ // *DocumentContent_Uri
+ DataOrUri isDocumentContent_DataOrUri `protobuf_oneof:"data_or_uri"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *DocumentContent) Reset() {
+ *x = DocumentContent{}
+ mi := &file_proto_content_proto_msgTypes[12]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *DocumentContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*DocumentContent) ProtoMessage() {}
+
+func (x *DocumentContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[12]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use DocumentContent.ProtoReflect.Descriptor instead.
+func (*DocumentContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{12}
+}
+
+func (x *DocumentContent) GetMimeType() DocumentContent_MimeType {
+ if x != nil {
+ return x.MimeType
+ }
+ return DocumentContent_TYPE_UNSPECIFIED
+}
+
+func (x *DocumentContent) GetDataOrUri() isDocumentContent_DataOrUri {
+ if x != nil {
+ return x.DataOrUri
+ }
+ return nil
+}
+
+func (x *DocumentContent) GetData() []byte {
+ if x != nil {
+ if x, ok := x.DataOrUri.(*DocumentContent_Data); ok {
+ return x.Data
+ }
+ }
+ return nil
+}
+
+func (x *DocumentContent) GetUri() string {
+ if x != nil {
+ if x, ok := x.DataOrUri.(*DocumentContent_Uri); ok {
+ return x.Uri
+ }
+ }
+ return ""
+}
+
+type isDocumentContent_DataOrUri interface {
+ isDocumentContent_DataOrUri()
+}
+
+type DocumentContent_Data struct {
+ Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"`
+}
+
+type DocumentContent_Uri struct {
+ Uri string `protobuf:"bytes,5,opt,name=uri,proto3,oneof"`
+}
+
+func (*DocumentContent_Data) isDocumentContent_DataOrUri() {}
+
+func (*DocumentContent_Uri) isDocumentContent_DataOrUri() {}
+
+// A video content block.
+type VideoContent struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ MimeType VideoContent_MimeType `protobuf:"varint,1,opt,name=mime_type,json=mimeType,proto3,enum=ax.VideoContent_MimeType" json:"mime_type,omitempty"`
+ // Types that are valid to be assigned to DataOrUri:
+ //
+ // *VideoContent_Data
+ // *VideoContent_Uri
+ DataOrUri isVideoContent_DataOrUri `protobuf_oneof:"data_or_uri"`
+ Resolution MediaResolution `protobuf:"varint,5,opt,name=resolution,proto3,enum=ax.MediaResolution" json:"resolution,omitempty"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *VideoContent) Reset() {
+ *x = VideoContent{}
+ mi := &file_proto_content_proto_msgTypes[13]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *VideoContent) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*VideoContent) ProtoMessage() {}
+
+func (x *VideoContent) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[13]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use VideoContent.ProtoReflect.Descriptor instead.
+func (*VideoContent) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{13}
+}
+
+func (x *VideoContent) GetMimeType() VideoContent_MimeType {
+ if x != nil {
+ return x.MimeType
+ }
+ return VideoContent_TYPE_UNSPECIFIED
+}
+
+func (x *VideoContent) GetDataOrUri() isVideoContent_DataOrUri {
+ if x != nil {
+ return x.DataOrUri
+ }
+ return nil
+}
+
+func (x *VideoContent) GetData() []byte {
+ if x != nil {
+ if x, ok := x.DataOrUri.(*VideoContent_Data); ok {
+ return x.Data
+ }
+ }
+ return nil
+}
+
+func (x *VideoContent) GetUri() string {
+ if x != nil {
+ if x, ok := x.DataOrUri.(*VideoContent_Uri); ok {
+ return x.Uri
+ }
+ }
+ return ""
+}
+
+func (x *VideoContent) GetResolution() MediaResolution {
+ if x != nil {
+ return x.Resolution
+ }
+ return MediaResolution_MEDIA_RESOLUTION_UNSPECIFIED
+}
+
+type isVideoContent_DataOrUri interface {
+ isVideoContent_DataOrUri()
+}
+
+type VideoContent_Data struct {
+ Data []byte `protobuf:"bytes,2,opt,name=data,proto3,oneof"`
+}
+
+type VideoContent_Uri struct {
+ Uri string `protobuf:"bytes,6,opt,name=uri,proto3,oneof"`
+}
+
+func (*VideoContent_Data) isVideoContent_DataOrUri() {}
+
+func (*VideoContent_Uri) isVideoContent_DataOrUri() {}
+
+// Content represents a content input or output.
+type Content struct {
+ state protoimpl.MessageState `protogen:"open.v1"`
+ // Types that are valid to be assigned to Type:
+ //
+ // *Content_Thought
+ // *Content_Text
+ // *Content_Image
+ // *Content_Audio
+ // *Content_Document
+ // *Content_Video
+ // *Content_Confirmation
+ // *Content_ToolCall
+ // *Content_ToolResult
+ Type isContent_Type `protobuf_oneof:"type"`
+ unknownFields protoimpl.UnknownFields
+ sizeCache protoimpl.SizeCache
+}
+
+func (x *Content) Reset() {
+ *x = Content{}
+ mi := &file_proto_content_proto_msgTypes[14]
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ ms.StoreMessageInfo(mi)
+}
+
+func (x *Content) String() string {
+ return protoimpl.X.MessageStringOf(x)
+}
+
+func (*Content) ProtoMessage() {}
+
+func (x *Content) ProtoReflect() protoreflect.Message {
+ mi := &file_proto_content_proto_msgTypes[14]
+ if x != nil {
+ ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
+ if ms.LoadMessageInfo() == nil {
+ ms.StoreMessageInfo(mi)
+ }
+ return ms
+ }
+ return mi.MessageOf(x)
+}
+
+// Deprecated: Use Content.ProtoReflect.Descriptor instead.
+func (*Content) Descriptor() ([]byte, []int) {
+ return file_proto_content_proto_rawDescGZIP(), []int{14}
+}
+
+func (x *Content) GetType() isContent_Type {
+ if x != nil {
+ return x.Type
+ }
+ return nil
+}
+
+func (x *Content) GetThought() *ThoughtContent {
+ if x != nil {
+ if x, ok := x.Type.(*Content_Thought); ok {
+ return x.Thought
+ }
+ }
+ return nil
+}
+
+func (x *Content) GetText() *TextContent {
+ if x != nil {
+ if x, ok := x.Type.(*Content_Text); ok {
+ return x.Text
+ }
+ }
+ return nil
+}
+
+func (x *Content) GetImage() *ImageContent {
+ if x != nil {
+ if x, ok := x.Type.(*Content_Image); ok {
+ return x.Image
+ }
+ }
+ return nil
+}
+
+func (x *Content) GetAudio() *AudioContent {
+ if x != nil {
+ if x, ok := x.Type.(*Content_Audio); ok {
+ return x.Audio
+ }
+ }
+ return nil
+}
+
+func (x *Content) GetDocument() *DocumentContent {
+ if x != nil {
+ if x, ok := x.Type.(*Content_Document); ok {
+ return x.Document
+ }
+ }
+ return nil
+}
+
+func (x *Content) GetVideo() *VideoContent {
+ if x != nil {
+ if x, ok := x.Type.(*Content_Video); ok {
+ return x.Video
+ }
+ }
+ return nil
+}
+
+func (x *Content) GetConfirmation() *ConfirmationContent {
+ if x != nil {
+ if x, ok := x.Type.(*Content_Confirmation); ok {
+ return x.Confirmation
+ }
+ }
+ return nil
+}
+
+func (x *Content) GetToolCall() *ToolCallContent {
+ if x != nil {
+ if x, ok := x.Type.(*Content_ToolCall); ok {
+ return x.ToolCall
+ }
+ }
+ return nil
+}
+
+func (x *Content) GetToolResult() *ToolResultContent {
+ if x != nil {
+ if x, ok := x.Type.(*Content_ToolResult); ok {
+ return x.ToolResult
+ }
+ }
+ return nil
+}
+
+type isContent_Type interface {
+ isContent_Type()
+}
+
+type Content_Thought struct {
+ Thought *ThoughtContent `protobuf:"bytes,5,opt,name=thought,proto3,oneof"`
+}
+
+type Content_Text struct {
+ Text *TextContent `protobuf:"bytes,10,opt,name=text,proto3,oneof"`
+}
+
+type Content_Image struct {
+ Image *ImageContent `protobuf:"bytes,11,opt,name=image,proto3,oneof"`
+}
+
+type Content_Audio struct {
+ Audio *AudioContent `protobuf:"bytes,12,opt,name=audio,proto3,oneof"`
+}
+
+type Content_Document struct {
+ Document *DocumentContent `protobuf:"bytes,13,opt,name=document,proto3,oneof"`
+}
+
+type Content_Video struct {
+ Video *VideoContent `protobuf:"bytes,14,opt,name=video,proto3,oneof"`
+}
+
+type Content_Confirmation struct {
+ Confirmation *ConfirmationContent `protobuf:"bytes,26,opt,name=confirmation,proto3,oneof"` // TODO(jbd): Remove out of the Content.
+}
+
+type Content_ToolCall struct {
+ ToolCall *ToolCallContent `protobuf:"bytes,24,opt,name=tool_call,json=toolCall,proto3,oneof"`
+}
+
+type Content_ToolResult struct {
+ ToolResult *ToolResultContent `protobuf:"bytes,25,opt,name=tool_result,json=toolResult,proto3,oneof"`
+}
+
+func (*Content_Thought) isContent_Type() {}
+
+func (*Content_Text) isContent_Type() {}
+
+func (*Content_Image) isContent_Type() {}
+
+func (*Content_Audio) isContent_Type() {}
+
+func (*Content_Document) isContent_Type() {}
+
+func (*Content_Video) isContent_Type() {}
+
+func (*Content_Confirmation) isContent_Type() {}
+
+func (*Content_ToolCall) isContent_Type() {}
+
+func (*Content_ToolResult) isContent_Type() {}
+
+var File_proto_content_proto protoreflect.FileDescriptor
+
+const file_proto_content_proto_rawDesc = "" +
+ "\n" +
+ "\x13proto/content.proto\x12\x02ax\x1a\x1cgoogle/protobuf/struct.proto\"3\n" +
+ "\vTextContent\x12\x12\n" +
+ "\x04text\x18\x03 \x01(\tR\x04textJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x04type\".\n" +
+ "\x10ApprovalDecision\x12\x1a\n" +
+ "\bapproved\x18\x01 \x01(\bR\bapproved\";\n" +
+ "\x0fDeclineDecision\x12\x1a\n" +
+ "\bdeclined\x18\x01 \x01(\bR\bdeclinedJ\x04\b\x02\x10\x03R\x06reason\"\xc4\x01\n" +
+ "\x13ConfirmationContent\x12\x0e\n" +
+ "\x02id\x18\x03 \x01(\tR\x02id\x12\x1a\n" +
+ "\bquestion\x18\x04 \x01(\tR\bquestion\x122\n" +
+ "\bapproval\x18\x05 \x01(\v2\x14.ax.ApprovalDecisionH\x00R\bapproval\x12/\n" +
+ "\adecline\x18\x06 \x01(\v2\x13.ax.DeclineDecisionH\x00R\adeclineB\n" +
+ "\n" +
+ "\bdecisionJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03R\x04type\"L\n" +
+ "\x15ThoughtSummaryContent\x12%\n" +
+ "\x04text\x18\x01 \x01(\v2\x0f.ax.TextContentH\x00R\x04textB\x06\n" +
+ "\x04typeJ\x04\b\x02\x10\x03\"\x8d\x01\n" +
+ "\x0eThoughtContent\x12\x1c\n" +
+ "\tsignature\x18\a \x01(\fR\tsignature\x123\n" +
+ "\asummary\x18\t \x03(\v2\x19.ax.ThoughtSummaryContentR\asummaryJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03J\x04\b\x03\x10\x04J\x04\b\x04\x10\x05J\x04\b\x05\x10\x06J\x04\b\b\x10\tR\x04type\"\x87\x01\n" +
+ "\x0fToolCallContent\x12\x0e\n" +
+ "\x02id\x18\x01 \x01(\tR\x02id\x12\x1c\n" +
+ "\tsignature\x18\t \x01(\fR\tsignature\x12>\n" +
+ "\rfunction_call\x18\x02 \x01(\v2\x17.ax.FunctionCallContentH\x00R\ffunctionCallB\x06\n" +
+ "\x04type\"\x9e\x01\n" +
+ "\x11ToolResultContent\x12\x17\n" +
+ "\acall_id\x18\b \x01(\tR\x06callId\x12\x1c\n" +
+ "\tsignature\x18\t \x01(\fR\tsignature\x12D\n" +
+ "\x0ffunction_result\x18\x02 \x01(\v2\x19.ax.FunctionResultContentH\x00R\x0efunctionResultB\x06\n" +
+ "\x04typeJ\x04\b\x01\x10\x02\"l\n" +
+ "\x13FunctionCallContent\x12\x12\n" +
+ "\x04name\x18\x03 \x01(\tR\x04name\x125\n" +
+ "\targuments\x18\x04 \x01(\v2\x17.google.protobuf.StructR\targumentsJ\x04\b\x01\x10\x02J\x04\b\x02\x10\x03\"~\n" +
+ "\x15FunctionResultContent\x12\x12\n" +
+ "\x04name\x18\b \x01(\tR\x04name\x125\n" +
+ "\bresponse\x18\x03 \x01(\v2\x17.google.protobuf.StructH\x00R\bresponseB\b\n" +
+ "\x06resultJ\x04\b\x01\x10\x02J\x04\b\x04\x10\x05R\x04type\"\xde\x02\n" +
+ "\fImageContent\x126\n" +
+ "\tmime_type\x18\x01 \x01(\x0e2\x19.ax.ImageContent.MimeTypeR\bmimeType\x12\x14\n" +
+ "\x04data\x18\x02 \x01(\fH\x00R\x04data\x12\x12\n" +
+ "\x03uri\x18\x06 \x01(\tH\x00R\x03uri\x123\n" +
+ "\n" +
+ "resolution\x18\x05 \x01(\x0e2\x13.ax.MediaResolutionR\n" +
+ "resolution\"\x9b\x01\n" +
+ "\bMimeType\x12\x14\n" +
+ "\x10TYPE_UNSPECIFIED\x10\x00\x12\f\n" +
+ "\bTYPE_PNG\x10\x01\x12\r\n" +
+ "\tTYPE_JPEG\x10\x02\x12\r\n" +
+ "\tTYPE_WEBP\x10\x03\x12\r\n" +
+ "\tTYPE_HEIC\x10\x04\x12\r\n" +
+ "\tTYPE_HEIF\x10\x05\x12\f\n" +
+ "\bTYPE_GIF\x10\a\x12\f\n" +
+ "\bTYPE_BMP\x10\b\x12\r\n" +
+ "\tTYPE_TIFF\x10\t\"\x04\b\x06\x10\x06B\r\n" +
+ "\vdata_or_uriJ\x04\b\x03\x10\x04R\x04type\"\xb6\x03\n" +
+ "\fAudioContent\x126\n" +
+ "\tmime_type\x18\x01 \x01(\x0e2\x19.ax.AudioContent.MimeTypeR\bmimeType\x12\x14\n" +
+ "\x04data\x18\x02 \x01(\fH\x00R\x04data\x12\x12\n" +
+ "\x03uri\x18\x05 \x01(\tH\x00R\x03uri\x12\x1a\n" +
+ "\bchannels\x18\a \x01(\x05R\bchannels\x12\x1f\n" +
+ "\vsample_rate\x18\b \x01(\x05R\n" +
+ "sampleRate\"\xdf\x01\n" +
+ "\bMimeType\x12\x14\n" +
+ "\x10TYPE_UNSPECIFIED\x10\x00\x12\f\n" +
+ "\bTYPE_WAV\x10\x01\x12\f\n" +
+ "\bTYPE_MP3\x10\x02\x12\r\n" +
+ "\tTYPE_AIFF\x10\x03\x12\f\n" +
+ "\bTYPE_AAC\x10\x04\x12\f\n" +
+ "\bTYPE_OGG\x10\x05\x12\r\n" +
+ "\tTYPE_FLAC\x10\x06\x12\r\n" +
+ "\tTYPE_MPEG\x10\a\x12\f\n" +
+ "\bTYPE_M4A\x10\b\x12\f\n" +
+ "\bTYPE_L16\x10\t\x12\x0e\n" +
+ "\n" +
+ "TYPE_S16LE\x10\n" +
+ "\x12\r\n" +
+ "\tTYPE_OPUS\x10\v\x12\r\n" +
+ "\tTYPE_ALAW\x10\f\x12\x0e\n" +
+ "\n" +
+ "TYPE_MULAW\x10\rB\r\n" +
+ "\vdata_or_uriJ\x04\b\x03\x10\x04J\x04\b\x06\x10\aR\x04typeR\x04rate\"\xe1\x01\n" +
+ "\x0fDocumentContent\x129\n" +
+ "\tmime_type\x18\x01 \x01(\x0e2\x1c.ax.DocumentContent.MimeTypeR\bmimeType\x12\x14\n" +
+ "\x04data\x18\x02 \x01(\fH\x00R\x04data\x12\x12\n" +
+ "\x03uri\x18\x05 \x01(\tH\x00R\x03uri\"N\n" +
+ "\bMimeType\x12\x14\n" +
+ "\x10TYPE_UNSPECIFIED\x10\x00\x12\f\n" +
+ "\bTYPE_PDF\x10\x01\x12\r\n" +
+ "\tTYPE_JSON\x10\x02\x12\x0f\n" +
+ "\vTYPE_PYTHON\x10\x03B\r\n" +
+ "\vdata_or_uriJ\x04\b\x03\x10\x04R\x04type\"\xe6\x02\n" +
+ "\fVideoContent\x126\n" +
+ "\tmime_type\x18\x01 \x01(\x0e2\x19.ax.VideoContent.MimeTypeR\bmimeType\x12\x14\n" +
+ "\x04data\x18\x02 \x01(\fH\x00R\x04data\x12\x12\n" +
+ "\x03uri\x18\x06 \x01(\tH\x00R\x03uri\x123\n" +
+ "\n" +
+ "resolution\x18\x05 \x01(\x0e2\x13.ax.MediaResolutionR\n" +
+ "resolution\"\xa3\x01\n" +
+ "\bMimeType\x12\x14\n" +
+ "\x10TYPE_UNSPECIFIED\x10\x00\x12\f\n" +
+ "\bTYPE_MP4\x10\x01\x12\r\n" +
+ "\tTYPE_MPEG\x10\x02\x12\f\n" +
+ "\bTYPE_MPG\x10\x03\x12\f\n" +
+ "\bTYPE_MOV\x10\x04\x12\f\n" +
+ "\bTYPE_AVI\x10\x05\x12\x0e\n" +
+ "\n" +
+ "TYPE_X_FLV\x10\x06\x12\r\n" +
+ "\tTYPE_WEBM\x10\a\x12\f\n" +
+ "\bTYPE_WMV\x10\b\x12\r\n" +
+ "\tTYPE_3GPP\x10\tB\r\n" +
+ "\vdata_or_uriJ\x04\b\x03\x10\x04R\x04type\"\xd8\x03\n" +
+ "\aContent\x12.\n" +
+ "\athought\x18\x05 \x01(\v2\x12.ax.ThoughtContentH\x00R\athought\x12%\n" +
+ "\x04text\x18\n" +
+ " \x01(\v2\x0f.ax.TextContentH\x00R\x04text\x12(\n" +
+ "\x05image\x18\v \x01(\v2\x10.ax.ImageContentH\x00R\x05image\x12(\n" +
+ "\x05audio\x18\f \x01(\v2\x10.ax.AudioContentH\x00R\x05audio\x121\n" +
+ "\bdocument\x18\r \x01(\v2\x13.ax.DocumentContentH\x00R\bdocument\x12(\n" +
+ "\x05video\x18\x0e \x01(\v2\x10.ax.VideoContentH\x00R\x05video\x12=\n" +
+ "\fconfirmation\x18\x1a \x01(\v2\x17.ax.ConfirmationContentH\x00R\fconfirmation\x122\n" +
+ "\ttool_call\x18\x18 \x01(\v2\x13.ax.ToolCallContentH\x00R\btoolCall\x128\n" +
+ "\vtool_result\x18\x19 \x01(\v2\x15.ax.ToolResultContentH\x00R\n" +
+ "toolResultB\x06\n" +
+ "\x04typeJ\x04\b\x01\x10\x05J\x04\b\x06\x10\n" +
+ "J\x04\b\x0f\x10\x18*b\n" +
+ "\x0fMediaResolution\x12 \n" +
+ "\x1cMEDIA_RESOLUTION_UNSPECIFIED\x10\x00\x12\a\n" +
+ "\x03LOW\x10\x01\x12\n" +
+ "\n" +
+ "\x06MEDIUM\x10\x02\x12\b\n" +
+ "\x04HIGH\x10\x03\x12\x0e\n" +
+ "\n" +
+ "ULTRA_HIGH\x10\x04B\x1cZ\x1agithub.com/google/ax/protob\x06proto3"
+
+var (
+ file_proto_content_proto_rawDescOnce sync.Once
+ file_proto_content_proto_rawDescData []byte
+)
+
+func file_proto_content_proto_rawDescGZIP() []byte {
+ file_proto_content_proto_rawDescOnce.Do(func() {
+ file_proto_content_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_proto_content_proto_rawDesc), len(file_proto_content_proto_rawDesc)))
+ })
+ return file_proto_content_proto_rawDescData
+}
+
+var file_proto_content_proto_enumTypes = make([]protoimpl.EnumInfo, 5)
+var file_proto_content_proto_msgTypes = make([]protoimpl.MessageInfo, 15)
+var file_proto_content_proto_goTypes = []any{
+ (MediaResolution)(0), // 0: ax.MediaResolution
+ (ImageContent_MimeType)(0), // 1: ax.ImageContent.MimeType
+ (AudioContent_MimeType)(0), // 2: ax.AudioContent.MimeType
+ (DocumentContent_MimeType)(0), // 3: ax.DocumentContent.MimeType
+ (VideoContent_MimeType)(0), // 4: ax.VideoContent.MimeType
+ (*TextContent)(nil), // 5: ax.TextContent
+ (*ApprovalDecision)(nil), // 6: ax.ApprovalDecision
+ (*DeclineDecision)(nil), // 7: ax.DeclineDecision
+ (*ConfirmationContent)(nil), // 8: ax.ConfirmationContent
+ (*ThoughtSummaryContent)(nil), // 9: ax.ThoughtSummaryContent
+ (*ThoughtContent)(nil), // 10: ax.ThoughtContent
+ (*ToolCallContent)(nil), // 11: ax.ToolCallContent
+ (*ToolResultContent)(nil), // 12: ax.ToolResultContent
+ (*FunctionCallContent)(nil), // 13: ax.FunctionCallContent
+ (*FunctionResultContent)(nil), // 14: ax.FunctionResultContent
+ (*ImageContent)(nil), // 15: ax.ImageContent
+ (*AudioContent)(nil), // 16: ax.AudioContent
+ (*DocumentContent)(nil), // 17: ax.DocumentContent
+ (*VideoContent)(nil), // 18: ax.VideoContent
+ (*Content)(nil), // 19: ax.Content
+ (*structpb.Struct)(nil), // 20: google.protobuf.Struct
+}
+var file_proto_content_proto_depIdxs = []int32{
+ 6, // 0: ax.ConfirmationContent.approval:type_name -> ax.ApprovalDecision
+ 7, // 1: ax.ConfirmationContent.decline:type_name -> ax.DeclineDecision
+ 5, // 2: ax.ThoughtSummaryContent.text:type_name -> ax.TextContent
+ 9, // 3: ax.ThoughtContent.summary:type_name -> ax.ThoughtSummaryContent
+ 13, // 4: ax.ToolCallContent.function_call:type_name -> ax.FunctionCallContent
+ 14, // 5: ax.ToolResultContent.function_result:type_name -> ax.FunctionResultContent
+ 20, // 6: ax.FunctionCallContent.arguments:type_name -> google.protobuf.Struct
+ 20, // 7: ax.FunctionResultContent.response:type_name -> google.protobuf.Struct
+ 1, // 8: ax.ImageContent.mime_type:type_name -> ax.ImageContent.MimeType
+ 0, // 9: ax.ImageContent.resolution:type_name -> ax.MediaResolution
+ 2, // 10: ax.AudioContent.mime_type:type_name -> ax.AudioContent.MimeType
+ 3, // 11: ax.DocumentContent.mime_type:type_name -> ax.DocumentContent.MimeType
+ 4, // 12: ax.VideoContent.mime_type:type_name -> ax.VideoContent.MimeType
+ 0, // 13: ax.VideoContent.resolution:type_name -> ax.MediaResolution
+ 10, // 14: ax.Content.thought:type_name -> ax.ThoughtContent
+ 5, // 15: ax.Content.text:type_name -> ax.TextContent
+ 15, // 16: ax.Content.image:type_name -> ax.ImageContent
+ 16, // 17: ax.Content.audio:type_name -> ax.AudioContent
+ 17, // 18: ax.Content.document:type_name -> ax.DocumentContent
+ 18, // 19: ax.Content.video:type_name -> ax.VideoContent
+ 8, // 20: ax.Content.confirmation:type_name -> ax.ConfirmationContent
+ 11, // 21: ax.Content.tool_call:type_name -> ax.ToolCallContent
+ 12, // 22: ax.Content.tool_result:type_name -> ax.ToolResultContent
+ 23, // [23:23] is the sub-list for method output_type
+ 23, // [23:23] is the sub-list for method input_type
+ 23, // [23:23] is the sub-list for extension type_name
+ 23, // [23:23] is the sub-list for extension extendee
+ 0, // [0:23] is the sub-list for field type_name
+}
+
+func init() { file_proto_content_proto_init() }
+func file_proto_content_proto_init() {
+ if File_proto_content_proto != nil {
+ return
+ }
+ file_proto_content_proto_msgTypes[3].OneofWrappers = []any{
+ (*ConfirmationContent_Approval)(nil),
+ (*ConfirmationContent_Decline)(nil),
+ }
+ file_proto_content_proto_msgTypes[4].OneofWrappers = []any{
+ (*ThoughtSummaryContent_Text)(nil),
+ }
+ file_proto_content_proto_msgTypes[6].OneofWrappers = []any{
+ (*ToolCallContent_FunctionCall)(nil),
+ }
+ file_proto_content_proto_msgTypes[7].OneofWrappers = []any{
+ (*ToolResultContent_FunctionResult)(nil),
+ }
+ file_proto_content_proto_msgTypes[9].OneofWrappers = []any{
+ (*FunctionResultContent_Response)(nil),
+ }
+ file_proto_content_proto_msgTypes[10].OneofWrappers = []any{
+ (*ImageContent_Data)(nil),
+ (*ImageContent_Uri)(nil),
+ }
+ file_proto_content_proto_msgTypes[11].OneofWrappers = []any{
+ (*AudioContent_Data)(nil),
+ (*AudioContent_Uri)(nil),
+ }
+ file_proto_content_proto_msgTypes[12].OneofWrappers = []any{
+ (*DocumentContent_Data)(nil),
+ (*DocumentContent_Uri)(nil),
+ }
+ file_proto_content_proto_msgTypes[13].OneofWrappers = []any{
+ (*VideoContent_Data)(nil),
+ (*VideoContent_Uri)(nil),
+ }
+ file_proto_content_proto_msgTypes[14].OneofWrappers = []any{
+ (*Content_Thought)(nil),
+ (*Content_Text)(nil),
+ (*Content_Image)(nil),
+ (*Content_Audio)(nil),
+ (*Content_Document)(nil),
+ (*Content_Video)(nil),
+ (*Content_Confirmation)(nil),
+ (*Content_ToolCall)(nil),
+ (*Content_ToolResult)(nil),
+ }
+ type x struct{}
+ out := protoimpl.TypeBuilder{
+ File: protoimpl.DescBuilder{
+ GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
+ RawDescriptor: unsafe.Slice(unsafe.StringData(file_proto_content_proto_rawDesc), len(file_proto_content_proto_rawDesc)),
+ NumEnums: 5,
+ NumMessages: 15,
+ NumExtensions: 0,
+ NumServices: 0,
+ },
+ GoTypes: file_proto_content_proto_goTypes,
+ DependencyIndexes: file_proto_content_proto_depIdxs,
+ EnumInfos: file_proto_content_proto_enumTypes,
+ MessageInfos: file_proto_content_proto_msgTypes,
+ }.Build()
+ File_proto_content_proto = out.File
+ file_proto_content_proto_goTypes = nil
+ file_proto_content_proto_depIdxs = nil
+}
diff --git a/proto/content.proto b/proto/content.proto
new file mode 100644
index 00000000..20cf98f1
--- /dev/null
+++ b/proto/content.proto
@@ -0,0 +1,225 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+syntax = "proto3";
+
+package ax;
+
+import "google/protobuf/struct.proto";
+
+option go_package = "github.com/google/ax/proto";
+
+// TextContent represents a text content.
+message TextContent {
+ reserved 1, 2;
+ reserved "type";
+ string text = 3;
+}
+
+message ApprovalDecision {
+ bool approved = 1;
+}
+
+message DeclineDecision {
+ reserved 2;
+ reserved "reason";
+ bool declined = 1;
+}
+
+message ConfirmationContent {
+ reserved 1, 2;
+ reserved "type";
+ string id = 3;
+ string question = 4;
+ oneof decision {
+ ApprovalDecision approval = 5;
+ DeclineDecision decline = 6;
+ }
+}
+
+message ThoughtSummaryContent {
+ reserved 2;
+
+ oneof type {
+ TextContent text = 1;
+ }
+}
+
+message ThoughtContent {
+ reserved 1, 2, 3, 4, 5, 8;
+ reserved "type";
+
+ bytes signature = 7;
+ repeated ThoughtSummaryContent summary = 9;
+}
+
+message ToolCallContent {
+ string id = 1; // A unique ID for this specific tool call.
+ bytes signature = 9; // A signature hash for backend validation.
+ oneof type {
+ FunctionCallContent function_call = 2;
+ }
+}
+
+message ToolResultContent {
+ reserved 1;
+
+ string call_id = 8; // ID to match the ID from the function call block.
+ bytes signature = 9; // A signature hash for backend validation.
+
+ oneof type {
+ FunctionResultContent function_result = 2;
+ }
+}
+
+message FunctionCallContent {
+ reserved 1, 2;
+
+ string name = 3;
+ google.protobuf.Struct arguments = 4;
+}
+
+message FunctionResultContent {
+ reserved 1, 4;
+ reserved "type";
+
+ string name = 8;
+ oneof result {
+ google.protobuf.Struct response = 3;
+ }
+}
+
+// Resolution for input media (images/video).
+enum MediaResolution {
+ MEDIA_RESOLUTION_UNSPECIFIED = 0;
+ LOW = 1;
+ MEDIUM = 2;
+ HIGH = 3;
+ ULTRA_HIGH = 4;
+}
+
+// An image content block.
+message ImageContent {
+ reserved 3;
+ reserved "type";
+
+ enum MimeType {
+ reserved 6;
+ TYPE_UNSPECIFIED = 0;
+ TYPE_PNG = 1; // image/png
+ TYPE_JPEG = 2; // image/jpeg
+ TYPE_WEBP = 3; // image/webp
+ TYPE_HEIC = 4; // image/heic
+ TYPE_HEIF = 5; // image/heif
+ TYPE_GIF = 7; // image/gif
+ TYPE_BMP = 8; // image/bmp
+ TYPE_TIFF = 9; // image/tiff
+ }
+ MimeType mime_type = 1;
+ oneof data_or_uri {
+ bytes data = 2;
+ string uri = 6;
+ }
+ MediaResolution resolution = 5;
+}
+
+// An audio content block.
+message AudioContent {
+ reserved 3, 6;
+ reserved "type", "rate";
+
+ enum MimeType {
+ TYPE_UNSPECIFIED = 0;
+ TYPE_WAV = 1; // audio/wav
+ TYPE_MP3 = 2; // audio/mp3
+ TYPE_AIFF = 3; // audio/aiff
+ TYPE_AAC = 4; // audio/aac
+ TYPE_OGG = 5; // audio/ogg
+ TYPE_FLAC = 6; // audio/flac
+ TYPE_MPEG = 7; // audio/mpeg
+ TYPE_M4A = 8; // audio/m4a
+ TYPE_L16 = 9; // audio/l16
+ TYPE_S16LE = 10; // audio/s16le
+ TYPE_OPUS = 11; // audio/opus
+ TYPE_ALAW = 12; // audio/alaw
+ TYPE_MULAW = 13; // audio/mulaw
+ }
+ MimeType mime_type = 1;
+ oneof data_or_uri {
+ bytes data = 2;
+ string uri = 5;
+ }
+ int32 channels = 7;
+ int32 sample_rate = 8;
+}
+
+// A document content block.
+message DocumentContent {
+ reserved 3;
+ reserved "type";
+
+ enum MimeType {
+ TYPE_UNSPECIFIED = 0;
+ TYPE_PDF = 1; // application/pdf
+ TYPE_JSON = 2; // application/json
+ TYPE_PYTHON = 3; // text/x-python
+ }
+ MimeType mime_type = 1;
+ oneof data_or_uri {
+ bytes data = 2;
+ string uri = 5;
+ }
+}
+
+// A video content block.
+message VideoContent {
+ reserved 3;
+ reserved "type";
+
+ enum MimeType {
+ TYPE_UNSPECIFIED = 0;
+ TYPE_MP4 = 1; // video/mp4
+ TYPE_MPEG = 2; // video/mpeg
+ TYPE_MPG = 3; // video/mpg
+ TYPE_MOV = 4; // video/mov
+ TYPE_AVI = 5; // video/avi
+ TYPE_X_FLV = 6; // video/x-flv
+ TYPE_WEBM = 7; // video/webm
+ TYPE_WMV = 8; // video/wmv
+ TYPE_3GPP = 9; // video/3gpp
+ }
+ MimeType mime_type = 1;
+ oneof data_or_uri {
+ bytes data = 2;
+ string uri = 6;
+ }
+ MediaResolution resolution = 5;
+}
+
+// Content represents a content input or output.
+message Content {
+ reserved 1 to 4, 6 to 9, 15 to 23;
+
+ oneof type {
+ ThoughtContent thought = 5;
+ TextContent text = 10;
+ ImageContent image = 11;
+ AudioContent audio = 12;
+ DocumentContent document = 13;
+ VideoContent video = 14;
+ ConfirmationContent confirmation = 26; // TODO(jbd): Remove out of the Content.
+ ToolCallContent tool_call = 24;
+ ToolResultContent tool_result = 25;
+ }
+}
diff --git a/proto/gar.pb.go b/proto/gar.pb.go
deleted file mode 100644
index cd325fd0..00000000
--- a/proto/gar.pb.go
+++ /dev/null
@@ -1,958 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Code generated by protoc-gen-go. DO NOT EDIT.
-// versions:
-// protoc-gen-go v1.35.1
-// protoc v5.28.2
-// source: proto/gar.proto
-
-package proto
-
-import (
- protoreflect "google.golang.org/protobuf/reflect/protoreflect"
- protoimpl "google.golang.org/protobuf/runtime/protoimpl"
- timestamppb "google.golang.org/protobuf/types/known/timestamppb"
- reflect "reflect"
- sync "sync"
-)
-
-const (
- // Verify that this generated code is sufficiently up-to-date.
- _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
- // Verify that runtime/protoimpl is sufficiently up-to-date.
- _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
-)
-
-// State represents the state of a session
-type State int32
-
-const (
- State_STATE_UNSPECIFIED State = 0 // Unspecified state
- State_STATE_RUNNING State = 1 // Session is currently running
- State_STATE_FAILED State = 2 // Session failed
- State_STATE_COMPLETED State = 3 // Completed, can accept new triggers
-)
-
-// Enum value maps for State.
-var (
- State_name = map[int32]string{
- 0: "STATE_UNSPECIFIED",
- 1: "STATE_RUNNING",
- 2: "STATE_FAILED",
- 3: "STATE_COMPLETED",
- }
- State_value = map[string]int32{
- "STATE_UNSPECIFIED": 0,
- "STATE_RUNNING": 1,
- "STATE_FAILED": 2,
- "STATE_COMPLETED": 3,
- }
-)
-
-func (x State) Enum() *State {
- p := new(State)
- *p = x
- return p
-}
-
-func (x State) String() string {
- return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x))
-}
-
-func (State) Descriptor() protoreflect.EnumDescriptor {
- return file_proto_gar_proto_enumTypes[0].Descriptor()
-}
-
-func (State) Type() protoreflect.EnumType {
- return &file_proto_gar_proto_enumTypes[0]
-}
-
-func (x State) Number() protoreflect.EnumNumber {
- return protoreflect.EnumNumber(x)
-}
-
-// Deprecated: Use State.Descriptor instead.
-func (State) EnumDescriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{0}
-}
-
-// Content represents a message with role, type, mimetype, and data fields
-type Content struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-
- Role string `protobuf:"bytes,1,opt,name=role,proto3" json:"role,omitempty"` // The role of the content (e.g., "user", "assistant", "system")
- Type string `protobuf:"bytes,2,opt,name=type,proto3" json:"type,omitempty"` // The type of content (e.g., "text", "image")
- Mimetype string `protobuf:"bytes,3,opt,name=mimetype,proto3" json:"mimetype,omitempty"` // MIME type of the data (e.g., "text/plain", "image/png")
- Data string `protobuf:"bytes,4,opt,name=data,proto3" json:"data,omitempty"` // The actual content data
- CheckpointId string `protobuf:"bytes,5,opt,name=checkpoint_id,json=checkpointId,proto3" json:"checkpoint_id,omitempty"` // Optional: Checkpoint ID for this content
-}
-
-func (x *Content) Reset() {
- *x = Content{}
- mi := &file_proto_gar_proto_msgTypes[0]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *Content) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*Content) ProtoMessage() {}
-
-func (x *Content) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[0]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use Content.ProtoReflect.Descriptor instead.
-func (*Content) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{0}
-}
-
-func (x *Content) GetRole() string {
- if x != nil {
- return x.Role
- }
- return ""
-}
-
-func (x *Content) GetType() string {
- if x != nil {
- return x.Type
- }
- return ""
-}
-
-func (x *Content) GetMimetype() string {
- if x != nil {
- return x.Mimetype
- }
- return ""
-}
-
-func (x *Content) GetData() string {
- if x != nil {
- return x.Data
- }
- return ""
-}
-
-func (x *Content) GetCheckpointId() string {
- if x != nil {
- return x.CheckpointId
- }
- return ""
-}
-
-// HealthCheckRequest for agent health checks
-type HealthCheckRequest struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-}
-
-func (x *HealthCheckRequest) Reset() {
- *x = HealthCheckRequest{}
- mi := &file_proto_gar_proto_msgTypes[1]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *HealthCheckRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*HealthCheckRequest) ProtoMessage() {}
-
-func (x *HealthCheckRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[1]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use HealthCheckRequest.ProtoReflect.Descriptor instead.
-func (*HealthCheckRequest) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{1}
-}
-
-// HealthCheckResponse contains agent health status
-type HealthCheckResponse struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-
- Healthy bool `protobuf:"varint,1,opt,name=healthy,proto3" json:"healthy,omitempty"`
- Message string `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"`
-}
-
-func (x *HealthCheckResponse) Reset() {
- *x = HealthCheckResponse{}
- mi := &file_proto_gar_proto_msgTypes[2]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *HealthCheckResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*HealthCheckResponse) ProtoMessage() {}
-
-func (x *HealthCheckResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[2]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use HealthCheckResponse.ProtoReflect.Descriptor instead.
-func (*HealthCheckResponse) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{2}
-}
-
-func (x *HealthCheckResponse) GetHealthy() bool {
- if x != nil {
- return x.Healthy
- }
- return false
-}
-
-func (x *HealthCheckResponse) GetMessage() string {
- if x != nil {
- return x.Message
- }
- return ""
-}
-
-// TriggerSessionRequest for triggering a new session
-type TriggerSessionRequest struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-
- SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Unique session identifier
- CheckpointId string `protobuf:"bytes,2,opt,name=checkpoint_id,json=checkpointId,proto3" json:"checkpoint_id,omitempty"` // Optional: Resume from specific checkpoint UUID (empty for latest)
- Inputs []*Content `protobuf:"bytes,3,rep,name=inputs,proto3" json:"inputs,omitempty"` // Input content to process
-}
-
-func (x *TriggerSessionRequest) Reset() {
- *x = TriggerSessionRequest{}
- mi := &file_proto_gar_proto_msgTypes[3]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *TriggerSessionRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*TriggerSessionRequest) ProtoMessage() {}
-
-func (x *TriggerSessionRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[3]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use TriggerSessionRequest.ProtoReflect.Descriptor instead.
-func (*TriggerSessionRequest) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{3}
-}
-
-func (x *TriggerSessionRequest) GetSessionId() string {
- if x != nil {
- return x.SessionId
- }
- return ""
-}
-
-func (x *TriggerSessionRequest) GetCheckpointId() string {
- if x != nil {
- return x.CheckpointId
- }
- return ""
-}
-
-func (x *TriggerSessionRequest) GetInputs() []*Content {
- if x != nil {
- return x.Inputs
- }
- return nil
-}
-
-// TriggerSessionResponse contains the result of triggering a session
-type TriggerSessionResponse struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-
- SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` // Session ID (in case it was generated or modified by server)
- State State `protobuf:"varint,2,opt,name=state,proto3,enum=proto.State" json:"state,omitempty"` // Session state
- Output *Content `protobuf:"bytes,3,opt,name=output,proto3" json:"output,omitempty"` // Output content
-}
-
-func (x *TriggerSessionResponse) Reset() {
- *x = TriggerSessionResponse{}
- mi := &file_proto_gar_proto_msgTypes[4]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *TriggerSessionResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*TriggerSessionResponse) ProtoMessage() {}
-
-func (x *TriggerSessionResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[4]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use TriggerSessionResponse.ProtoReflect.Descriptor instead.
-func (*TriggerSessionResponse) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{4}
-}
-
-func (x *TriggerSessionResponse) GetSessionId() string {
- if x != nil {
- return x.SessionId
- }
- return ""
-}
-
-func (x *TriggerSessionResponse) GetState() State {
- if x != nil {
- return x.State
- }
- return State_STATE_UNSPECIFIED
-}
-
-func (x *TriggerSessionResponse) GetOutput() *Content {
- if x != nil {
- return x.Output
- }
- return nil
-}
-
-// GetSessionRequest for retrieving session details
-type GetSessionRequest struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-
- SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
-}
-
-func (x *GetSessionRequest) Reset() {
- *x = GetSessionRequest{}
- mi := &file_proto_gar_proto_msgTypes[5]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *GetSessionRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*GetSessionRequest) ProtoMessage() {}
-
-func (x *GetSessionRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[5]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use GetSessionRequest.ProtoReflect.Descriptor instead.
-func (*GetSessionRequest) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{5}
-}
-
-func (x *GetSessionRequest) GetSessionId() string {
- if x != nil {
- return x.SessionId
- }
- return ""
-}
-
-// SessionInfo contains session details
-type SessionInfo struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-
- State State `protobuf:"varint,1,opt,name=state,proto3,enum=proto.State" json:"state,omitempty"`
- ActiveAgents []string `protobuf:"bytes,2,rep,name=active_agents,json=activeAgents,proto3" json:"active_agents,omitempty"`
- CheckpointCount int32 `protobuf:"varint,3,opt,name=checkpoint_count,json=checkpointCount,proto3" json:"checkpoint_count,omitempty"`
- CreatedAt *timestamppb.Timestamp `protobuf:"bytes,4,opt,name=created_at,json=createdAt,proto3" json:"created_at,omitempty"` // Session creation time
- UpdatedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=updated_at,json=updatedAt,proto3" json:"updated_at,omitempty"` // Last update time
-}
-
-func (x *SessionInfo) Reset() {
- *x = SessionInfo{}
- mi := &file_proto_gar_proto_msgTypes[6]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *SessionInfo) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*SessionInfo) ProtoMessage() {}
-
-func (x *SessionInfo) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[6]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use SessionInfo.ProtoReflect.Descriptor instead.
-func (*SessionInfo) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{6}
-}
-
-func (x *SessionInfo) GetState() State {
- if x != nil {
- return x.State
- }
- return State_STATE_UNSPECIFIED
-}
-
-func (x *SessionInfo) GetActiveAgents() []string {
- if x != nil {
- return x.ActiveAgents
- }
- return nil
-}
-
-func (x *SessionInfo) GetCheckpointCount() int32 {
- if x != nil {
- return x.CheckpointCount
- }
- return 0
-}
-
-func (x *SessionInfo) GetCreatedAt() *timestamppb.Timestamp {
- if x != nil {
- return x.CreatedAt
- }
- return nil
-}
-
-func (x *SessionInfo) GetUpdatedAt() *timestamppb.Timestamp {
- if x != nil {
- return x.UpdatedAt
- }
- return nil
-}
-
-// GetSessionResponse contains session information
-type GetSessionResponse struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-
- Session *SessionInfo `protobuf:"bytes,1,opt,name=session,proto3" json:"session,omitempty"`
-}
-
-func (x *GetSessionResponse) Reset() {
- *x = GetSessionResponse{}
- mi := &file_proto_gar_proto_msgTypes[7]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *GetSessionResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*GetSessionResponse) ProtoMessage() {}
-
-func (x *GetSessionResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[7]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use GetSessionResponse.ProtoReflect.Descriptor instead.
-func (*GetSessionResponse) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{7}
-}
-
-func (x *GetSessionResponse) GetSession() *SessionInfo {
- if x != nil {
- return x.Session
- }
- return nil
-}
-
-// RegisterAgentRequest for registering an agent
-type RegisterAgentRequest struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-
- AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"`
- Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` // Human-readable name for the agent
- Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` // Description of agent capabilities
- Address string `protobuf:"bytes,4,opt,name=address,proto3" json:"address,omitempty"` // gRPC address for remote agents
- Metadata map[string]string `protobuf:"bytes,5,rep,name=metadata,proto3" json:"metadata,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"`
-}
-
-func (x *RegisterAgentRequest) Reset() {
- *x = RegisterAgentRequest{}
- mi := &file_proto_gar_proto_msgTypes[8]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *RegisterAgentRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*RegisterAgentRequest) ProtoMessage() {}
-
-func (x *RegisterAgentRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[8]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use RegisterAgentRequest.ProtoReflect.Descriptor instead.
-func (*RegisterAgentRequest) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{8}
-}
-
-func (x *RegisterAgentRequest) GetAgentId() string {
- if x != nil {
- return x.AgentId
- }
- return ""
-}
-
-func (x *RegisterAgentRequest) GetName() string {
- if x != nil {
- return x.Name
- }
- return ""
-}
-
-func (x *RegisterAgentRequest) GetDescription() string {
- if x != nil {
- return x.Description
- }
- return ""
-}
-
-func (x *RegisterAgentRequest) GetAddress() string {
- if x != nil {
- return x.Address
- }
- return ""
-}
-
-func (x *RegisterAgentRequest) GetMetadata() map[string]string {
- if x != nil {
- return x.Metadata
- }
- return nil
-}
-
-// RegisterAgentResponse contains registration result
-type RegisterAgentResponse struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-}
-
-func (x *RegisterAgentResponse) Reset() {
- *x = RegisterAgentResponse{}
- mi := &file_proto_gar_proto_msgTypes[9]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *RegisterAgentResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*RegisterAgentResponse) ProtoMessage() {}
-
-func (x *RegisterAgentResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[9]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use RegisterAgentResponse.ProtoReflect.Descriptor instead.
-func (*RegisterAgentResponse) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{9}
-}
-
-// UnregisterAgentRequest for unregistering an agent
-type UnregisterAgentRequest struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-
- AgentId string `protobuf:"bytes,1,opt,name=agent_id,json=agentId,proto3" json:"agent_id,omitempty"`
-}
-
-func (x *UnregisterAgentRequest) Reset() {
- *x = UnregisterAgentRequest{}
- mi := &file_proto_gar_proto_msgTypes[10]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *UnregisterAgentRequest) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*UnregisterAgentRequest) ProtoMessage() {}
-
-func (x *UnregisterAgentRequest) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[10]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use UnregisterAgentRequest.ProtoReflect.Descriptor instead.
-func (*UnregisterAgentRequest) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{10}
-}
-
-func (x *UnregisterAgentRequest) GetAgentId() string {
- if x != nil {
- return x.AgentId
- }
- return ""
-}
-
-// UnregisterAgentResponse contains unregistration result
-type UnregisterAgentResponse struct {
- state protoimpl.MessageState
- sizeCache protoimpl.SizeCache
- unknownFields protoimpl.UnknownFields
-}
-
-func (x *UnregisterAgentResponse) Reset() {
- *x = UnregisterAgentResponse{}
- mi := &file_proto_gar_proto_msgTypes[11]
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- ms.StoreMessageInfo(mi)
-}
-
-func (x *UnregisterAgentResponse) String() string {
- return protoimpl.X.MessageStringOf(x)
-}
-
-func (*UnregisterAgentResponse) ProtoMessage() {}
-
-func (x *UnregisterAgentResponse) ProtoReflect() protoreflect.Message {
- mi := &file_proto_gar_proto_msgTypes[11]
- if x != nil {
- ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
- if ms.LoadMessageInfo() == nil {
- ms.StoreMessageInfo(mi)
- }
- return ms
- }
- return mi.MessageOf(x)
-}
-
-// Deprecated: Use UnregisterAgentResponse.ProtoReflect.Descriptor instead.
-func (*UnregisterAgentResponse) Descriptor() ([]byte, []int) {
- return file_proto_gar_proto_rawDescGZIP(), []int{11}
-}
-
-var File_proto_gar_proto protoreflect.FileDescriptor
-
-var file_proto_gar_proto_rawDesc = []byte{
- 0x0a, 0x0f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2f, 0x67, 0x61, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74,
- 0x6f, 0x12, 0x05, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65,
- 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74,
- 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0x86, 0x01, 0x0a, 0x07, 0x43, 0x6f,
- 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x18, 0x01, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x04, 0x72, 0x6f, 0x6c, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70,
- 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x1a, 0x0a,
- 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x74, 0x79, 0x70, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
- 0x08, 0x6d, 0x69, 0x6d, 0x65, 0x74, 0x79, 0x70, 0x65, 0x12, 0x12, 0x0a, 0x04, 0x64, 0x61, 0x74,
- 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x23, 0x0a,
- 0x0d, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x05,
- 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74,
- 0x49, 0x64, 0x22, 0x14, 0x0a, 0x12, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63,
- 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x49, 0x0a, 0x13, 0x48, 0x65, 0x61, 0x6c,
- 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
- 0x18, 0x0a, 0x07, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08,
- 0x52, 0x07, 0x68, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x79, 0x12, 0x18, 0x0a, 0x07, 0x6d, 0x65, 0x73,
- 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73,
- 0x61, 0x67, 0x65, 0x22, 0x83, 0x01, 0x0a, 0x15, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x53,
- 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a,
- 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28,
- 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x64, 0x12, 0x23, 0x0a, 0x0d,
- 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x0c, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x49,
- 0x64, 0x12, 0x26, 0x0a, 0x06, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x73, 0x18, 0x03, 0x20, 0x03, 0x28,
- 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e,
- 0x74, 0x52, 0x06, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x73, 0x22, 0x83, 0x01, 0x0a, 0x16, 0x54, 0x72,
- 0x69, 0x67, 0x67, 0x65, 0x72, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70,
- 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f,
- 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f,
- 0x6e, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x02, 0x20, 0x01,
- 0x28, 0x0e, 0x32, 0x0c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x65,
- 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x26, 0x0a, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75,
- 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
- 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x52, 0x06, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x22,
- 0x32, 0x0a, 0x11, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x5f,
- 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f,
- 0x6e, 0x49, 0x64, 0x22, 0xf7, 0x01, 0x0a, 0x0b, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x49,
- 0x6e, 0x66, 0x6f, 0x12, 0x22, 0x0a, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x18, 0x01, 0x20, 0x01,
- 0x28, 0x0e, 0x32, 0x0c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x53, 0x74, 0x61, 0x74, 0x65,
- 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x23, 0x0a, 0x0d, 0x61, 0x63, 0x74, 0x69, 0x76,
- 0x65, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x02, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0c,
- 0x61, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x73, 0x12, 0x29, 0x0a, 0x10,
- 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69, 0x6e, 0x74, 0x5f, 0x63, 0x6f, 0x75, 0x6e, 0x74,
- 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x52, 0x0f, 0x63, 0x68, 0x65, 0x63, 0x6b, 0x70, 0x6f, 0x69,
- 0x6e, 0x74, 0x43, 0x6f, 0x75, 0x6e, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x63, 0x72, 0x65, 0x61, 0x74,
- 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f,
- 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69,
- 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x63, 0x72, 0x65, 0x61, 0x74, 0x65, 0x64,
- 0x41, 0x74, 0x12, 0x39, 0x0a, 0x0a, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x5f, 0x61, 0x74,
- 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e,
- 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61,
- 0x6d, 0x70, 0x52, 0x09, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, 0x42, 0x0a,
- 0x12, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f,
- 0x6e, 0x73, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x01,
- 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x53, 0x65, 0x73,
- 0x73, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x07, 0x73, 0x65, 0x73, 0x73, 0x69, 0x6f,
- 0x6e, 0x22, 0x85, 0x02, 0x0a, 0x14, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x41, 0x67,
- 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08, 0x61, 0x67,
- 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x67,
- 0x65, 0x6e, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, 0x20,
- 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73,
- 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b,
- 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x18, 0x0a, 0x07, 0x61,
- 0x64, 0x64, 0x72, 0x65, 0x73, 0x73, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x61, 0x64,
- 0x64, 0x72, 0x65, 0x73, 0x73, 0x12, 0x45, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74,
- 0x61, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e,
- 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71,
- 0x75, 0x65, 0x73, 0x74, 0x2e, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74,
- 0x72, 0x79, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x1a, 0x3b, 0x0a, 0x0d,
- 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a,
- 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12,
- 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05,
- 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0x17, 0x0a, 0x15, 0x52, 0x65, 0x67,
- 0x69, 0x73, 0x74, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
- 0x73, 0x65, 0x22, 0x33, 0x0a, 0x16, 0x55, 0x6e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72,
- 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x19, 0x0a, 0x08,
- 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07,
- 0x61, 0x67, 0x65, 0x6e, 0x74, 0x49, 0x64, 0x22, 0x19, 0x0a, 0x17, 0x55, 0x6e, 0x72, 0x65, 0x67,
- 0x69, 0x73, 0x74, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
- 0x73, 0x65, 0x2a, 0x58, 0x0a, 0x05, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x15, 0x0a, 0x11, 0x53,
- 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44,
- 0x10, 0x00, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x52, 0x55, 0x4e, 0x4e,
- 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x10, 0x0a, 0x0c, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x46,
- 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x02, 0x12, 0x13, 0x0a, 0x0f, 0x53, 0x54, 0x41, 0x54, 0x45,
- 0x5f, 0x43, 0x4f, 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x44, 0x10, 0x03, 0x32, 0x83, 0x01, 0x0a,
- 0x0c, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x2d, 0x0a,
- 0x07, 0x50, 0x72, 0x6f, 0x63, 0x65, 0x73, 0x73, 0x12, 0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
- 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x1a, 0x0e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f,
- 0x2e, 0x43, 0x6f, 0x6e, 0x74, 0x65, 0x6e, 0x74, 0x28, 0x01, 0x30, 0x01, 0x12, 0x44, 0x0a, 0x0b,
- 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x12, 0x19, 0x2e, 0x70, 0x72,
- 0x6f, 0x74, 0x6f, 0x2e, 0x48, 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52,
- 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1a, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x48,
- 0x65, 0x61, 0x6c, 0x74, 0x68, 0x43, 0x68, 0x65, 0x63, 0x6b, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e,
- 0x73, 0x65, 0x32, 0xbe, 0x02, 0x0a, 0x0a, 0x47, 0x41, 0x52, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63,
- 0x65, 0x12, 0x4f, 0x0a, 0x0e, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65, 0x72, 0x53, 0x65, 0x73, 0x73,
- 0x69, 0x6f, 0x6e, 0x12, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x72, 0x69, 0x67,
- 0x67, 0x65, 0x72, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73,
- 0x74, 0x1a, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x54, 0x72, 0x69, 0x67, 0x67, 0x65,
- 0x72, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65,
- 0x30, 0x01, 0x12, 0x41, 0x0a, 0x0a, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e,
- 0x12, 0x18, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73,
- 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x2e, 0x47, 0x65, 0x74, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73,
- 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4a, 0x0a, 0x0d, 0x52, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65,
- 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x12, 0x1b, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52,
- 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75,
- 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x52, 0x65, 0x67, 0x69,
- 0x73, 0x74, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73,
- 0x65, 0x12, 0x50, 0x0a, 0x0f, 0x55, 0x6e, 0x72, 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x41,
- 0x67, 0x65, 0x6e, 0x74, 0x12, 0x1d, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x55, 0x6e, 0x72,
- 0x65, 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x71, 0x75,
- 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2e, 0x55, 0x6e, 0x72, 0x65,
- 0x67, 0x69, 0x73, 0x74, 0x65, 0x72, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x52, 0x65, 0x73, 0x70, 0x6f,
- 0x6e, 0x73, 0x65, 0x42, 0x1d, 0x5a, 0x1b, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f,
- 0x6d, 0x2f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x67, 0x61, 0x72, 0x2f, 0x70, 0x72, 0x6f,
- 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
-}
-
-var (
- file_proto_gar_proto_rawDescOnce sync.Once
- file_proto_gar_proto_rawDescData = file_proto_gar_proto_rawDesc
-)
-
-func file_proto_gar_proto_rawDescGZIP() []byte {
- file_proto_gar_proto_rawDescOnce.Do(func() {
- file_proto_gar_proto_rawDescData = protoimpl.X.CompressGZIP(file_proto_gar_proto_rawDescData)
- })
- return file_proto_gar_proto_rawDescData
-}
-
-var file_proto_gar_proto_enumTypes = make([]protoimpl.EnumInfo, 1)
-var file_proto_gar_proto_msgTypes = make([]protoimpl.MessageInfo, 13)
-var file_proto_gar_proto_goTypes = []any{
- (State)(0), // 0: proto.State
- (*Content)(nil), // 1: proto.Content
- (*HealthCheckRequest)(nil), // 2: proto.HealthCheckRequest
- (*HealthCheckResponse)(nil), // 3: proto.HealthCheckResponse
- (*TriggerSessionRequest)(nil), // 4: proto.TriggerSessionRequest
- (*TriggerSessionResponse)(nil), // 5: proto.TriggerSessionResponse
- (*GetSessionRequest)(nil), // 6: proto.GetSessionRequest
- (*SessionInfo)(nil), // 7: proto.SessionInfo
- (*GetSessionResponse)(nil), // 8: proto.GetSessionResponse
- (*RegisterAgentRequest)(nil), // 9: proto.RegisterAgentRequest
- (*RegisterAgentResponse)(nil), // 10: proto.RegisterAgentResponse
- (*UnregisterAgentRequest)(nil), // 11: proto.UnregisterAgentRequest
- (*UnregisterAgentResponse)(nil), // 12: proto.UnregisterAgentResponse
- nil, // 13: proto.RegisterAgentRequest.MetadataEntry
- (*timestamppb.Timestamp)(nil), // 14: google.protobuf.Timestamp
-}
-var file_proto_gar_proto_depIdxs = []int32{
- 1, // 0: proto.TriggerSessionRequest.inputs:type_name -> proto.Content
- 0, // 1: proto.TriggerSessionResponse.state:type_name -> proto.State
- 1, // 2: proto.TriggerSessionResponse.output:type_name -> proto.Content
- 0, // 3: proto.SessionInfo.state:type_name -> proto.State
- 14, // 4: proto.SessionInfo.created_at:type_name -> google.protobuf.Timestamp
- 14, // 5: proto.SessionInfo.updated_at:type_name -> google.protobuf.Timestamp
- 7, // 6: proto.GetSessionResponse.session:type_name -> proto.SessionInfo
- 13, // 7: proto.RegisterAgentRequest.metadata:type_name -> proto.RegisterAgentRequest.MetadataEntry
- 1, // 8: proto.AgentService.Process:input_type -> proto.Content
- 2, // 9: proto.AgentService.HealthCheck:input_type -> proto.HealthCheckRequest
- 4, // 10: proto.GARService.TriggerSession:input_type -> proto.TriggerSessionRequest
- 6, // 11: proto.GARService.GetSession:input_type -> proto.GetSessionRequest
- 9, // 12: proto.GARService.RegisterAgent:input_type -> proto.RegisterAgentRequest
- 11, // 13: proto.GARService.UnregisterAgent:input_type -> proto.UnregisterAgentRequest
- 1, // 14: proto.AgentService.Process:output_type -> proto.Content
- 3, // 15: proto.AgentService.HealthCheck:output_type -> proto.HealthCheckResponse
- 5, // 16: proto.GARService.TriggerSession:output_type -> proto.TriggerSessionResponse
- 8, // 17: proto.GARService.GetSession:output_type -> proto.GetSessionResponse
- 10, // 18: proto.GARService.RegisterAgent:output_type -> proto.RegisterAgentResponse
- 12, // 19: proto.GARService.UnregisterAgent:output_type -> proto.UnregisterAgentResponse
- 14, // [14:20] is the sub-list for method output_type
- 8, // [8:14] is the sub-list for method input_type
- 8, // [8:8] is the sub-list for extension type_name
- 8, // [8:8] is the sub-list for extension extendee
- 0, // [0:8] is the sub-list for field type_name
-}
-
-func init() { file_proto_gar_proto_init() }
-func file_proto_gar_proto_init() {
- if File_proto_gar_proto != nil {
- return
- }
- type x struct{}
- out := protoimpl.TypeBuilder{
- File: protoimpl.DescBuilder{
- GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
- RawDescriptor: file_proto_gar_proto_rawDesc,
- NumEnums: 1,
- NumMessages: 13,
- NumExtensions: 0,
- NumServices: 2,
- },
- GoTypes: file_proto_gar_proto_goTypes,
- DependencyIndexes: file_proto_gar_proto_depIdxs,
- EnumInfos: file_proto_gar_proto_enumTypes,
- MessageInfos: file_proto_gar_proto_msgTypes,
- }.Build()
- File_proto_gar_proto = out.File
- file_proto_gar_proto_rawDesc = nil
- file_proto_gar_proto_goTypes = nil
- file_proto_gar_proto_depIdxs = nil
-}
diff --git a/proto/gar.proto b/proto/gar.proto
deleted file mode 100644
index 0823f82c..00000000
--- a/proto/gar.proto
+++ /dev/null
@@ -1,132 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-syntax = "proto3";
-
-package proto;
-
-import "google/protobuf/timestamp.proto";
-
-option go_package = "github.com/google/gar/proto";
-
-// Content represents a message with role, type, mimetype, and data fields
-message Content {
- string role = 1; // The role of the content (e.g., "user", "assistant", "system")
- string type = 2; // The type of content (e.g., "text", "image")
- string mimetype = 3; // MIME type of the data (e.g., "text/plain", "image/png")
- string data = 4; // The actual content data
- string checkpoint_id = 5; // Optional: Checkpoint ID for this content
-
- // TODO: Replace Content with Interactions Content.
-}
-
-// HealthCheckRequest for agent health checks
-message HealthCheckRequest {}
-
-// HealthCheckResponse contains agent health status
-message HealthCheckResponse {
- bool healthy = 1;
- string message = 2;
-}
-
-// AgentService defines the gRPC service for agent communication
-service AgentService {
- // Process handles bidirectional streaming of content between controller and agent
- rpc Process(stream Content) returns (stream Content);
-
- // HealthCheck checks if the agent is healthy and responsive
- rpc HealthCheck(HealthCheckRequest) returns (HealthCheckResponse);
-}
-
-// State represents the state of a session
-enum State {
- STATE_UNSPECIFIED = 0; // Unspecified state
- STATE_RUNNING = 1; // Session is currently running
- STATE_FAILED = 2; // Session failed
- STATE_COMPLETED = 3; // Completed, can accept new triggers
-
- // TODO(jbd): Add STATE_COMPLETED and only allow triggers if
- // the state is STATE_UNSPECIFIED or STATE_COMPLETED.
-}
-
-// TriggerSessionRequest for triggering a new session
-message TriggerSessionRequest {
- string session_id = 1; // Unique session identifier
- string checkpoint_id = 2; // Optional: Resume from specific checkpoint UUID (empty for latest)
- repeated Content inputs = 3; // Input content to process
-}
-
-// TriggerSessionResponse contains the result of triggering a session
-message TriggerSessionResponse {
- string session_id = 1; // Session ID (in case it was generated or modified by server)
- State state = 2; // Session state
- Content output = 3; // Output content
-}
-
-// GetSessionRequest for retrieving session details
-message GetSessionRequest {
- string session_id = 1;
-}
-
-// SessionInfo contains session details
-message SessionInfo {
- State state = 1;
- repeated string active_agents = 2;
- int32 checkpoint_count = 3;
- google.protobuf.Timestamp created_at = 4; // Session creation time
- google.protobuf.Timestamp updated_at = 5; // Last update time
-}
-
-// GetSessionResponse contains session information
-message GetSessionResponse {
- SessionInfo session = 1;
-}
-
-// RegisterAgentRequest for registering an agent
-message RegisterAgentRequest {
- string agent_id = 1;
- string name = 2; // Human-readable name for the agent
- string description = 3; // Description of agent capabilities
- string address = 4; // gRPC address for remote agents
- map metadata = 5;
-}
-
-// RegisterAgentResponse contains registration result
-message RegisterAgentResponse {
-}
-
-// UnregisterAgentRequest for unregistering an agent
-message UnregisterAgentRequest {
- string agent_id = 1;
-}
-
-// UnregisterAgentResponse contains unregistration result
-message UnregisterAgentResponse {
-}
-
-// GARService defines the gRPC service for GAR operations
-service GARService {
- // TriggerSession triggers a new agentic loop session or resumes an existing one with streaming responses
- // If the session_id already exists, it will be resumed from the last checkpoint
- rpc TriggerSession(TriggerSessionRequest) returns (stream TriggerSessionResponse);
-
- // GetSession retrieves session details
- rpc GetSession(GetSessionRequest) returns (GetSessionResponse);
-
- // RegisterAgent registers a new agent with the controller
- rpc RegisterAgent(RegisterAgentRequest) returns (RegisterAgentResponse);
-
- // UnregisterAgent removes an agent from the controller
- rpc UnregisterAgent(UnregisterAgentRequest) returns (UnregisterAgentResponse);
-}
diff --git a/proto/gar_grpc.pb.go b/proto/gar_grpc.pb.go
deleted file mode 100644
index e5e4fbad..00000000
--- a/proto/gar_grpc.pb.go
+++ /dev/null
@@ -1,411 +0,0 @@
-// Copyright 2026 Google LLC
-//
-// Licensed under the Apache License, Version 2.0 (the "License");
-// you may not use this file except in compliance with the License.
-// You may obtain a copy of the License at
-//
-// http://www.apache.org/licenses/LICENSE-2.0
-//
-// Unless required by applicable law or agreed to in writing, software
-// distributed under the License is distributed on an "AS IS" BASIS,
-// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-// See the License for the specific language governing permissions and
-// limitations under the License.
-
-// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
-// versions:
-// - protoc-gen-go-grpc v1.5.1
-// - protoc v5.28.2
-// source: proto/gar.proto
-
-package proto
-
-import (
- context "context"
-
- grpc "google.golang.org/grpc"
- codes "google.golang.org/grpc/codes"
- status "google.golang.org/grpc/status"
-)
-
-// This is a compile-time assertion to ensure that this generated file
-// is compatible with the grpc package it is being compiled against.
-// Requires gRPC-Go v1.64.0 or later.
-const _ = grpc.SupportPackageIsVersion9
-
-const (
- AgentService_Process_FullMethodName = "/proto.AgentService/Process"
- AgentService_HealthCheck_FullMethodName = "/proto.AgentService/HealthCheck"
-)
-
-// AgentServiceClient is the client API for AgentService service.
-//
-// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
-//
-// AgentService defines the gRPC service for agent communication
-type AgentServiceClient interface {
- // Process handles bidirectional streaming of content between controller and agent
- Process(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Content, Content], error)
- // HealthCheck checks if the agent is healthy and responsive
- HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error)
-}
-
-type agentServiceClient struct {
- cc grpc.ClientConnInterface
-}
-
-func NewAgentServiceClient(cc grpc.ClientConnInterface) AgentServiceClient {
- return &agentServiceClient{cc}
-}
-
-func (c *agentServiceClient) Process(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[Content, Content], error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- stream, err := c.cc.NewStream(ctx, &AgentService_ServiceDesc.Streams[0], AgentService_Process_FullMethodName, cOpts...)
- if err != nil {
- return nil, err
- }
- x := &grpc.GenericClientStream[Content, Content]{ClientStream: stream}
- return x, nil
-}
-
-// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
-type AgentService_ProcessClient = grpc.BidiStreamingClient[Content, Content]
-
-func (c *agentServiceClient) HealthCheck(ctx context.Context, in *HealthCheckRequest, opts ...grpc.CallOption) (*HealthCheckResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(HealthCheckResponse)
- err := c.cc.Invoke(ctx, AgentService_HealthCheck_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-// AgentServiceServer is the server API for AgentService service.
-// All implementations must embed UnimplementedAgentServiceServer
-// for forward compatibility.
-//
-// AgentService defines the gRPC service for agent communication
-type AgentServiceServer interface {
- // Process handles bidirectional streaming of content between controller and agent
- Process(grpc.BidiStreamingServer[Content, Content]) error
- // HealthCheck checks if the agent is healthy and responsive
- HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error)
- mustEmbedUnimplementedAgentServiceServer()
-}
-
-// UnimplementedAgentServiceServer must be embedded to have
-// forward compatible implementations.
-//
-// NOTE: this should be embedded by value instead of pointer to avoid a nil
-// pointer dereference when methods are called.
-type UnimplementedAgentServiceServer struct{}
-
-func (UnimplementedAgentServiceServer) Process(grpc.BidiStreamingServer[Content, Content]) error {
- return status.Errorf(codes.Unimplemented, "method Process not implemented")
-}
-func (UnimplementedAgentServiceServer) HealthCheck(context.Context, *HealthCheckRequest) (*HealthCheckResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method HealthCheck not implemented")
-}
-func (UnimplementedAgentServiceServer) mustEmbedUnimplementedAgentServiceServer() {}
-func (UnimplementedAgentServiceServer) testEmbeddedByValue() {}
-
-// UnsafeAgentServiceServer may be embedded to opt out of forward compatibility for this service.
-// Use of this interface is not recommended, as added methods to AgentServiceServer will
-// result in compilation errors.
-type UnsafeAgentServiceServer interface {
- mustEmbedUnimplementedAgentServiceServer()
-}
-
-func RegisterAgentServiceServer(s grpc.ServiceRegistrar, srv AgentServiceServer) {
- // If the following call pancis, it indicates UnimplementedAgentServiceServer was
- // embedded by pointer and is nil. This will cause panics if an
- // unimplemented method is ever invoked, so we test this at initialization
- // time to prevent it from happening at runtime later due to I/O.
- if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
- t.testEmbeddedByValue()
- }
- s.RegisterService(&AgentService_ServiceDesc, srv)
-}
-
-func _AgentService_Process_Handler(srv interface{}, stream grpc.ServerStream) error {
- return srv.(AgentServiceServer).Process(&grpc.GenericServerStream[Content, Content]{ServerStream: stream})
-}
-
-// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
-type AgentService_ProcessServer = grpc.BidiStreamingServer[Content, Content]
-
-func _AgentService_HealthCheck_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(HealthCheckRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(AgentServiceServer).HealthCheck(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: AgentService_HealthCheck_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(AgentServiceServer).HealthCheck(ctx, req.(*HealthCheckRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-// AgentService_ServiceDesc is the grpc.ServiceDesc for AgentService service.
-// It's only intended for direct use with grpc.RegisterService,
-// and not to be introspected or modified (even as a copy)
-var AgentService_ServiceDesc = grpc.ServiceDesc{
- ServiceName: "proto.AgentService",
- HandlerType: (*AgentServiceServer)(nil),
- Methods: []grpc.MethodDesc{
- {
- MethodName: "HealthCheck",
- Handler: _AgentService_HealthCheck_Handler,
- },
- },
- Streams: []grpc.StreamDesc{
- {
- StreamName: "Process",
- Handler: _AgentService_Process_Handler,
- ServerStreams: true,
- ClientStreams: true,
- },
- },
- Metadata: "proto/gar.proto",
-}
-
-const (
- GARService_TriggerSession_FullMethodName = "/proto.GARService/TriggerSession"
- GARService_GetSession_FullMethodName = "/proto.GARService/GetSession"
- GARService_RegisterAgent_FullMethodName = "/proto.GARService/RegisterAgent"
- GARService_UnregisterAgent_FullMethodName = "/proto.GARService/UnregisterAgent"
-)
-
-// GARServiceClient is the client API for GARService service.
-//
-// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
-//
-// GARService defines the gRPC service for GAR operations
-type GARServiceClient interface {
- // TriggerSession triggers a new agentic loop session or resumes an existing one with streaming responses
- // If the session_id already exists, it will be resumed from the last checkpoint
- TriggerSession(ctx context.Context, in *TriggerSessionRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TriggerSessionResponse], error)
- // GetSession retrieves session details
- GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error)
- // RegisterAgent registers a new agent with the controller
- RegisterAgent(ctx context.Context, in *RegisterAgentRequest, opts ...grpc.CallOption) (*RegisterAgentResponse, error)
- // UnregisterAgent removes an agent from the controller
- UnregisterAgent(ctx context.Context, in *UnregisterAgentRequest, opts ...grpc.CallOption) (*UnregisterAgentResponse, error)
-}
-
-type gARServiceClient struct {
- cc grpc.ClientConnInterface
-}
-
-func NewGARServiceClient(cc grpc.ClientConnInterface) GARServiceClient {
- return &gARServiceClient{cc}
-}
-
-func (c *gARServiceClient) TriggerSession(ctx context.Context, in *TriggerSessionRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[TriggerSessionResponse], error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- stream, err := c.cc.NewStream(ctx, &GARService_ServiceDesc.Streams[0], GARService_TriggerSession_FullMethodName, cOpts...)
- if err != nil {
- return nil, err
- }
- x := &grpc.GenericClientStream[TriggerSessionRequest, TriggerSessionResponse]{ClientStream: stream}
- if err := x.ClientStream.SendMsg(in); err != nil {
- return nil, err
- }
- if err := x.ClientStream.CloseSend(); err != nil {
- return nil, err
- }
- return x, nil
-}
-
-// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
-type GARService_TriggerSessionClient = grpc.ServerStreamingClient[TriggerSessionResponse]
-
-func (c *gARServiceClient) GetSession(ctx context.Context, in *GetSessionRequest, opts ...grpc.CallOption) (*GetSessionResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(GetSessionResponse)
- err := c.cc.Invoke(ctx, GARService_GetSession_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (c *gARServiceClient) RegisterAgent(ctx context.Context, in *RegisterAgentRequest, opts ...grpc.CallOption) (*RegisterAgentResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(RegisterAgentResponse)
- err := c.cc.Invoke(ctx, GARService_RegisterAgent_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-func (c *gARServiceClient) UnregisterAgent(ctx context.Context, in *UnregisterAgentRequest, opts ...grpc.CallOption) (*UnregisterAgentResponse, error) {
- cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
- out := new(UnregisterAgentResponse)
- err := c.cc.Invoke(ctx, GARService_UnregisterAgent_FullMethodName, in, out, cOpts...)
- if err != nil {
- return nil, err
- }
- return out, nil
-}
-
-// GARServiceServer is the server API for GARService service.
-// All implementations must embed UnimplementedGARServiceServer
-// for forward compatibility.
-//
-// GARService defines the gRPC service for GAR operations
-type GARServiceServer interface {
- // TriggerSession triggers a new agentic loop session or resumes an existing one with streaming responses
- // If the session_id already exists, it will be resumed from the last checkpoint
- TriggerSession(*TriggerSessionRequest, grpc.ServerStreamingServer[TriggerSessionResponse]) error
- // GetSession retrieves session details
- GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error)
- // RegisterAgent registers a new agent with the controller
- RegisterAgent(context.Context, *RegisterAgentRequest) (*RegisterAgentResponse, error)
- // UnregisterAgent removes an agent from the controller
- UnregisterAgent(context.Context, *UnregisterAgentRequest) (*UnregisterAgentResponse, error)
- mustEmbedUnimplementedGARServiceServer()
-}
-
-// UnimplementedGARServiceServer must be embedded to have
-// forward compatible implementations.
-//
-// NOTE: this should be embedded by value instead of pointer to avoid a nil
-// pointer dereference when methods are called.
-type UnimplementedGARServiceServer struct{}
-
-func (UnimplementedGARServiceServer) TriggerSession(*TriggerSessionRequest, grpc.ServerStreamingServer[TriggerSessionResponse]) error {
- return status.Errorf(codes.Unimplemented, "method TriggerSession not implemented")
-}
-func (UnimplementedGARServiceServer) GetSession(context.Context, *GetSessionRequest) (*GetSessionResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method GetSession not implemented")
-}
-func (UnimplementedGARServiceServer) RegisterAgent(context.Context, *RegisterAgentRequest) (*RegisterAgentResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method RegisterAgent not implemented")
-}
-func (UnimplementedGARServiceServer) UnregisterAgent(context.Context, *UnregisterAgentRequest) (*UnregisterAgentResponse, error) {
- return nil, status.Errorf(codes.Unimplemented, "method UnregisterAgent not implemented")
-}
-func (UnimplementedGARServiceServer) mustEmbedUnimplementedGARServiceServer() {}
-func (UnimplementedGARServiceServer) testEmbeddedByValue() {}
-
-// UnsafeGARServiceServer may be embedded to opt out of forward compatibility for this service.
-// Use of this interface is not recommended, as added methods to GARServiceServer will
-// result in compilation errors.
-type UnsafeGARServiceServer interface {
- mustEmbedUnimplementedGARServiceServer()
-}
-
-func RegisterGARServiceServer(s grpc.ServiceRegistrar, srv GARServiceServer) {
- // If the following call pancis, it indicates UnimplementedGARServiceServer was
- // embedded by pointer and is nil. This will cause panics if an
- // unimplemented method is ever invoked, so we test this at initialization
- // time to prevent it from happening at runtime later due to I/O.
- if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
- t.testEmbeddedByValue()
- }
- s.RegisterService(&GARService_ServiceDesc, srv)
-}
-
-func _GARService_TriggerSession_Handler(srv interface{}, stream grpc.ServerStream) error {
- m := new(TriggerSessionRequest)
- if err := stream.RecvMsg(m); err != nil {
- return err
- }
- return srv.(GARServiceServer).TriggerSession(m, &grpc.GenericServerStream[TriggerSessionRequest, TriggerSessionResponse]{ServerStream: stream})
-}
-
-// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
-type GARService_TriggerSessionServer = grpc.ServerStreamingServer[TriggerSessionResponse]
-
-func _GARService_GetSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(GetSessionRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(GARServiceServer).GetSession(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: GARService_GetSession_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(GARServiceServer).GetSession(ctx, req.(*GetSessionRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-func _GARService_RegisterAgent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(RegisterAgentRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(GARServiceServer).RegisterAgent(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: GARService_RegisterAgent_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(GARServiceServer).RegisterAgent(ctx, req.(*RegisterAgentRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-func _GARService_UnregisterAgent_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
- in := new(UnregisterAgentRequest)
- if err := dec(in); err != nil {
- return nil, err
- }
- if interceptor == nil {
- return srv.(GARServiceServer).UnregisterAgent(ctx, in)
- }
- info := &grpc.UnaryServerInfo{
- Server: srv,
- FullMethod: GARService_UnregisterAgent_FullMethodName,
- }
- handler := func(ctx context.Context, req interface{}) (interface{}, error) {
- return srv.(GARServiceServer).UnregisterAgent(ctx, req.(*UnregisterAgentRequest))
- }
- return interceptor(ctx, in, info, handler)
-}
-
-// GARService_ServiceDesc is the grpc.ServiceDesc for GARService service.
-// It's only intended for direct use with grpc.RegisterService,
-// and not to be introspected or modified (even as a copy)
-var GARService_ServiceDesc = grpc.ServiceDesc{
- ServiceName: "proto.GARService",
- HandlerType: (*GARServiceServer)(nil),
- Methods: []grpc.MethodDesc{
- {
- MethodName: "GetSession",
- Handler: _GARService_GetSession_Handler,
- },
- {
- MethodName: "RegisterAgent",
- Handler: _GARService_RegisterAgent_Handler,
- },
- {
- MethodName: "UnregisterAgent",
- Handler: _GARService_UnregisterAgent_Handler,
- },
- },
- Streams: []grpc.StreamDesc{
- {
- StreamName: "TriggerSession",
- Handler: _GARService_TriggerSession_Handler,
- ServerStreams: true,
- },
- },
- Metadata: "proto/gar.proto",
-}
diff --git a/python/antigravity/__init__.py b/python/antigravity/__init__.py
new file mode 100644
index 00000000..58d482ea
--- /dev/null
+++ b/python/antigravity/__init__.py
@@ -0,0 +1,13 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/python/antigravity/config.json b/python/antigravity/config.json
new file mode 100644
index 00000000..9f78bbb5
--- /dev/null
+++ b/python/antigravity/config.json
@@ -0,0 +1,3 @@
+{
+ "model": "gemini-3.1-pro-preview"
+}
\ No newline at end of file
diff --git a/python/antigravity/harness_server.py b/python/antigravity/harness_server.py
new file mode 100644
index 00000000..e8b53907
--- /dev/null
+++ b/python/antigravity/harness_server.py
@@ -0,0 +1,525 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# NOTE ON ARCHITECTURE:
+# This gRPC server implements the AX HarnessService protocol. It embeds the
+# Antigravity weather agent logic directly, serving it over production gRPC.
+
+import argparse
+
+import asyncio
+import json
+import logging
+import os
+import pathlib
+import re
+import sys
+from typing import TypedDict
+import grpc
+from grpc_health.v1 import health, health_pb2, health_pb2_grpc
+from google.protobuf.struct_pb2 import Struct
+
+
+from python.proto import ax_pb2
+from python.proto import ax_pb2_grpc
+from python.proto import content_pb2
+from google.antigravity import Agent, AgentConfig, LocalAgentConfig
+from google.antigravity.types import Text, Thought, ToolCall
+
+# Fields that come from outside harness_config and must not be set through it:
+# - conversation_id: taken from the runtime request (request.conversation_id).
+# - save_dir: derived at the server level from the configured state_dir.
+# TODO: add validation for fields that are unsafe to set per execution
+# (e.g. credentials, deployment routing) or that may only be set at
+# conversation creation.
+_NON_HARNESS_CONFIG_FIELDS = frozenset({"conversation_id", "save_dir"})
+
+
+class HarnessConfigError(ValueError):
+ """Raised when request harness_config is not a valid overlay."""
+
+
+class ConversationIdError(ValueError):
+ """Raised when a request's conversation_id is unusable as a save_dir name."""
+
+
+def _validate_conversation_id(conversation_id: str) -> None:
+ """Guards conversation_id for safe use as a save_dir path component.
+
+ Rejects empty ids and path separators / "." / ".." so a request cannot
+ escape state_dir. The id-format contract (length, charset) is left to the
+ Antigravity harness (forwarded cascade_id) to avoid the layers drifting.
+ """
+ if not conversation_id:
+ raise ConversationIdError("conversation_id must be set")
+ if "/" in conversation_id or "\\" in conversation_id:
+ raise ConversationIdError(
+ "conversation_id must not contain a path separator, got "
+ f"{conversation_id!r}"
+ )
+ if conversation_id in (".", ".."):
+ raise ConversationIdError(
+ f"conversation_id must not be a path component, got {conversation_id!r}"
+ )
+
+
+class VertexKwargs(TypedDict, total=False):
+ """Typed subset of LocalAgentConfig kwargs needed to enable Vertex AI.
+
+ `total=False` so {} is a valid value (returned when env does not request
+ Vertex). When populated, all three keys are present.
+ """
+
+ vertex: bool
+ project: str
+ location: str
+
+
+def _env_use_vertex() -> bool:
+ """True if env requests the Vertex AI backend (vs. AI Studio API key)."""
+ return os.environ.get("GOOGLE_GENAI_USE_VERTEXAI", "").lower() in (
+ "true",
+ "1",
+ ) or os.environ.get("GOOGLE_GENAI_USE_ENTERPRISE", "").lower() in ("true", "1")
+
+
+def _vertex_kwargs_from_env() -> VertexKwargs:
+ """Returns LocalAgentConfig kwargs from GOOGLE_CLOUD_{PROJECT,LOCATION} env.
+
+ Temporary override until AGY supports reading these env vars natively.
+ Returns {} when env does not request Vertex (caller's programmatic config
+ stands as-is). When env requests Vertex, returns VertexKwargs populated
+ for passing to LocalAgentConfig.__init__ so AGY's @model_validator picks
+ them up.
+
+ Raises ValueError if env requests Vertex but project/location are missing.
+
+ TODO: remove once AGY reads these env vars natively.
+ """
+ if not _env_use_vertex():
+ return {}
+
+ project = os.environ.get("GOOGLE_CLOUD_PROJECT", "")
+ location = os.environ.get("GOOGLE_CLOUD_LOCATION", "")
+
+ missing = [
+ name
+ for name, value in (
+ ("project (set GOOGLE_CLOUD_PROJECT)", project),
+ ("location (set GOOGLE_CLOUD_LOCATION)", location),
+ )
+ if not value
+ ]
+ if missing:
+ raise ValueError(
+ "Vertex AI backend requested but missing required config: "
+ + ", ".join(missing)
+ )
+
+ print(f"Vertex AI backend configured: project={project} location={location}")
+ return {"vertex": True, "project": project, "location": location}
+
+
+def _build_default_config() -> LocalAgentConfig:
+ """Builds the default agent config the sidecar serves on startup.
+
+ Vertex configuration is read from env via `_vertex_kwargs_from_env`.
+
+ TODO(#194): per-request `harness_config` will override fields of this
+ default on a per-conversation basis. Until then, every conversation uses
+ this config.
+ """
+ return LocalAgentConfig(
+ system_instructions="You are a helpful agent.",
+ **_vertex_kwargs_from_env(),
+ )
+
+
+def _has_credentials(config: AgentConfig | None) -> bool:
+ """Checks if Gemini credentials are set per AGY's accepted sources.
+
+ Mirrors AGY's own validation. AGY accepts exactly these sources:
+ 1. GEMINI_API_KEY environment variable (read directly by AGY).
+ 2. config.api_key set programmatically (AI Studio path).
+ 3. config.vertex=True + config.{project,location} set (Vertex path).
+ 4. config.vertex=True + config.api_key set (Vertex Express Mode;
+ covered by case 2).
+
+ Anything else (e.g. vertex=True without project/location) is rejected
+ by AGY at request time, so we reject it here at startup too.
+ """
+ # Check env - AGY reads GEMINI_API_KEY directly from os.environ.
+ if os.environ.get("GEMINI_API_KEY"):
+ return True
+
+ # Check passed in config
+ if config:
+ if getattr(config, "api_key", None):
+ return True
+ if getattr(config, "vertex", False):
+ # Vertex requires project + location, unless an api_key (Express
+ # Mode) is set - but Express Mode would have returned True above.
+ if getattr(config, "project", None) and getattr(config, "location", None):
+ return True
+
+ return False
+
+
+def _existing_sdk_conv_id(save_dir: str) -> str | None:
+ # SDK persists each conversation as {save_dir}/{sdk_conv_id}.db where sdk_conv_id
+ # is SDK-picked (a hash), not our AX conversation_id. We give each AX conversation
+ # its own save_dir so at most one .db lives there; the SDK conv_id is its stem.
+ dbs = list(pathlib.Path(save_dir).glob("*.db"))
+ return dbs[0].stem if dbs else None
+
+
+def _reject_disallowed_fields(overrides: dict[str, object]) -> None:
+ """Best-effort validation of a request harness_config overlay's keys.
+
+ Rejects fields managed outside harness_config and unknown top-level
+ fields (typos that LocalAgentConfig's extra="ignore" would otherwise
+ silently drop).
+ Top-level only: nested-key and value/type validation is delegated to the
+ SDK's own LocalAgentConfig validation when the config is constructed.
+ """
+ managed = sorted(set(overrides) & _NON_HARNESS_CONFIG_FIELDS)
+ if managed:
+ raise HarnessConfigError(
+ f"field(s) managed outside harness_config cannot be set: {', '.join(managed)}"
+ )
+ unknown = sorted(set(overrides) - set(LocalAgentConfig.model_fields))
+ if unknown:
+ raise HarnessConfigError(f"unknown config field(s): {', '.join(unknown)}")
+
+
+class AntigravityHarnessServiceServicer(ax_pb2_grpc.HarnessServiceServicer):
+ """Implements the ax.HarnessService protocol over gRPC."""
+
+ def __init__(self, default_config: AgentConfig, state_dir: pathlib.Path):
+ self._default_config = default_config
+ self._state_dir = state_dir
+
+ def _build_config_for(
+ self, conversation_id: str, harness_config: bytes = b""
+ ) -> LocalAgentConfig:
+ # Overlay the request's harness_config (JSON-in-bytes) onto the server
+ # default. The parsed dict is a local intermediate only; this method's
+ # boundary type is the validated LocalAgentConfig.
+ if harness_config:
+ try:
+ overrides = json.loads(harness_config.decode("utf-8"))
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise HarnessConfigError(f"expected UTF-8 JSON: {exc}") from exc
+ if not isinstance(overrides, dict):
+ raise HarnessConfigError("top-level JSON value must be an object")
+ _reject_disallowed_fields(overrides)
+ else:
+ overrides = {}
+
+ # Persistence values managed outside harness_config go on last so a
+ # request can never redirect trajectory storage. Per-AX-conv save_dir
+ # under the configured state_dir base; resume by the SDK's own conv_id
+ # if a trajectory already exists there. SDK auto-creates the directory.
+ overrides["save_dir"] = str(self._state_dir / conversation_id)
+ if sdk_conv_id := _existing_sdk_conv_id(overrides["save_dir"]):
+ overrides["conversation_id"] = sdk_conv_id
+
+ # Reconstruct (not model_copy) so the SDK re-validates overlaid values
+ # and surfaces its own error.
+ values = {
+ name: getattr(self._default_config, name)
+ for name in self._default_config.model_fields_set
+ }
+ values.update(overrides)
+ try:
+ return LocalAgentConfig(**values)
+ except (TypeError, ValueError) as exc:
+ raise HarnessConfigError(str(exc)) from exc
+
+ async def Connect(self, request_iterator, context):
+ # Each HarnessRequest{start} drives one stateless turn; the stream stays
+ # open across turns until the client half-closes.
+ async for request in request_iterator:
+ if request.WhichOneof("type") != "start":
+ continue # cancel frames not handled yet
+
+ async for response in self._run_turn(request):
+ yield response
+
+ async def _run_turn(self, request):
+ print(f"[gRPC] Connect turn requested. conv_id={request.conversation_id}")
+
+ # Guard conversation_id for safe use as a save_dir path component
+ # below. The id-format contract (length, charset) is owned by the
+ # Antigravity harness via the forwarded cascade_id.
+ try:
+ _validate_conversation_id(request.conversation_id)
+ except ConversationIdError as exc:
+ yield ax_pb2.HarnessResponse(
+ conversation_id=request.conversation_id,
+ end=ax_pb2.HarnessEnd(
+ state=ax_pb2.STATE_FAILED,
+ error=ax_pb2.Error(
+ code=3, # INVALID_ARGUMENT
+ description=f"Invalid conversation_id: {exc}",
+ ),
+ ),
+ )
+ return
+
+ # 1. Retrieve messages.
+ ax_messages = request.start.messages
+ if not ax_messages:
+ latest_query_text = ""
+ else:
+ latest_message = ax_messages[-1]
+
+ if latest_message.content.WhichOneof("type") != "text":
+ yield ax_pb2.HarnessResponse(
+ conversation_id=request.conversation_id,
+ end=ax_pb2.HarnessEnd(
+ state=ax_pb2.STATE_FAILED,
+ error=ax_pb2.Error(
+ code=3, # INVALID_ARGUMENT
+ description="Latest message must contain text content",
+ ),
+ ),
+ )
+ return
+ latest_query_text = latest_message.content.text.text
+
+ if not self._default_config:
+ yield ax_pb2.HarnessResponse(
+ conversation_id=request.conversation_id,
+ end=ax_pb2.HarnessEnd(
+ state=ax_pb2.STATE_FAILED,
+ error=ax_pb2.Error(
+ code=9, # FAILED_PRECONDITION
+ description="Agent config is not loaded on the server",
+ ),
+ ),
+ )
+ return
+ try:
+ per_conv_config = self._build_config_for(
+ request.conversation_id, request.start.harness_config
+ )
+ print(
+ f"[gRPC] Starting Agent for conv_id={request.conversation_id}, save_dir={per_conv_config.save_dir}"
+ )
+ async with Agent(per_conv_config) as agent:
+ conversation = agent.conversation
+
+ print(f"[gRPC] Running chat query: {latest_query_text}")
+ response = await conversation.chat(latest_query_text)
+
+ # To avoid streaming individual tokens inside TextContent messages (which is not
+ # supported by the Interactions proto/TextContent specifications), we buffer
+ # contiguous blocks of text and thought chunks, yielding them only when the
+ # contiguous block ends or a different chunk type is received.
+ text_chunks = []
+ thought_chunks = []
+
+ def flush_text():
+ if not text_chunks:
+ return None
+ msg = ax_pb2.Message(
+ role="assistant",
+ content=content_pb2.Content(
+ text=content_pb2.TextContent(text="".join(text_chunks))
+ ),
+ )
+ text_chunks.clear()
+ return ax_pb2.HarnessResponse(
+ conversation_id=request.conversation_id,
+ outputs=ax_pb2.HarnessOutputs(messages=[msg]),
+ )
+
+ def flush_thought():
+ if not thought_chunks:
+ return None
+
+ # Normalize Gemini's thinking output: collapse runs of 3+ newlines
+ # (emitted between reasoning sections) to exactly one blank line
+ # and strip trailing whitespace. Then append exactly one trailing
+ # newline so downstream displays render a blank line between the
+ # thinking block and whatever follows (next thought, tool call,
+ # or answer text). Without the trailing newline the display's
+ # transition logic emits only one newline, gluing blocks together.
+ raw_text = "".join(thought_chunks)
+ clean_text = re.sub(r"\n{3,}", "\n\n", raw_text).rstrip() + "\n"
+
+ summary = [
+ content_pb2.ThoughtSummaryContent(
+ text=content_pb2.TextContent(text=clean_text)
+ )
+ ]
+ thought_chunks.clear()
+ msg = ax_pb2.Message(
+ role="model",
+ content=content_pb2.Content(
+ thought=content_pb2.ThoughtContent(summary=summary)
+ ),
+ )
+ return ax_pb2.HarnessResponse(
+ conversation_id=request.conversation_id,
+ outputs=ax_pb2.HarnessOutputs(messages=[msg]),
+ )
+
+ async for chunk in response.chunks:
+ if isinstance(chunk, Text):
+ if resp := flush_thought():
+ yield resp
+ text_chunks.append(chunk.text)
+ elif isinstance(chunk, Thought):
+ if resp := flush_text():
+ yield resp
+ thought_chunks.append(chunk.text)
+ elif isinstance(chunk, ToolCall):
+ # Flush all pending text/thought buffers before dispatching the tool call
+ if resp := flush_text():
+ yield resp
+ if resp := flush_thought():
+ yield resp
+
+ struct_args = Struct()
+ struct_args.update(chunk.args)
+
+ func_call = content_pb2.FunctionCallContent(
+ name=str(chunk.name), arguments=struct_args
+ )
+ msg = ax_pb2.Message(
+ role="model",
+ content=content_pb2.Content(
+ tool_call=content_pb2.ToolCallContent(
+ id=chunk.id or "", function_call=func_call
+ )
+ ),
+ )
+ yield ax_pb2.HarnessResponse(
+ conversation_id=request.conversation_id,
+ outputs=ax_pb2.HarnessOutputs(messages=[msg]),
+ )
+
+ # Flush any remaining text/thought buffers after the generator loop ends
+ if resp := flush_text():
+ yield resp
+ if resp := flush_thought():
+ yield resp
+
+ # Yield completion end frame
+ yield ax_pb2.HarnessResponse(
+ conversation_id=request.conversation_id,
+ end=ax_pb2.HarnessEnd(state=ax_pb2.STATE_COMPLETED),
+ )
+ print("[gRPC] Turn completed successfully.")
+
+ except HarnessConfigError as exc:
+ yield ax_pb2.HarnessResponse(
+ conversation_id=request.conversation_id,
+ end=ax_pb2.HarnessEnd(
+ state=ax_pb2.STATE_FAILED,
+ error=ax_pb2.Error(
+ code=3, # INVALID_ARGUMENT
+ description=f"Invalid harness_config: {exc}",
+ ),
+ ),
+ )
+ return
+ except Exception as e:
+ logging.exception("Error inside Connect servicer execution")
+ yield ax_pb2.HarnessResponse(
+ conversation_id=request.conversation_id,
+ end=ax_pb2.HarnessEnd(
+ state=ax_pb2.STATE_FAILED,
+ error=ax_pb2.Error(
+ code=13, # INTERNAL
+ description=f"Agent execution terminated due to error. ({str(e)})",
+ ),
+ ),
+ )
+ return
+
+
+async def _serve(
+ host: str, port: int, default_config: AgentConfig, state_dir: pathlib.Path
+):
+ server = grpc.aio.server()
+ servicer = AntigravityHarnessServiceServicer(default_config, state_dir)
+ ax_pb2_grpc.add_HarnessServiceServicer_to_server(servicer, server)
+
+ # Serve the standard gRPC health protocol.
+ health_servicer = health.aio.HealthServicer()
+ health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server)
+ await health_servicer.set("", health_pb2.HealthCheckResponse.SERVING)
+
+ listen_addr = f"{host}:{port}"
+ server.add_insecure_port(listen_addr)
+ print(f"Starting gRPC harness server on {listen_addr}...")
+ await server.start()
+ await server.wait_for_termination()
+
+
+def _enhance_config_from_env(config) -> None:
+ skills_dir = os.environ.get("SKILLS_DIR")
+ if skills_dir and os.path.isdir(skills_dir):
+ print(f"Adding preinstalled skills directory to agent config: {skills_dir}")
+ if not hasattr(config, "skills_paths") or config.skills_paths is None:
+ config.skills_paths = []
+ config.skills_paths = list(config.skills_paths)
+ if skills_dir not in config.skills_paths:
+ config.skills_paths.append(skills_dir)
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Antigravity gRPC Harness Server")
+ parser.add_argument(
+ "--port", type=int, default=50053, help="Port to bind the server to"
+ )
+ parser.add_argument(
+ "--host", default="localhost", help="Host to bind the server to"
+ )
+ parser.add_argument(
+ "--state-dir",
+ default=str(pathlib.Path.home() / ".ax" / "antigravity" / "conversations"),
+ help="Base directory for per-conversation trajectory storage",
+ )
+ args = parser.parse_args()
+
+ try:
+ default_config = _build_default_config()
+ _enhance_config_from_env(default_config)
+ if not _has_credentials(default_config):
+ raise ValueError(
+ "No Gemini credentials configured. Set GEMINI_API_KEY "
+ "(AI Studio) or GOOGLE_GENAI_USE_VERTEXAI=True + "
+ "GOOGLE_CLOUD_{PROJECT,LOCATION} (Vertex AI)."
+ )
+ except ValueError as e:
+ # Single startup-config exit point.
+ print(f"ERROR: {e}", file=sys.stderr)
+ sys.exit(1)
+
+ asyncio.run(
+ _serve(
+ args.host,
+ args.port,
+ default_config,
+ pathlib.Path(args.state_dir).expanduser(),
+ )
+ )
+
+
+if __name__ == "__main__":
+ main()
diff --git a/python/antigravity/harness_server_test.py b/python/antigravity/harness_server_test.py
new file mode 100644
index 00000000..3d06d98f
--- /dev/null
+++ b/python/antigravity/harness_server_test.py
@@ -0,0 +1,737 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# https://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+import asyncio
+import json
+import pytest
+import grpc
+from python.proto import ax_pb2, ax_pb2_grpc, content_pb2
+from python.antigravity.harness_server import AntigravityHarnessServiceServicer
+from python.antigravity.harness_server import ConversationIdError
+from python.antigravity.harness_server import HarnessConfigError
+from python.antigravity.harness_server import _validate_conversation_id
+from google.antigravity import LocalAgentConfig
+
+@pytest.fixture
+def mock_config(monkeypatch):
+ monkeypatch.setenv("GEMINI_API_KEY", "mock-api-key")
+ return LocalAgentConfig(system_instructions="Test instructions")
+
+def test_grpc_connect_success(mock_config, monkeypatch, tmp_path):
+ async def _run():
+ # 1. Start temporary local gRPC server on random open port
+ server = grpc.aio.server()
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ ax_pb2_grpc.add_HarnessServiceServicer_to_server(servicer, server)
+ port = server.add_insecure_port("localhost:0")
+ await server.start()
+
+ # 2. Connect async stub channel
+ addr = f"localhost:{port}"
+ async with grpc.aio.insecure_channel(addr) as channel:
+ stub = ax_pb2_grpc.HarnessServiceStub(channel)
+
+ # Mock the underlying Antigravity SDK class calls
+ class MockConversation:
+ def __init__(self):
+ self._steps = []
+ async def chat(self, text):
+ class MockResponse:
+ def __init__(self):
+ self.chunks = self._chunk_generator()
+ async def _chunk_generator(self):
+ from google.antigravity.types import Text, Thought
+ yield Thought(text="Thinking details", step_index=0)
+ yield Text(text="Hello human", step_index=0)
+ return MockResponse()
+
+ class MockAgent:
+ def __init__(self, config):
+ self.conversation = MockConversation()
+ async def __aenter__(self):
+ return self
+ async def __aexit__(self, exc_type, exc, tb):
+ pass
+
+ monkeypatch.setattr("python.antigravity.harness_server.Agent", MockAgent)
+
+ # 3. Construct and fire a HarnessRequest{start} over the bidi stream
+ start_payload = ax_pb2.HarnessStart(
+ messages=[
+ ax_pb2.Message(role="user", content=content_pb2.Content(text=content_pb2.TextContent(text="Hi")))
+ ]
+ )
+ req = ax_pb2.HarnessRequest(
+ conversation_id="conv-test",
+ harness_id="antigravity",
+ start=start_payload
+ )
+
+ async def request_iter():
+ yield req
+
+ responses = []
+ async for resp in stub.Connect(request_iter()):
+ responses.append(resp)
+
+ # 4. Assert outputs are correctly mapped and completed
+ assert len(responses) == 3 # Thought + Text + End
+ assert responses[0].outputs.messages[0].content.thought.summary[0].text.text == "Thinking details\n"
+ assert responses[1].outputs.messages[0].content.text.text == "Hello human"
+ assert responses[2].WhichOneof('type') == 'end'
+ assert responses[2].end.state == ax_pb2.STATE_COMPLETED
+
+ await server.stop(0)
+
+ asyncio.run(_run())
+
+
+def test_grpc_connect_agent_per_turn_with_save_dir(mock_config, monkeypatch, tmp_path):
+ """Each turn spawns a fresh Agent with per-conv save_dir under the
+ configured state_dir. Same AX conv_id -> same save_dir
+ (SDK-native resume)."""
+
+ async def _run():
+ server = grpc.aio.server()
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ ax_pb2_grpc.add_HarnessServiceServicer_to_server(servicer, server)
+ port = server.add_insecure_port("localhost:0")
+ await server.start()
+
+ addr = f"localhost:{port}"
+ async with grpc.aio.insecure_channel(addr) as channel:
+ stub = ax_pb2_grpc.HarnessServiceStub(channel)
+
+ class MockConversation:
+ async def chat(self, text):
+ class MockResponse:
+ def __init__(self):
+ self.chunks = self._chunk_generator()
+ async def _chunk_generator(self):
+ from google.antigravity.types import Text
+ yield Text(text="Response", step_index=0)
+ return MockResponse()
+
+ agent_instances = []
+ class MockAgent:
+ def __init__(self, config):
+ self.config = config
+ self.conversation = MockConversation()
+ agent_instances.append(self)
+ async def __aenter__(self):
+ return self
+ async def __aexit__(self, exc_type, exc, tb):
+ pass
+
+ monkeypatch.setattr("python.antigravity.harness_server.Agent", MockAgent)
+
+ async def fire(conv_id):
+ req = ax_pb2.HarnessRequest(
+ conversation_id=conv_id,
+ harness_id="antigravity",
+ start=ax_pb2.HarnessStart(
+ messages=[ax_pb2.Message(role="user",
+ content=content_pb2.Content(text=content_pb2.TextContent(text="Hi")))]
+ ),
+ )
+ async def req_iter():
+ yield req
+ async for _ in stub.Connect(req_iter()):
+ pass
+
+ await fire("conv-1")
+ await fire("conv-1")
+ await fire("conv-2")
+
+ assert len(agent_instances) == 3
+ save_dirs = [a.config.save_dir for a in agent_instances]
+ assert save_dirs[0] == save_dirs[1]
+ assert save_dirs[0] != save_dirs[2]
+ assert save_dirs[0] == str(tmp_path / "conv-1")
+ assert save_dirs[2] == str(tmp_path / "conv-2")
+ # conversation_id only passed when trajectory exists (not in these mocked runs).
+ assert [a.config.conversation_id for a in agent_instances] == [None, None, None]
+
+ await server.stop(0)
+
+ asyncio.run(_run())
+
+
+def test_health_check(tmp_path):
+ async def _run():
+ from grpc_health.v1 import health, health_pb2, health_pb2_grpc
+
+ cfg = LocalAgentConfig(system_instructions="health-check stub")
+ server = grpc.aio.server()
+ ax_pb2_grpc.add_HarnessServiceServicer_to_server(AntigravityHarnessServiceServicer(cfg, tmp_path), server)
+ health_servicer = health.aio.HealthServicer()
+ health_pb2_grpc.add_HealthServicer_to_server(health_servicer, server)
+ await health_servicer.set("", health_pb2.HealthCheckResponse.SERVING)
+ port = server.add_insecure_port("localhost:0")
+ await server.start()
+ try:
+ async with grpc.aio.insecure_channel(f"localhost:{port}") as channel:
+ stub = health_pb2_grpc.HealthStub(channel)
+ resp = await stub.Check(health_pb2.HealthCheckRequest(service=""))
+ assert resp.status == health_pb2.HealthCheckResponse.SERVING
+ finally:
+ await server.stop(0)
+
+ asyncio.run(_run())
+
+
+def test_has_credentials_missing(monkeypatch):
+ """Returns False when neither env nor config provides credentials."""
+ from python.antigravity.harness_server import _has_credentials
+
+ monkeypatch.delenv("GEMINI_API_KEY", raising=False)
+ monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
+ monkeypatch.delenv("GOOGLE_GENAI_USE_ENTERPRISE", raising=False)
+
+ cfg = LocalAgentConfig(system_instructions="test")
+ assert _has_credentials(cfg) is False
+
+
+def test_has_credentials_vertex_requires_project_and_location(monkeypatch):
+ """vertex=True alone is not enough; AGY requires project+location too."""
+ from python.antigravity.harness_server import _has_credentials
+
+ monkeypatch.delenv("GEMINI_API_KEY", raising=False)
+
+ cfg_vertex_only = LocalAgentConfig(system_instructions="test", vertex=True)
+ assert _has_credentials(cfg_vertex_only) is False
+
+ cfg_vertex_proj_only = LocalAgentConfig(system_instructions="test", vertex=True, project="p")
+ assert _has_credentials(cfg_vertex_proj_only) is False
+
+ cfg_vertex_loc_only = LocalAgentConfig(system_instructions="test", vertex=True, location="us-central1")
+ assert _has_credentials(cfg_vertex_loc_only) is False
+
+ cfg_vertex_full = LocalAgentConfig(system_instructions="test", vertex=True, project="p", location="us-central1")
+ assert _has_credentials(cfg_vertex_full) is True
+
+
+def test_has_credentials_vertex_express_mode(monkeypatch):
+ """vertex=True + api_key (Express Mode) is accepted even without project/location."""
+ from python.antigravity.harness_server import _has_credentials
+
+ monkeypatch.delenv("GEMINI_API_KEY", raising=False)
+
+ cfg = LocalAgentConfig(system_instructions="test", vertex=True, api_key="express-key")
+ assert _has_credentials(cfg) is True
+
+
+def test_grpc_connect_programmatic_credentials(monkeypatch, tmp_path):
+ monkeypatch.delenv("GEMINI_API_KEY", raising=False)
+ monkeypatch.delenv("GOOGLE_API_KEY", raising=False)
+ monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
+ monkeypatch.delenv("GOOGLE_GENAI_USE_ENTERPRISE", raising=False)
+
+ # Config with API key programmatically set
+ cfg = LocalAgentConfig(system_instructions="Test instructions", api_key="mock-config-api-key")
+
+ async def _run():
+ server = grpc.aio.server()
+ servicer = AntigravityHarnessServiceServicer(cfg, tmp_path)
+ ax_pb2_grpc.add_HarnessServiceServicer_to_server(servicer, server)
+ port = server.add_insecure_port("localhost:0")
+ await server.start()
+
+ addr = f"localhost:{port}"
+ async with grpc.aio.insecure_channel(addr) as channel:
+ stub = ax_pb2_grpc.HarnessServiceStub(channel)
+
+ # Mock Agent so we can test programmatic config logic passes
+ class MockConversation:
+ def __init__(self):
+ self._steps = []
+ async def chat(self, text):
+ class MockResponse:
+ def __init__(self):
+ self.chunks = self._chunk_generator()
+ async def _chunk_generator(self):
+ from google.antigravity.types import Text
+ yield Text(text="Passed check", step_index=0)
+ return MockResponse()
+
+ class MockAgent:
+ def __init__(self, config):
+ self.conversation = MockConversation()
+ async def __aenter__(self):
+ return self
+ async def __aexit__(self, exc_type, exc, tb):
+ pass
+ monkeypatch.setattr("python.antigravity.harness_server.Agent", MockAgent)
+
+ start_payload = ax_pb2.HarnessStart(
+ messages=[
+ ax_pb2.Message(role="user", content=content_pb2.Content(text=content_pb2.TextContent(text="Hi")))
+ ]
+ )
+ req = ax_pb2.HarnessRequest(
+ conversation_id="conv-test-prog",
+ harness_id="antigravity",
+ start=start_payload
+ )
+
+ async def request_iter():
+ yield req
+
+ responses = []
+ async for resp in stub.Connect(request_iter()):
+ responses.append(resp)
+
+ assert len(responses) == 2 # Text + End
+ assert responses[0].outputs.messages[0].content.text.text == "Passed check"
+ assert responses[1].end.state == ax_pb2.STATE_COMPLETED
+
+ await server.stop(0)
+
+ asyncio.run(_run())
+
+
+def test_enhance_config_from_env(monkeypatch, tmp_path):
+ from python.antigravity.harness_server import _enhance_config_from_env
+ from google.antigravity import LocalAgentConfig
+ import os
+
+ # Create a mock skills dir
+ skills_dir = tmp_path / "skills"
+ skills_dir.mkdir()
+
+ cfg = LocalAgentConfig(system_instructions="test")
+
+ # Test: Using SKILLS_DIR env var
+ monkeypatch.setenv("SKILLS_DIR", str(skills_dir))
+ _enhance_config_from_env(cfg)
+ assert str(skills_dir) in cfg.skills_paths
+
+
+def test_grpc_connect_buffering(mock_config, monkeypatch, tmp_path):
+ async def _run():
+ server = grpc.aio.server()
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ ax_pb2_grpc.add_HarnessServiceServicer_to_server(servicer, server)
+ port = server.add_insecure_port("localhost:0")
+ await server.start()
+
+ addr = f"localhost:{port}"
+ async with grpc.aio.insecure_channel(addr) as channel:
+ stub = ax_pb2_grpc.HarnessServiceStub(channel)
+
+ class MockConversation:
+ def __init__(self):
+ self._steps = []
+ async def chat(self, text):
+ class MockResponse:
+ def __init__(self):
+ self.chunks = self._chunk_generator()
+ async def _chunk_generator(self):
+ from google.antigravity.types import Text, Thought, ToolCall
+ yield Thought(text="Think1", step_index=0)
+ yield Thought(text=" Think2", step_index=0)
+ yield ToolCall(name="tool1", args={}, id="call1")
+ yield Text(text="Hello", step_index=0)
+ yield Text(text=" human", step_index=0)
+ return MockResponse()
+
+ class MockAgent:
+ def __init__(self, config):
+ self.conversation = MockConversation()
+ async def __aenter__(self):
+ return self
+ async def __aexit__(self, exc_type, exc, tb):
+ pass
+ monkeypatch.setattr("python.antigravity.harness_server.Agent", MockAgent)
+
+ start_payload = ax_pb2.HarnessStart(
+ messages=[
+ ax_pb2.Message(role="user", content=content_pb2.Content(text=content_pb2.TextContent(text="Hi")))
+ ]
+ )
+ req = ax_pb2.HarnessRequest(
+ conversation_id="conv-test-buffer",
+ harness_id="antigravity",
+ start=start_payload
+ )
+
+ async def request_iter():
+ yield req
+
+ responses = []
+ async for resp in stub.Connect(request_iter()):
+ responses.append(resp)
+
+ # Responses should be:
+ # 1. Thought ("Think1 Think2") - flushed when ToolCall is encountered
+ # 2. ToolCall ("tool1") - processed immediately
+ # 3. Text ("Hello human") - flushed at the end
+ # 4. End frame
+ assert len(responses) == 4
+
+ # Assert 1st response: Thought summary text is "Think1 Think2"
+ assert responses[0].outputs.messages[0].content.WhichOneof('type') == 'thought'
+ assert responses[0].outputs.messages[0].content.thought.summary[0].text.text == "Think1 Think2\n"
+
+ # Assert 2nd response: ToolCall name is "tool1"
+ assert responses[1].outputs.messages[0].content.WhichOneof('type') == 'tool_call'
+ assert responses[1].outputs.messages[0].content.tool_call.function_call.name == "tool1"
+
+ # Assert 3rd response: Text content is "Hello human"
+ assert responses[2].outputs.messages[0].content.WhichOneof('type') == 'text'
+ assert responses[2].outputs.messages[0].content.text.text == "Hello human"
+
+ # Assert 4th response: Completion end frame
+ assert responses[3].WhichOneof('type') == 'end'
+ assert responses[3].end.state == ax_pb2.STATE_COMPLETED
+
+ await server.stop(0)
+
+ asyncio.run(_run())
+
+def test_vertex_kwargs_from_env_returns_kwargs(monkeypatch):
+ """GOOGLE_GENAI_USE_VERTEXAI + GOOGLE_CLOUD_{PROJECT,LOCATION} -> kwargs dict."""
+ from python.antigravity.harness_server import _vertex_kwargs_from_env
+ monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "True")
+ monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "env-project")
+ monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-east1")
+ assert _vertex_kwargs_from_env() == {
+ "vertex": True,
+ "project": "env-project",
+ "location": "us-east1",
+ }
+
+
+def test_vertex_kwargs_from_env_no_op_without_vertex(monkeypatch):
+ """Without GOOGLE_GENAI_USE_VERTEXAI, returns empty dict."""
+ from python.antigravity.harness_server import _vertex_kwargs_from_env
+ monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
+ monkeypatch.delenv("GOOGLE_GENAI_USE_ENTERPRISE", raising=False)
+ monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "should-be-ignored")
+ monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "should-be-ignored")
+ assert _vertex_kwargs_from_env() == {}
+
+
+def test_vertex_kwargs_from_env_raises_when_project_missing(monkeypatch):
+ """Vertex requested with no project -> ValueError naming the env var."""
+ from python.antigravity.harness_server import _vertex_kwargs_from_env
+ monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "True")
+ monkeypatch.delenv("GOOGLE_CLOUD_PROJECT", raising=False)
+ monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-east1")
+ with pytest.raises(ValueError, match="GOOGLE_CLOUD_PROJECT"):
+ _vertex_kwargs_from_env()
+
+
+def test_vertex_kwargs_from_env_raises_when_location_missing(monkeypatch):
+ """Vertex requested with no location -> ValueError naming the env var."""
+ from python.antigravity.harness_server import _vertex_kwargs_from_env
+ monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "True")
+ monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "env-project")
+ monkeypatch.delenv("GOOGLE_CLOUD_LOCATION", raising=False)
+ with pytest.raises(ValueError, match="GOOGLE_CLOUD_LOCATION"):
+ _vertex_kwargs_from_env()
+
+
+def test_vertex_kwargs_from_env_enterprise_alias(monkeypatch):
+ """GOOGLE_GENAI_USE_ENTERPRISE is an alias for VERTEXAI."""
+ from python.antigravity.harness_server import _vertex_kwargs_from_env
+ monkeypatch.delenv("GOOGLE_GENAI_USE_VERTEXAI", raising=False)
+ monkeypatch.setenv("GOOGLE_GENAI_USE_ENTERPRISE", "true")
+ monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "ent-project")
+ monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-central1")
+ assert _vertex_kwargs_from_env() == {
+ "vertex": True,
+ "project": "ent-project",
+ "location": "us-central1",
+ }
+
+
+def test_build_default_config_picks_up_vertex_env(monkeypatch):
+ """End-to-end: env vars flow through _build_default_config into LocalAgentConfig."""
+ from python.antigravity.harness_server import _build_default_config
+ monkeypatch.setenv("GOOGLE_GENAI_USE_VERTEXAI", "True")
+ monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "env-project")
+ monkeypatch.setenv("GOOGLE_CLOUD_LOCATION", "us-east1")
+ cfg = _build_default_config()
+ assert cfg.vertex is True
+ assert cfg.project == "env-project"
+ assert cfg.location == "us-east1"
+
+
+def test_servicer_requires_default_config():
+ """Constructor takes a default config; passing nothing is a TypeError."""
+ with pytest.raises(TypeError, match="default_config"):
+ AntigravityHarnessServiceServicer()
+
+
+def test_run_turn_guards_against_missing_default_config(monkeypatch, tmp_path):
+ """If something sets _default_config to None at runtime (future bug in
+ per-request layering, #194), _run_turn returns STATE_FAILED instead of
+ crashing the server.
+ """
+ async def _run():
+ cfg = LocalAgentConfig(system_instructions="will be set to None")
+ servicer = AntigravityHarnessServiceServicer(cfg, tmp_path)
+ servicer._default_config = None
+ server = grpc.aio.server()
+ ax_pb2_grpc.add_HarnessServiceServicer_to_server(servicer, server)
+ port = server.add_insecure_port("localhost:0")
+ await server.start()
+ try:
+ async with grpc.aio.insecure_channel(f"localhost:{port}") as channel:
+ stub = ax_pb2_grpc.HarnessServiceStub(channel)
+ req = ax_pb2.HarnessRequest(
+ conversation_id="conv-guard",
+ harness_id="antigravity",
+ start=ax_pb2.HarnessStart(messages=[
+ ax_pb2.Message(role="user",
+ content=content_pb2.Content(text=content_pb2.TextContent(text="Hi"))),
+ ]),
+ )
+ async def request_iter():
+ yield req
+ responses = []
+ async for resp in stub.Connect(request_iter()):
+ responses.append(resp)
+ assert len(responses) == 1
+ assert responses[0].end.state == ax_pb2.STATE_FAILED
+ assert responses[0].end.error.code == 9
+ assert "Agent config is not loaded" in responses[0].end.error.description
+ finally:
+ await server.stop(0)
+ asyncio.run(_run())
+
+
+def test_harness_config_empty_is_noop(mock_config, tmp_path):
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ assert servicer._build_config_for("conv-1", b"").system_instructions == (
+ mock_config.system_instructions
+ )
+ assert servicer._build_config_for("conv-1", b"{}").system_instructions == (
+ mock_config.system_instructions
+ )
+
+
+def test_harness_config_overlay_applies(mock_config, tmp_path):
+ # Fields flow through to the SDK, which validates values.
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ raw = json.dumps({"system_instructions": "Answer in one sentence."}).encode()
+
+ config = servicer._build_config_for("conv-1", raw)
+
+ assert config.system_instructions == "Answer in one sentence."
+
+
+def test_harness_config_overlay_keeps_ax_managed_save_dir(mock_config, tmp_path):
+ # A valid overlay must not disturb the AX-injected save_dir.
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ raw = json.dumps({"system_instructions": "x"}).encode()
+
+ config = servicer._build_config_for("conv-1", raw)
+
+ assert config.system_instructions == "x"
+ assert config.save_dir == str(tmp_path / "conv-1")
+
+
+def test_harness_config_overlay_does_not_mutate_default(mock_config, tmp_path):
+ # Reconstruction must not mutate the shared server default.
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ servicer._build_config_for(
+ "conv-1", json.dumps({"system_instructions": "overridden"}).encode()
+ )
+ assert mock_config.system_instructions == "Test instructions"
+
+
+def test_harness_config_overlay_applies_multiple_fields(mock_config, tmp_path):
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ raw = json.dumps({
+ "system_instructions": "x",
+ "model": "gemini-2.5-pro",
+ }).encode()
+
+ config = servicer._build_config_for("conv-1", raw)
+
+ assert config.system_instructions == "x"
+ assert config.model == "gemini-2.5-pro"
+
+
+@pytest.mark.parametrize(("raw_config", "error"), [
+ (b"{", "expected UTF-8 JSON"),
+ (b"\xff", "expected UTF-8 JSON"),
+ (json.dumps([]).encode(), "top-level JSON value must be an object"),
+ (json.dumps({"save_dir": "/tmp/other"}).encode(), "managed outside harness_config"),
+ (json.dumps({"conversation_id": "other"}).encode(), "managed outside harness_config"),
+ (
+ json.dumps({"capabilities": {"enabled_tools": ["not-a-tool"]}}).encode(),
+ "validation error",
+ ),
+ (json.dumps({"system_instruction": "typo"}).encode(), "unknown config field"),
+ (json.dumps({"model": "m", "frobnicate": True}).encode(), "unknown config field"),
+])
+def test_harness_config_rejects(mock_config, tmp_path, raw_config, error):
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ with pytest.raises(HarnessConfigError, match=error):
+ servicer._build_config_for("conv-1", raw_config)
+
+
+def test_run_turn_invalid_harness_config_maps_to_invalid_argument(mock_config, tmp_path):
+ async def _run():
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ req = ax_pb2.HarnessRequest(
+ conversation_id="conv-1",
+ harness_id="antigravity",
+ start=ax_pb2.HarnessStart(
+ harness_config=b"{",
+ messages=[ax_pb2.Message(
+ role="user",
+ content=content_pb2.Content(text=content_pb2.TextContent(text="hi")),
+ )],
+ ),
+ )
+ responses = [r async for r in servicer._run_turn(req)]
+ assert len(responses) == 1
+ assert responses[0].end.state == ax_pb2.STATE_FAILED
+ assert responses[0].end.error.code == 3
+ assert "Invalid harness_config" in responses[0].end.error.description
+
+ asyncio.run(_run())
+
+
+def test_harness_config_unknown_field_names_are_reported(mock_config, tmp_path):
+ # The error lists the offending field(s), sorted, so a typo is actionable;
+ # any unknown field rejects the whole overlay (no silent drop) and valid
+ # fields in the same request are not flagged.
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ raw = json.dumps({"zzz_bad": 1, "aaa_bad": 2, "system_instructions": "ok"}).encode()
+ with pytest.raises(HarnessConfigError) as excinfo:
+ servicer._build_config_for("conv-1", raw)
+ msg = str(excinfo.value)
+ assert "unknown config field(s): aaa_bad, zzz_bad" in msg
+ assert "system_instructions" not in msg
+
+
+
+@pytest.mark.parametrize("conv_id", [
+ "conv-1", # short ids are fine here; the harness owns the format contract
+ "conv-test",
+ "11111111-2222-3333-4444-555555555555",
+ "a", # single char, still a safe dir name
+])
+def test_validate_conversation_id_accepts_path_safe(conv_id):
+ # Should not raise: these are all usable as a save_dir path component.
+ _validate_conversation_id(conv_id)
+
+
+@pytest.mark.parametrize(("conv_id", "error"), [
+ ("", "must be set"),
+ ("..", "path component"),
+ (".", "path component"),
+ ("../escape", "path separator"),
+ ("a/b", "path separator"),
+ ("nested/../conv", "path separator"),
+ ("back\\slash", "path separator"),
+])
+def test_validate_conversation_id_rejects_unsafe(conv_id, error):
+ with pytest.raises(ConversationIdError, match=error):
+ _validate_conversation_id(conv_id)
+
+
+def test_run_turn_unsafe_conversation_id_maps_to_invalid_argument(mock_config, tmp_path):
+ # An unsafe conversation_id is rejected at the boundary: the turn yields a
+ # single STATE_FAILED end frame with INVALID_ARGUMENT, before any agent runs.
+ async def _run():
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ req = ax_pb2.HarnessRequest(
+ conversation_id="../escape",
+ harness_id="antigravity",
+ start=ax_pb2.HarnessStart(
+ messages=[ax_pb2.Message(
+ role="user",
+ content=content_pb2.Content(text=content_pb2.TextContent(text="hi")),
+ )],
+ ),
+ )
+ responses = [r async for r in servicer._run_turn(req)]
+ assert len(responses) == 1
+ assert responses[0].end.state == ax_pb2.STATE_FAILED
+ assert responses[0].end.error.code == 3
+ assert "Invalid conversation_id" in responses[0].end.error.description
+
+ asyncio.run(_run())
+
+
+def test_run_turn_rejects_conversation_id_before_creating_save_dir(mock_config, tmp_path):
+ # Validation must happen before conversation_id is used as a storage
+ # directory name, so a rejected id leaves no directory behind under (or
+ # outside) state_dir.
+ async def _run():
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ req = ax_pb2.HarnessRequest(
+ conversation_id="../escape",
+ harness_id="antigravity",
+ start=ax_pb2.HarnessStart(
+ messages=[ax_pb2.Message(
+ role="user",
+ content=content_pb2.Content(text=content_pb2.TextContent(text="hi")),
+ )],
+ ),
+ )
+ responses = [r async for r in servicer._run_turn(req)]
+ assert len(responses) == 1
+ assert responses[0].end.state == ax_pb2.STATE_FAILED
+ assert list(tmp_path.iterdir()) == []
+
+ asyncio.run(_run())
+
+
+def test_run_turn_empty_messages_resumes_with_empty_prompt(mock_config, monkeypatch, tmp_path):
+ """Antigravity SDK to resume with empty input."""
+
+ captured = {}
+
+ class MockConversation:
+ async def chat(self, text):
+ captured["prompt"] = text
+ class MockResponse:
+ def __init__(self):
+ self.chunks = self._chunk_generator()
+ async def _chunk_generator(self):
+ from google.antigravity.types import Text
+ yield Text(text="continued essay", step_index=0)
+ return MockResponse()
+
+ class MockAgent:
+ def __init__(self, config):
+ self.conversation = MockConversation()
+ async def __aenter__(self):
+ return self
+ async def __aexit__(self, exc_type, exc, tb):
+ pass
+
+ monkeypatch.setattr("python.antigravity.harness_server.Agent", MockAgent)
+
+ async def _run():
+ servicer = AntigravityHarnessServiceServicer(mock_config, tmp_path)
+ req = ax_pb2.HarnessRequest(
+ conversation_id="conv-1",
+ harness_id="antigravity",
+ start=ax_pb2.HarnessStart(messages=[]), # empty = resume
+ )
+ responses = [r async for r in servicer._run_turn(req)]
+
+ assert captured["prompt"] == ""
+ assert all(r.end.state != ax_pb2.STATE_FAILED for r in responses if r.HasField("end"))
+ assert any(r.HasField("end") and r.end.state == ax_pb2.STATE_COMPLETED for r in responses)
+
+ asyncio.run(_run())
diff --git a/python/antigravity/requirements.txt b/python/antigravity/requirements.txt
new file mode 100644
index 00000000..e65fc40a
--- /dev/null
+++ b/python/antigravity/requirements.txt
@@ -0,0 +1,4 @@
+# Runtime dependencies for the antigravity HarnessService server
+# (python/antigravity/harness_server.py).
+google-antigravity==0.1.7
+grpcio-health-checking==1.81.0
diff --git a/python/example_agent.py b/python/example_agent.py
deleted file mode 100644
index fee97ef9..00000000
--- a/python/example_agent.py
+++ /dev/null
@@ -1,47 +0,0 @@
-# Copyright 2026 Google LLC
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-#!/usr/bin/env python3
-"""
-Example Python agent using the GAR framework.
-
-This demonstrates a simple agent that uppercases input text.
-"""
-
-from gar import Agent
-import proto.gar_pb2 as pb2
-
-
-def process(session_id, inputs):
- """Process incoming content list and yield responses"""
- for content in inputs:
- yield pb2.Content(
- role="assistant",
- type="text",
- mimetype="text/plain",
- data=f"Python processed (session {session_id}): {content.data.upper()}"
- )
-
-
-def health_check():
- """Health check function that always returns healthy"""
- return True, "OK", {}
-
-
-if __name__ == "__main__":
- agent = Agent(
- process_func=process,
- health_check_func=health_check
- )
- agent.serve(port=50051)
diff --git a/python/gar.py b/python/gar.py
deleted file mode 100644
index 51273d36..00000000
--- a/python/gar.py
+++ /dev/null
@@ -1,136 +0,0 @@
-# Copyright 2026 Google LLC
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-"""
-GAR Agent Framework for Python
-
-A simple framework for building Python agents that work with the GAR orchestrator.
-"""
-
-import grpc
-from concurrent import futures
-from typing import Callable, Iterator, Optional, Dict
-
-
-class Agent:
- """
- Agent provides a simple framework for building Python agents.
-
- Usage:
- def process(session_id, inputs):
- for content in inputs:
- yield Content(role="assistant", type="text",
- mimetype="text/plain", data=f"Processed: {content.data}")
-
- agent = Agent(process_func=process)
- agent.serve(port=50051)
- """
-
- def __init__(
- self,
- process_func: Callable,
- health_check_func: Optional[Callable] = None
- ):
- """
- Initialize an agent.
-
- Args:
- process_func: Function that takes (session_id: str, inputs: list) and yields Content responses
- health_check_func: Optional function that returns (healthy: bool, message: str, metadata: dict)
- """
- self.process_func = process_func
- self.health_check_func = health_check_func
-
- def _create_servicer(self, pb2, pb2_grpc):
- """Create the gRPC servicer implementation."""
- agent = self
-
- class AgentServicer(pb2_grpc.AgentServiceServicer):
- def Process(self, request_iterator, context):
- # Extract session_id from gRPC metadata
- metadata = dict(context.invocation_metadata())
- session_id = metadata.get('session-id', '')
-
- # Collect all content into a list
- inputs = list(request_iterator)
-
- # Process the list of content with session_id
- for response in agent.process_func(session_id, inputs):
- if response:
- yield response
-
- def HealthCheck(self, request, context):
- if agent.health_check_func:
- healthy, message, metadata = agent.health_check_func()
- else:
- healthy, message, metadata = True, "Agent is healthy", {}
-
- return pb2.HealthCheckResponse(
- healthy=healthy,
- message=message,
- metadata=metadata or {}
- )
-
- return AgentServicer()
-
- def serve(self, port: int = 50051, max_workers: int = 10):
- """
- Start the gRPC server.
-
- Args:
- port: Port to listen on (default: 50051)
- max_workers: Maximum number of worker threads (default: 10)
- """
- # Import proto files (assuming they've been generated)
- try:
- import proto.gar_pb2 as pb2
- import proto.gar_pb2_grpc as pb2_grpc
- except ImportError:
- raise ImportError(
- "Proto files not found. Generate them first:\n"
- " python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. proto/gar.proto"
- )
-
- server = grpc.server(futures.ThreadPoolExecutor(max_workers=max_workers))
- pb2_grpc.add_AgentServiceServicer_to_server(
- self._create_servicer(pb2, pb2_grpc),
- server
- )
- server.add_insecure_port(f'[::]:{port}')
- server.start()
- print(f"Agent listening on port {port}")
-
- try:
- server.wait_for_termination()
- except KeyboardInterrupt:
- print("\nShutting down agent...")
- server.stop(grace=5)
-
-
-# Convenience function for quick agent creation
-def create_agent(
- process_func: Callable,
- health_check_func: Optional[Callable] = None,
- port: int = 50051
-):
- """
- Create and start an agent in one call.
-
- Args:
- process_func: Function that takes (session_id: str, inputs: list) and yields Content responses
- health_check_func: Optional function that returns (healthy: bool, message: str, metadata: dict)
- port: Port to listen on (default: 50051)
- """
- agent = Agent(process_func, health_check_func)
- agent.serve(port=port)
diff --git a/python/proto/__init__.py b/python/proto/__init__.py
new file mode 100644
index 00000000..ce346f19
--- /dev/null
+++ b/python/proto/__init__.py
@@ -0,0 +1,21 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Generated proto files."""
+
+import sys
+import os
+
+# Add this directory to sys.path to allow generated files to find each other.
+sys.path.insert(0, os.path.dirname(__file__))
diff --git a/python/proto/ax_pb2.py b/python/proto/ax_pb2.py
new file mode 100644
index 00000000..d1471030
--- /dev/null
+++ b/python/proto/ax_pb2.py
@@ -0,0 +1,77 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: proto/ax.proto
+# Protobuf Python Version: 4.25.1
+"""Generated protocol buffer code."""
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+from google.protobuf.internal import builder as _builder
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2
+from proto import content_pb2 as proto_dot_content__pb2
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x0eproto/ax.proto\x12\x02\x61x\x1a\x1cgoogle/protobuf/struct.proto\x1a\x13proto/content.proto\"5\n\x07Message\x12\x0c\n\x04role\x18\x01 \x01(\t\x12\x1c\n\x07\x63ontent\x18\x02 \x01(\x0b\x32\x0b.ax.Content\"\xc8\x01\n\x11\x43onversationEvent\x12\x17\n\x0f\x63onversation_id\x18\x01 \x01(\t\x12\x0b\n\x03seq\x18\x02 \x01(\x05\x12\x0f\n\x07\x65xec_id\x18\x03 \x01(\t\x12\x12\n\nharness_id\x18\x04 \x01(\t\x12/\n\x0eharness_config\x18\x05 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x1d\n\x08messages\x18\x06 \x03(\x0b\x32\x0b.ax.Message\x12\x18\n\x05state\x18\x07 \x01(\x0e\x32\t.ax.State\"E\n\x0cHarnessStart\x12\x16\n\x0eharness_config\x18\x01 \x01(\x0c\x12\x1d\n\x08messages\x18\x02 \x03(\x0b\x32\x0b.ax.Message\"1\n\rHarnessCancel\x12 \n\x06reason\x18\x01 \x01(\x0e\x32\x10.ax.CancelReason\"\x8d\x01\n\x0eHarnessRequest\x12\x17\n\x0f\x63onversation_id\x18\x01 \x01(\t\x12\x12\n\nharness_id\x18\x02 \x01(\t\x12!\n\x05start\x18\x03 \x01(\x0b\x32\x10.ax.HarnessStartH\x00\x12#\n\x06\x63\x61ncel\x18\x04 \x01(\x0b\x32\x11.ax.HarnessCancelH\x00\x42\x06\n\x04type\"/\n\x0eHarnessOutputs\x12\x1d\n\x08messages\x18\x01 \x03(\x0b\x32\x0b.ax.Message\"*\n\x05\x45rror\x12\x0c\n\x04\x63ode\x18\x01 \x01(\x05\x12\x13\n\x0b\x64\x65scription\x18\x02 \x01(\t\"@\n\nHarnessEnd\x12\x18\n\x05state\x18\x01 \x01(\x0e\x32\t.ax.State\x12\x18\n\x05\x65rror\x18\x02 \x01(\x0b\x32\t.ax.Error\"x\n\x0fHarnessResponse\x12\x17\n\x0f\x63onversation_id\x18\x01 \x01(\t\x12%\n\x07outputs\x18\x02 \x01(\x0b\x32\x12.ax.HarnessOutputsH\x00\x12\x1d\n\x03\x65nd\x18\x03 \x01(\x0b\x32\x0e.ax.HarnessEndH\x00\x42\x06\n\x04type\"\x81\x01\n\x0b\x45xecRequest\x12\x17\n\x0f\x63onversation_id\x18\x01 \x01(\t\x12\x1b\n\x06inputs\x18\x02 \x03(\x0b\x32\x0b.ax.Message\x12\x10\n\x08last_seq\x18\x03 \x01(\x05\x12\x12\n\nharness_id\x18\x04 \x01(\t\x12\x16\n\x0eharness_config\x18\x05 \x01(\x0c\"9\n\x0c\x45xecResponse\x12\x1c\n\x07outputs\x18\x01 \x03(\x0b\x32\x0b.ax.Message\x12\x0b\n\x03seq\x18\x02 \x01(\x05\"4\n\x19\x44\x65leteConversationRequest\x12\x17\n\x0f\x63onversation_id\x18\x01 \x01(\t\"\x1c\n\x1a\x44\x65leteConversationResponse*l\n\x05State\x12\x15\n\x11STATE_UNSPECIFIED\x10\x00\x12\x11\n\rSTATE_PENDING\x10\x01\x12\x10\n\x0cSTATE_FAILED\x10\x02\x12\x13\n\x0fSTATE_COMPLETED\x10\x03\x12\x12\n\x0eSTATE_CANCELED\x10\x04*\x8c\x01\n\x0c\x43\x61ncelReason\x12\x1d\n\x19\x43\x41NCEL_REASON_UNSPECIFIED\x10\x00\x12 \n\x1c\x43\x41NCEL_REASON_USER_REQUESTED\x10\x01\x12\x19\n\x15\x43\x41NCEL_REASON_TIMEOUT\x10\x02\x12 \n\x1c\x43\x41NCEL_REASON_INTERNAL_ERROR\x10\x03\x32H\n\x0eHarnessService\x12\x36\n\x07\x43onnect\x12\x12.ax.HarnessRequest\x1a\x13.ax.HarnessResponse(\x01\x30\x01\x32?\n\x10\x45xecutionService\x12+\n\x04\x45xec\x12\x0f.ax.ExecRequest\x1a\x10.ax.ExecResponse0\x01\x32j\n\x13\x43onversationService\x12S\n\x12\x44\x65leteConversation\x12\x1d.ax.DeleteConversationRequest\x1a\x1e.ax.DeleteConversationResponseB\x1cZ\x1agithub.com/google/ax/protob\x06proto3')
+
+_globals = globals()
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'proto.ax_pb2', _globals)
+if _descriptor._USE_C_DESCRIPTORS == False:
+ _globals['DESCRIPTOR']._options = None
+ _globals['DESCRIPTOR']._serialized_options = b'Z\032github.com/google/ax/proto'
+ _globals['_STATE']._serialized_start=1153
+ _globals['_STATE']._serialized_end=1261
+ _globals['_CANCELREASON']._serialized_start=1264
+ _globals['_CANCELREASON']._serialized_end=1404
+ _globals['_MESSAGE']._serialized_start=73
+ _globals['_MESSAGE']._serialized_end=126
+ _globals['_CONVERSATIONEVENT']._serialized_start=129
+ _globals['_CONVERSATIONEVENT']._serialized_end=329
+ _globals['_HARNESSSTART']._serialized_start=331
+ _globals['_HARNESSSTART']._serialized_end=400
+ _globals['_HARNESSCANCEL']._serialized_start=402
+ _globals['_HARNESSCANCEL']._serialized_end=451
+ _globals['_HARNESSREQUEST']._serialized_start=454
+ _globals['_HARNESSREQUEST']._serialized_end=595
+ _globals['_HARNESSOUTPUTS']._serialized_start=597
+ _globals['_HARNESSOUTPUTS']._serialized_end=644
+ _globals['_ERROR']._serialized_start=646
+ _globals['_ERROR']._serialized_end=688
+ _globals['_HARNESSEND']._serialized_start=690
+ _globals['_HARNESSEND']._serialized_end=754
+ _globals['_HARNESSRESPONSE']._serialized_start=756
+ _globals['_HARNESSRESPONSE']._serialized_end=876
+ _globals['_EXECREQUEST']._serialized_start=879
+ _globals['_EXECREQUEST']._serialized_end=1008
+ _globals['_EXECRESPONSE']._serialized_start=1010
+ _globals['_EXECRESPONSE']._serialized_end=1067
+ _globals['_DELETECONVERSATIONREQUEST']._serialized_start=1069
+ _globals['_DELETECONVERSATIONREQUEST']._serialized_end=1121
+ _globals['_DELETECONVERSATIONRESPONSE']._serialized_start=1123
+ _globals['_DELETECONVERSATIONRESPONSE']._serialized_end=1151
+ _globals['_HARNESSSERVICE']._serialized_start=1406
+ _globals['_HARNESSSERVICE']._serialized_end=1478
+ _globals['_EXECUTIONSERVICE']._serialized_start=1480
+ _globals['_EXECUTIONSERVICE']._serialized_end=1543
+ _globals['_CONVERSATIONSERVICE']._serialized_start=1545
+ _globals['_CONVERSATIONSERVICE']._serialized_end=1651
+# @@protoc_insertion_point(module_scope)
diff --git a/python/proto/ax_pb2_grpc.py b/python/proto/ax_pb2_grpc.py
new file mode 100644
index 00000000..b35d9534
--- /dev/null
+++ b/python/proto/ax_pb2_grpc.py
@@ -0,0 +1,210 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
+from proto import ax_pb2 as proto_dot_ax__pb2
+
+
+class HarnessServiceStub(object):
+ """Missing associated documentation comment in .proto file."""
+
+ def __init__(self, channel):
+ """Constructor.
+
+ Args:
+ channel: A grpc.Channel.
+ """
+ self.Connect = channel.stream_stream(
+ '/ax.HarnessService/Connect',
+ request_serializer=proto_dot_ax__pb2.HarnessRequest.SerializeToString,
+ response_deserializer=proto_dot_ax__pb2.HarnessResponse.FromString,
+ )
+
+
+class HarnessServiceServicer(object):
+ """Missing associated documentation comment in .proto file."""
+
+ def Connect(self, request_iterator, context):
+ """Connect drives one harness execution. The client sends
+ HarnessRequest{start} (and may send one HarnessRequest{cancel}
+ mid-stream), and the server streams zero or more HarnessResponse{outputs}
+ frames terminated by exactly one HarnessResponse{end}.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+
+def add_HarnessServiceServicer_to_server(servicer, server):
+ rpc_method_handlers = {
+ 'Connect': grpc.stream_stream_rpc_method_handler(
+ servicer.Connect,
+ request_deserializer=proto_dot_ax__pb2.HarnessRequest.FromString,
+ response_serializer=proto_dot_ax__pb2.HarnessResponse.SerializeToString,
+ ),
+ }
+ generic_handler = grpc.method_handlers_generic_handler(
+ 'ax.HarnessService', rpc_method_handlers)
+ server.add_generic_rpc_handlers((generic_handler,))
+
+
+ # This class is part of an EXPERIMENTAL API.
+class HarnessService(object):
+ """Missing associated documentation comment in .proto file."""
+
+ @staticmethod
+ def Connect(request_iterator,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.stream_stream(request_iterator, target, '/ax.HarnessService/Connect',
+ proto_dot_ax__pb2.HarnessRequest.SerializeToString,
+ proto_dot_ax__pb2.HarnessResponse.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+
+class ExecutionServiceStub(object):
+ """Missing associated documentation comment in .proto file."""
+
+ def __init__(self, channel):
+ """Constructor.
+
+ Args:
+ channel: A grpc.Channel.
+ """
+ self.Exec = channel.unary_stream(
+ '/ax.ExecutionService/Exec',
+ request_serializer=proto_dot_ax__pb2.ExecRequest.SerializeToString,
+ response_deserializer=proto_dot_ax__pb2.ExecResponse.FromString,
+ )
+
+
+class ExecutionServiceServicer(object):
+ """Missing associated documentation comment in .proto file."""
+
+ def Exec(self, request, context):
+ """Exec executes an agentic task or resumes an existing one with streaming responses
+ If the conversation_id already exists, it will be resumed.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+
+def add_ExecutionServiceServicer_to_server(servicer, server):
+ rpc_method_handlers = {
+ 'Exec': grpc.unary_stream_rpc_method_handler(
+ servicer.Exec,
+ request_deserializer=proto_dot_ax__pb2.ExecRequest.FromString,
+ response_serializer=proto_dot_ax__pb2.ExecResponse.SerializeToString,
+ ),
+ }
+ generic_handler = grpc.method_handlers_generic_handler(
+ 'ax.ExecutionService', rpc_method_handlers)
+ server.add_generic_rpc_handlers((generic_handler,))
+
+
+ # This class is part of an EXPERIMENTAL API.
+class ExecutionService(object):
+ """Missing associated documentation comment in .proto file."""
+
+ @staticmethod
+ def Exec(request,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.unary_stream(request, target, '/ax.ExecutionService/Exec',
+ proto_dot_ax__pb2.ExecRequest.SerializeToString,
+ proto_dot_ax__pb2.ExecResponse.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
+
+
+class ConversationServiceStub(object):
+ """Missing associated documentation comment in .proto file."""
+
+ def __init__(self, channel):
+ """Constructor.
+
+ Args:
+ channel: A grpc.Channel.
+ """
+ self.DeleteConversation = channel.unary_unary(
+ '/ax.ConversationService/DeleteConversation',
+ request_serializer=proto_dot_ax__pb2.DeleteConversationRequest.SerializeToString,
+ response_deserializer=proto_dot_ax__pb2.DeleteConversationResponse.FromString,
+ )
+
+
+class ConversationServiceServicer(object):
+ """Missing associated documentation comment in .proto file."""
+
+ def DeleteConversation(self, request, context):
+ """Deletes conversational events and all event log resources
+ for its children executions.
+ """
+ context.set_code(grpc.StatusCode.UNIMPLEMENTED)
+ context.set_details('Method not implemented!')
+ raise NotImplementedError('Method not implemented!')
+
+
+def add_ConversationServiceServicer_to_server(servicer, server):
+ rpc_method_handlers = {
+ 'DeleteConversation': grpc.unary_unary_rpc_method_handler(
+ servicer.DeleteConversation,
+ request_deserializer=proto_dot_ax__pb2.DeleteConversationRequest.FromString,
+ response_serializer=proto_dot_ax__pb2.DeleteConversationResponse.SerializeToString,
+ ),
+ }
+ generic_handler = grpc.method_handlers_generic_handler(
+ 'ax.ConversationService', rpc_method_handlers)
+ server.add_generic_rpc_handlers((generic_handler,))
+
+
+ # This class is part of an EXPERIMENTAL API.
+class ConversationService(object):
+ """Missing associated documentation comment in .proto file."""
+
+ @staticmethod
+ def DeleteConversation(request,
+ target,
+ options=(),
+ channel_credentials=None,
+ call_credentials=None,
+ insecure=False,
+ compression=None,
+ wait_for_ready=None,
+ timeout=None,
+ metadata=None):
+ return grpc.experimental.unary_unary(request, target, '/ax.ConversationService/DeleteConversation',
+ proto_dot_ax__pb2.DeleteConversationRequest.SerializeToString,
+ proto_dot_ax__pb2.DeleteConversationResponse.FromString,
+ options, channel_credentials,
+ insecure, call_credentials, compression, wait_for_ready, timeout, metadata)
diff --git a/python/proto/content_pb2.py b/python/proto/content_pb2.py
new file mode 100644
index 00000000..c20ef7bf
--- /dev/null
+++ b/python/proto/content_pb2.py
@@ -0,0 +1,80 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# -*- coding: utf-8 -*-
+# Generated by the protocol buffer compiler. DO NOT EDIT!
+# source: proto/content.proto
+# Protobuf Python Version: 4.25.1
+"""Generated protocol buffer code."""
+from google.protobuf import descriptor as _descriptor
+from google.protobuf import descriptor_pool as _descriptor_pool
+from google.protobuf import symbol_database as _symbol_database
+from google.protobuf.internal import builder as _builder
+# @@protoc_insertion_point(imports)
+
+_sym_db = _symbol_database.Default()
+
+
+from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2
+
+
+DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x13proto/content.proto\x12\x02\x61x\x1a\x1cgoogle/protobuf/struct.proto\"-\n\x0bTextContent\x12\x0c\n\x04text\x18\x03 \x01(\tJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03R\x04type\"$\n\x10\x41pprovalDecision\x12\x10\n\x08\x61pproved\x18\x01 \x01(\x08\"1\n\x0f\x44\x65\x63lineDecision\x12\x10\n\x08\x64\x65\x63lined\x18\x01 \x01(\x08J\x04\x08\x02\x10\x03R\x06reason\"\xa3\x01\n\x13\x43onfirmationContent\x12\n\n\x02id\x18\x03 \x01(\t\x12\x10\n\x08question\x18\x04 \x01(\t\x12(\n\x08\x61pproval\x18\x05 \x01(\x0b\x32\x14.ax.ApprovalDecisionH\x00\x12&\n\x07\x64\x65\x63line\x18\x06 \x01(\x0b\x32\x13.ax.DeclineDecisionH\x00\x42\n\n\x08\x64\x65\x63isionJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03R\x04type\"F\n\x15ThoughtSummaryContent\x12\x1f\n\x04text\x18\x01 \x01(\x0b\x32\x0f.ax.TextContentH\x00\x42\x06\n\x04typeJ\x04\x08\x02\x10\x03\"y\n\x0eThoughtContent\x12\x11\n\tsignature\x18\x07 \x01(\x0c\x12*\n\x07summary\x18\t \x03(\x0b\x32\x19.ax.ThoughtSummaryContentJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05J\x04\x08\x05\x10\x06J\x04\x08\x08\x10\tR\x04type\"j\n\x0fToolCallContent\x12\n\n\x02id\x18\x01 \x01(\t\x12\x11\n\tsignature\x18\t \x01(\x0c\x12\x30\n\rfunction_call\x18\x02 \x01(\x0b\x32\x17.ax.FunctionCallContentH\x00\x42\x06\n\x04type\"{\n\x11ToolResultContent\x12\x0f\n\x07\x63\x61ll_id\x18\x08 \x01(\t\x12\x11\n\tsignature\x18\t \x01(\x0c\x12\x34\n\x0f\x66unction_result\x18\x02 \x01(\x0b\x32\x19.ax.FunctionResultContentH\x00\x42\x06\n\x04typeJ\x04\x08\x01\x10\x02\"[\n\x13\x46unctionCallContent\x12\x0c\n\x04name\x18\x03 \x01(\t\x12*\n\targuments\x18\x04 \x01(\x0b\x32\x17.google.protobuf.StructJ\x04\x08\x01\x10\x02J\x04\x08\x02\x10\x03\"n\n\x15\x46unctionResultContent\x12\x0c\n\x04name\x18\x08 \x01(\t\x12+\n\x08response\x18\x03 \x01(\x0b\x32\x17.google.protobuf.StructH\x00\x42\x08\n\x06resultJ\x04\x08\x01\x10\x02J\x04\x08\x04\x10\x05R\x04type\"\xbd\x02\n\x0cImageContent\x12,\n\tmime_type\x18\x01 \x01(\x0e\x32\x19.ax.ImageContent.MimeType\x12\x0e\n\x04\x64\x61ta\x18\x02 \x01(\x0cH\x00\x12\r\n\x03uri\x18\x06 \x01(\tH\x00\x12\'\n\nresolution\x18\x05 \x01(\x0e\x32\x13.ax.MediaResolution\"\x9b\x01\n\x08MimeType\x12\x14\n\x10TYPE_UNSPECIFIED\x10\x00\x12\x0c\n\x08TYPE_PNG\x10\x01\x12\r\n\tTYPE_JPEG\x10\x02\x12\r\n\tTYPE_WEBP\x10\x03\x12\r\n\tTYPE_HEIC\x10\x04\x12\r\n\tTYPE_HEIF\x10\x05\x12\x0c\n\x08TYPE_GIF\x10\x07\x12\x0c\n\x08TYPE_BMP\x10\x08\x12\r\n\tTYPE_TIFF\x10\t\"\x04\x08\x06\x10\x06\x42\r\n\x0b\x64\x61ta_or_uriJ\x04\x08\x03\x10\x04R\x04type\"\x8b\x03\n\x0c\x41udioContent\x12,\n\tmime_type\x18\x01 \x01(\x0e\x32\x19.ax.AudioContent.MimeType\x12\x0e\n\x04\x64\x61ta\x18\x02 \x01(\x0cH\x00\x12\r\n\x03uri\x18\x05 \x01(\tH\x00\x12\x10\n\x08\x63hannels\x18\x07 \x01(\x05\x12\x13\n\x0bsample_rate\x18\x08 \x01(\x05\"\xdf\x01\n\x08MimeType\x12\x14\n\x10TYPE_UNSPECIFIED\x10\x00\x12\x0c\n\x08TYPE_WAV\x10\x01\x12\x0c\n\x08TYPE_MP3\x10\x02\x12\r\n\tTYPE_AIFF\x10\x03\x12\x0c\n\x08TYPE_AAC\x10\x04\x12\x0c\n\x08TYPE_OGG\x10\x05\x12\r\n\tTYPE_FLAC\x10\x06\x12\r\n\tTYPE_MPEG\x10\x07\x12\x0c\n\x08TYPE_M4A\x10\x08\x12\x0c\n\x08TYPE_L16\x10\t\x12\x0e\n\nTYPE_S16LE\x10\n\x12\r\n\tTYPE_OPUS\x10\x0b\x12\r\n\tTYPE_ALAW\x10\x0c\x12\x0e\n\nTYPE_MULAW\x10\rB\r\n\x0b\x64\x61ta_or_uriJ\x04\x08\x03\x10\x04J\x04\x08\x06\x10\x07R\x04typeR\x04rate\"\xcc\x01\n\x0f\x44ocumentContent\x12/\n\tmime_type\x18\x01 \x01(\x0e\x32\x1c.ax.DocumentContent.MimeType\x12\x0e\n\x04\x64\x61ta\x18\x02 \x01(\x0cH\x00\x12\r\n\x03uri\x18\x05 \x01(\tH\x00\"N\n\x08MimeType\x12\x14\n\x10TYPE_UNSPECIFIED\x10\x00\x12\x0c\n\x08TYPE_PDF\x10\x01\x12\r\n\tTYPE_JSON\x10\x02\x12\x0f\n\x0bTYPE_PYTHON\x10\x03\x42\r\n\x0b\x64\x61ta_or_uriJ\x04\x08\x03\x10\x04R\x04type\"\xc5\x02\n\x0cVideoContent\x12,\n\tmime_type\x18\x01 \x01(\x0e\x32\x19.ax.VideoContent.MimeType\x12\x0e\n\x04\x64\x61ta\x18\x02 \x01(\x0cH\x00\x12\r\n\x03uri\x18\x06 \x01(\tH\x00\x12\'\n\nresolution\x18\x05 \x01(\x0e\x32\x13.ax.MediaResolution\"\xa3\x01\n\x08MimeType\x12\x14\n\x10TYPE_UNSPECIFIED\x10\x00\x12\x0c\n\x08TYPE_MP4\x10\x01\x12\r\n\tTYPE_MPEG\x10\x02\x12\x0c\n\x08TYPE_MPG\x10\x03\x12\x0c\n\x08TYPE_MOV\x10\x04\x12\x0c\n\x08TYPE_AVI\x10\x05\x12\x0e\n\nTYPE_X_FLV\x10\x06\x12\r\n\tTYPE_WEBM\x10\x07\x12\x0c\n\x08TYPE_WMV\x10\x08\x12\r\n\tTYPE_3GPP\x10\tB\r\n\x0b\x64\x61ta_or_uriJ\x04\x08\x03\x10\x04R\x04type\"\x86\x03\n\x07\x43ontent\x12%\n\x07thought\x18\x05 \x01(\x0b\x32\x12.ax.ThoughtContentH\x00\x12\x1f\n\x04text\x18\n \x01(\x0b\x32\x0f.ax.TextContentH\x00\x12!\n\x05image\x18\x0b \x01(\x0b\x32\x10.ax.ImageContentH\x00\x12!\n\x05\x61udio\x18\x0c \x01(\x0b\x32\x10.ax.AudioContentH\x00\x12\'\n\x08\x64ocument\x18\r \x01(\x0b\x32\x13.ax.DocumentContentH\x00\x12!\n\x05video\x18\x0e \x01(\x0b\x32\x10.ax.VideoContentH\x00\x12/\n\x0c\x63onfirmation\x18\x1a \x01(\x0b\x32\x17.ax.ConfirmationContentH\x00\x12(\n\ttool_call\x18\x18 \x01(\x0b\x32\x13.ax.ToolCallContentH\x00\x12,\n\x0btool_result\x18\x19 \x01(\x0b\x32\x15.ax.ToolResultContentH\x00\x42\x06\n\x04typeJ\x04\x08\x01\x10\x05J\x04\x08\x06\x10\nJ\x04\x08\x0f\x10\x18*b\n\x0fMediaResolution\x12 \n\x1cMEDIA_RESOLUTION_UNSPECIFIED\x10\x00\x12\x07\n\x03LOW\x10\x01\x12\n\n\x06MEDIUM\x10\x02\x12\x08\n\x04HIGH\x10\x03\x12\x0e\n\nULTRA_HIGH\x10\x04\x42\x1cZ\x1agithub.com/google/ax/protob\x06proto3')
+
+_globals = globals()
+_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
+_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'proto.content_pb2', _globals)
+if _descriptor._USE_C_DESCRIPTORS == False:
+ _globals['DESCRIPTOR']._options = None
+ _globals['DESCRIPTOR']._serialized_options = b'Z\032github.com/google/ax/proto'
+ _globals['_MEDIARESOLUTION']._serialized_start=2638
+ _globals['_MEDIARESOLUTION']._serialized_end=2736
+ _globals['_TEXTCONTENT']._serialized_start=57
+ _globals['_TEXTCONTENT']._serialized_end=102
+ _globals['_APPROVALDECISION']._serialized_start=104
+ _globals['_APPROVALDECISION']._serialized_end=140
+ _globals['_DECLINEDECISION']._serialized_start=142
+ _globals['_DECLINEDECISION']._serialized_end=191
+ _globals['_CONFIRMATIONCONTENT']._serialized_start=194
+ _globals['_CONFIRMATIONCONTENT']._serialized_end=357
+ _globals['_THOUGHTSUMMARYCONTENT']._serialized_start=359
+ _globals['_THOUGHTSUMMARYCONTENT']._serialized_end=429
+ _globals['_THOUGHTCONTENT']._serialized_start=431
+ _globals['_THOUGHTCONTENT']._serialized_end=552
+ _globals['_TOOLCALLCONTENT']._serialized_start=554
+ _globals['_TOOLCALLCONTENT']._serialized_end=660
+ _globals['_TOOLRESULTCONTENT']._serialized_start=662
+ _globals['_TOOLRESULTCONTENT']._serialized_end=785
+ _globals['_FUNCTIONCALLCONTENT']._serialized_start=787
+ _globals['_FUNCTIONCALLCONTENT']._serialized_end=878
+ _globals['_FUNCTIONRESULTCONTENT']._serialized_start=880
+ _globals['_FUNCTIONRESULTCONTENT']._serialized_end=990
+ _globals['_IMAGECONTENT']._serialized_start=993
+ _globals['_IMAGECONTENT']._serialized_end=1310
+ _globals['_IMAGECONTENT_MIMETYPE']._serialized_start=1128
+ _globals['_IMAGECONTENT_MIMETYPE']._serialized_end=1283
+ _globals['_AUDIOCONTENT']._serialized_start=1313
+ _globals['_AUDIOCONTENT']._serialized_end=1708
+ _globals['_AUDIOCONTENT_MIMETYPE']._serialized_start=1446
+ _globals['_AUDIOCONTENT_MIMETYPE']._serialized_end=1669
+ _globals['_DOCUMENTCONTENT']._serialized_start=1711
+ _globals['_DOCUMENTCONTENT']._serialized_end=1915
+ _globals['_DOCUMENTCONTENT_MIMETYPE']._serialized_start=1810
+ _globals['_DOCUMENTCONTENT_MIMETYPE']._serialized_end=1888
+ _globals['_VIDEOCONTENT']._serialized_start=1918
+ _globals['_VIDEOCONTENT']._serialized_end=2243
+ _globals['_VIDEOCONTENT_MIMETYPE']._serialized_start=2053
+ _globals['_VIDEOCONTENT_MIMETYPE']._serialized_end=2216
+ _globals['_CONTENT']._serialized_start=2246
+ _globals['_CONTENT']._serialized_end=2636
+# @@protoc_insertion_point(module_scope)
diff --git a/python/proto/content_pb2_grpc.py b/python/proto/content_pb2_grpc.py
new file mode 100644
index 00000000..fe9b2e77
--- /dev/null
+++ b/python/proto/content_pb2_grpc.py
@@ -0,0 +1,18 @@
+# Copyright 2026 Google LLC
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT!
+"""Client and server classes corresponding to protobuf-defined services."""
+import grpc
+
diff --git a/python/python.go b/python/python.go
new file mode 100644
index 00000000..faa502e3
--- /dev/null
+++ b/python/python.go
@@ -0,0 +1,24 @@
+// Copyright 2026 Google LLC
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// Package python embeds the Python directory assets (such as antigravity and proto modules)
+// for use by the sidecar and harnesses.
+package python
+
+import "embed"
+
+// FS contains the embedded python/ directory assets.
+//
+//go:embed antigravity proto antigravity/_*.py proto/_*.py
+var FS embed.FS