WikifitaGitHub live67e8de5
outro · sdk-test/sdk-test-localharness

Localharness Architecture — Go Binary Subprocess Deep-Dive

Complete architectural analysis of the localharness Go binary: subprocess lifecycle, WebSocket protocol, handshake sequence, binary discovery chain, and connection management

Baixar raw

Localharness Architecture — Go Binary Subprocess Deep-Dive

What Is Localharness?

Localharness is a pre-compiled Go binary that ships inside platform-specific wheels of the google-antigravity Python SDK. It is the actual agent runtime -- the Python layer is an orchestrator that manages its lifecycle. The SDK spawns it as a child process and communicates over a WebSocket connection using Protocol Buffers serialized as JSON.

The binary is the bridge between the Python SDK (google.antigravity) and the Gemini model backend. It handles:

  • Model API calls (Gemini, Gemma, Custom backends)
  • Built-in tool execution (file ops, shell, subagents, questions)
  • MCP server management
  • Trajectory persistence
  • Context compaction
  • Browser automation (CDP)

Architecture Overview

graph TB
    subgraph SDK_Process["Python SDK Process"]
        Agent["Agent<br/>async context manager"]
        Conv["Conversation<br/>session state"]
        LConn["LocalConnection<br/>WebSocket client"]
        TR["ToolRunner<br/>Python tools"]
        HR["HookRunner<br/>lifecycle hooks"]
        TGR["TriggerRunner<br/>background tasks"]

        Agent --> Conv
        Conv --> LConn
        Agent --> TR
        Agent --> HR
        Agent --> TGR
        LConn -.-> TR
        LConn -.-> HR
    end

    subgraph Transport["Transport Layer"]
        WS["WebSocket<br/>ws://localhost:{port}/"]
        STDIN["stdin pipe<br/>length-prefixed protobuf"]
        STDOUT["stdout pipe<br/>length-prefixed protobuf"]
        STDERR["stderr pipe<br/>log stream"]
    end

    subgraph Harness_Process["localharness (Go binary)"]
        WSS["WebSocket Server"]
        AL["Agent Loop<br/>(Cortex)"]
        MT["Model Transport<br/>gRPC to Gemini"]
        BTools["Built-in Tools<br/>file/shell/subagent"]
        MCPM["MCP Manager"]
        TPS["Trajectory Persistence"]
        SR["Stderr Logger"]

        WSS --> AL
        AL --> MT
        AL --> BTools
        AL --> MCPM
        AL --> TPS
    end

    LConn -->|"JSON-serialized protobuf"| WS
    Agent -->|"InputConfig (4-byte LE + pb)"| STDIN
    STDIN -->|"OutputConfig (port + api_key)"| STDOUT
    STDOUT --> LConn
    STDERR --> SR

    WS <--> WSS
    MT -->|"gRPC + Bearer"| GCloud["Gemini API<br/>cloudcode-pa.googleapis.com"]

Connection Lifecycle

The connection lifecycle has 7 distinct phases. Understanding each phase is critical for debugging, extending, or building alternative harness implementations.

Phase 1: Binary Discovery

The SDK locates the localharness binary through a 4-step fallback chain. This ensures the binary is found regardless of installation method (wheel, development, system PATH).

flowchart TD
    Start["LocalConnectionStrategy.__aenter__()"] --> EV{"ANTIGRAVITY_HARNESS_PATH<br/>env var set?"}
    EV -->|YES| UseEnv["Use env var path"]
    EV -->|NO| Meta{"importlib.metadata<br/>wheel discovery?"}
    Meta -->|FOUND| UseMeta["Use wheel binary<br/>google/antigravity/bin/localharness"]
    Meta -->|NOT FOUND| Res{"importlib.resources<br/>fallback?"}
    Res -->|FOUND| UseRes["Use resource binary"]
    Res -->|NOT FOUND| PATH{"shutil.which<br/>'localharness' in PATH?"}
    PATH -->|FOUND| UsePATH["Use system PATH binary"]
    PATH -->|NOT FOUND| FAIL["Raise FileNotFoundError"]

    UseEnv --> Validate["Validate binary exists + executable"]
    UseMeta --> Validate
    UseRes --> Validate
    UsePATH --> Validate
    Validate --> Phase2["Proceed to Phase 2: Spawn"]

Implementation detail: The environment variable path takes absolute precedence. In development, symlinking to a local build via ANTIGRAVITY_HARNESS_PATH is the standard pattern.

Phase 2: Process Spawn

# Pseudocode from LocalConnectionStrategy.__aenter__()
process = subprocess.Popen(
    [binary_path],
    stdin=subprocess.PIPE,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    # No shell=True -- direct exec for security
)

The harness binary starts and immediately blocks on stdin, waiting for the InputConfig message.

Phase 3: stdin/stdout Handshake

This is a synchronous, length-prefixed protobuf exchange over the process's stdin and stdout pipes. It happens BEFORE the WebSocket connection is established.

sequenceDiagram
    participant SDK as Python SDK
    participant Harness as localharness (Go)
    participant WSS as WebSocket Server

    Note over SDK,Harness: Phase 3: stdin/stdout Handshake

    SDK->>Harness: [4 bytes LE length][InputConfig protobuf]
    Note right of Harness: Parses InputConfig:<br/>- storage_directory<br/>- port (0 = auto)<br/>- bind_address (localhost)<br/>- client_info

    Harness->>WSS: Start WebSocket server on port
    WSS-->>Harness: Server ready, port assigned

    Harness->>SDK: [4 bytes LE length][OutputConfig protobuf]
    Note left of SDK: Parses OutputConfig:<br/>- port (actual port)<br/>- api_key (ephemeral)

Wire format for stdin/stdout:

[uint32 little-endian: message_length][protobuf bytes: message_body]

The api_key in OutputConfig is an ephemeral key generated by the harness for WebSocket authentication. It is NOT the Gemini API key -- it is a local-only credential to prevent other processes on the same machine from connecting.

Phase 4: WebSocket Connection

sequenceDiagram
    participant SDK as Python SDK
    participant Harness as localharness (Go)

    Note over SDK,Harness: Phase 4: WebSocket Connect

    SDK->>Harness: WebSocket upgrade request
    Note right of Harness: Header: x-goog-api-key: {api_key}

    alt Connection succeeds
        Harness-->>SDK: 101 Switching Protocols
    else Connection refused (port mismatch)
        SDK->>Harness: Retry with exponential backoff
        Note left of SDK: 5 attempts, max delay ~2s
    end

    Note over SDK,Harness: WebSocket established at ws://localhost:{port}/

Retry strategy: The SDK retries the WebSocket connection up to 5 times with exponential backoff. This handles the race condition where the harness hasn't finished starting its WebSocket server by the time the SDK tries to connect.

Phase 5: Initialize Conversation

sequenceDiagram
    participant SDK as Python SDK
    participant Harness as localharness (Go)

    Note over SDK,Harness: Phase 5: Initialize

    SDK->>Harness: InitializeConversationEvent (JSON-serialized protobuf)
    Note right of Harness: HarnessConfig contains:<br/>- cascade_id (session ID)<br/>- model_config (oneof: Gemini/Gemma/Custom)<br/>- system_instructions<br/>- tools (Python tools as schemas)<br/>- harness_side_tools (toggles)<br/>- workspaces<br/>- mcp_servers<br/>- skills_paths<br/>- compaction_threshold<br/>- initial_trajectory (if resuming)

    Harness-->>SDK: TrajectoryStateUpdate(state=RUNNING)

The InitializeConversationEvent is the single most important message in the protocol. It carries the entire agent configuration as a HarnessConfig protobuf. See sdk-test-protobuf for the complete field reference.

Phase 6: Message Loop

The bidirectional message loop is the core runtime. Messages flow as JSON-serialized protobuf over the WebSocket.

sequenceDiagram
    participant SDK as Python SDK
    participant Harness as localharness (Go)
    participant Gemini as Gemini API

    Note over SDK,Gemini: Phase 6: Message Loop

    SDK->>Harness: InputEvent{user_input: "What files are here?"}
    Harness->>Gemini: gRPC Chat request
    Gemini-->>Harness: Streaming response chunks

    loop For each step
        Harness-->>SDK: OutputEvent{step_update: {text_delta: "..."}}
        Harness-->>SDK: OutputEvent{step_update: {thinking_delta: "..."}}
    end

    alt Tool call required
        Harness-->>SDK: OutputEvent{step_update: {action: list_directory{...}}}
        Note over SDK: SDK dispatches PreToolCallDecideHook
        SDK->>Harness: InputEvent{tool_confirmation: {accepted: true}}
        Harness-->>SDK: OutputEvent{step_update: {state: DONE}}
    end

    alt Host-side tool (Python)
        Harness-->>SDK: OutputEvent{tool_call: {name: "get_weather", args: ...}}
        Note over SDK: SDK executes via ToolRunner
        SDK->>Harness: InputEvent{tool_response: {response_json: "..."}}
    end

    alt Subagent spawned
        Harness-->>SDK: OutputEvent{step_update: {action: invoke_subagent{}}}
        Note over Harness: New trajectory created
        Harness-->>SDK: OutputEvent{step_update: {trajectory_id: "sub-1", ...}}
        Harness-->>SDK: TrajectoryStateUpdate{trajectory_id: "sub-1", state: IDLE}
    end

    Harness-->>SDK: OutputEvent{step_update: {state: DONE, action: finish{...}}}
    Harness-->>SDK: TrajectoryStateUpdate{state: IDLE}

Message serialization: All WebSocket messages are JSON-serialized protobuf. The SDK uses google.protobuf.json_format for serialization/deserialization. This is NOT binary protobuf over WebSocket -- it is the JSON mapping, which enables easy debugging with standard WebSocket tools.

Phase 7: Shutdown

The shutdown sequence is a 7-step escalation designed to handle graceful and ungraceful termination.

sequenceDiagram
    participant SDK as Python SDK
    participant Harness as localharness (Go)

    Note over SDK,Harness: Phase 7: Shutdown

    SDK->>SDK: 1. Dispatch on_session_end hook
    SDK->>SDK: 2. Cancel background tasks (triggers)
    SDK->>SDK: 3. Cancel WebSocket reader task

    SDK->>Harness: 4. Close WebSocket (graceful, 0.5s timeout)
    alt WebSocket close succeeds
        Harness-->>SDK: WebSocket close frame
    else Timeout
        Note over SDK: Proceed to step 5
    end

    SDK->>Harness: 5. Close stdin pipe (EOF)
    Note right of Harness: Go detects EOF on stdin<br/>→ os.Exit(0)

    alt Process exits within 5s
        Harness-->>SDK: Process exit code
    else Timeout
        SDK->>Harness: 6. SIGTERM
        alt Process exits within 1s
            Harness-->>SDK: Process terminated
        else Timeout
            SDK->>Harness: 7. SIGKILL
            Note over Harness: Force killed
        end
    end

Why stdin EOF triggers exit: The Go harness blocks on stdin reading in its main goroutine. When the SDK closes the stdin pipe, the read returns io.EOF, which the harness treats as a shutdown signal. This is a deliberate design -- it means the harness always exits when the SDK disconnects, even if the WebSocket teardown fails.

Binary Discovery Chain — Implementation Detail

Step 1: Environment Variable

# Highest priority: explicit override
path = os.environ.get("ANTIGRAVITY_HARNESS_PATH")
if path and os.path.isfile(path) and os.access(path, os.X_OK):
    return path

Step 2: Wheel Metadata

# Installed via pip/uv: binary is inside the wheel
import importlib.metadata
dist = importlib.metadata.distribution("google-antigravity")
# Looks for: google/antigravity/bin/localharness
# Platform-specific wheel: google_antigravity-0.1.2-cp310-cp310-macosx_11_0_arm64.whl

Step 3: Resource Fallback

# Fallback for editable installs or non-standard layouts
import importlib.resources
# Attempts to locate within package resources

Step 4: System PATH

# Last resort: system PATH
import shutil
path = shutil.which("localharness")

Default Data Paths

PathPurposeConfigurable Via
~/.gemini/antigravity/App data (config, logs, trajectories)app_data_dir in HarnessConfig
/tmp/antigravity_*Trajectory save (temporary)save_dir in AgentConfig
os.getcwd()Default workspaceworkspaces in AgentConfig

Workspace Scoping

When workspaces are configured (which they are by default -- os.getcwd()), the SDK automatically applies workspace_only() policies. This restricts file tools (view_file, create_file, edit_file) to the specified directories. The harness also enforces workspace validation via the PermissionsConfig protobuf message.

Stderr Handling

The harness writes diagnostic logs to stderr. The SDK starts a dedicated reader thread:

# In LocalConnectionStrategy.__aenter__()
stderr_thread = threading.Thread(
    target=_read_stderr,
    args=(process.stderr,),
    daemon=True,  # Don't block shutdown
)
stderr_thread.start()

Stderr lines are logged at DEBUG level. They are NOT surfaced to the user by default. The harness logs include:

  • WebSocket connection events
  • Model API call latencies
  • Tool execution traces
  • MCP server lifecycle events
  • Trajectory persistence operations

Built-in Tools

The harness executes 11 built-in tools natively (in Go). These are NOT Python tools -- they run in the harness process.

ToolExecutionConfirmation Required
list_directoryHarness-sideNo
search_directoryHarness-sideNo
find_fileHarness-sideNo
view_fileHarness-sideNo
create_fileHarness-sideYes (configurable)
edit_fileHarness-sideYes (configurable)
run_commandHarness-sideYes (default policy)
ask_questionHost-side (SDK)N/A
start_subagentHarness-sideYes (configurable)
generate_imageHarness-sideNo
finishHarness-sideNo

Confirmation Flow

When a tool requires confirmation, the flow is:

sequenceDiagram
    participant Model as Gemini Model
    participant Harness as localharness
    participant SDK as Python SDK
    participant User as User

    Model->>Harness: Tool call: run_command("rm -rf /tmp/test")
    Harness-->>SDK: StepUpdate{tool_confirmation_request, action: run_command}
    SDK->>SDK: Dispatch PreToolCallDecideHook
    alt Policy: allow
        SDK->>Harness: ToolConfirmation(accepted=true)
    else Policy: deny
        SDK->>Harness: ToolConfirmation(accepted=false)
    else Policy: ask_user
        SDK->>User: "Allow run_command?"
        User-->>SDK: yes/no
        SDK->>Harness: ToolConfirmation(accepted=user_answer)
    end
    Harness->>Model: Continue with result

Host-Side Tool Flow (Python Tools)

Python tools registered via the SDK run in the SDK process, not the harness. The flow is:

sequenceDiagram
    participant Model as Gemini Model
    participant Harness as localharness
    participant SDK as Python SDK

    Model->>Harness: Needs custom tool "get_weather"
    Harness-->>SDK: OutputEvent{tool_call: {id: "tc_1", name: "get_weather", args: {city: "Tokyo"}}}
    SDK->>SDK: PreToolCallDecideHook (policy check)
    SDK->>SDK: ToolRunner.execute("get_weather", city="Tokyo")
    Note over SDK: Runs sync tool in asyncio.to_thread()
    SDK->>SDK: PostToolCallHook or OnToolErrorHook
    SDK->>Harness: InputEvent{tool_response: {id: "tc_1", response_json: '{"result": "sunny"}'}}
    Harness->>Model: Tool result appended to context

Parallel execution: When the model requests multiple tools in one turn, ToolRunner.process_tool_calls() uses asyncio.gather() for concurrent execution. Each call is wrapped in try/except for error isolation.

Subagent Management

The harness supports spawning subagents as separate trajectories. The SDK tracks them:

# LocalConnection internal state
_active_subagent_ids: set[str] = set()
_subagent_responses: dict[str, str] = {}

Idle Detection

The connection is considered idle ONLY when:

  1. The parent trajectory is in IDLE state, AND
  2. ALL subagent trajectories are in IDLE state

This prevents premature session termination while subagents are still running.

Subagent Completion

When a subagent trajectory reaches IDLE state, the SDK:

  1. Captures the subagent's last model response
  2. Dispatches PostToolCallHook with the response content
  3. Removes the trajectory from _active_subagent_ids

Security Model

Ephemeral WebSocket API Key

The OutputConfig.api_key is generated fresh for each harness spawn. It is:

  • A random string generated by the Go harness
  • Used ONLY for the x-goog-api-key header in the WebSocket handshake
  • NOT the Gemini API key or any cloud credential
  • Valid only for the lifetime of the harness process

Workspace Validation

The PermissionsConfig.enforce_workspace_validation flag controls whether the harness validates that file operations stay within configured workspaces. When enabled:

  • view_file, create_file, edit_file paths must resolve within a workspace
  • Symlink traversal is checked
  • Path traversal attacks (../../../etc/passwd) are blocked

Tool Safety Guard

The Agent class enforces at startup: if write tools or MCP servers are enabled but no policies and no PreToolCallDecideHook are registered, it raises ValueError. This prevents accidentally deploying an unguarded agent with destructive capabilities.

Differences: Localharness vs LocalConnection

AspectLocalharness (Go)LocalConnection (Python)
RoleAgent runtimeOrchestrator
LanguageGoPython
Model callsDirect gRPC to GeminiDelegated to harness
Built-in toolsExecutes nativelyDispatches confirmation only
Python toolsReceives results via WebSocketExecutes and returns results
MCP serversManages connectionsConfigures only
Trajectory persistenceWrites to diskReads initial_trajectory for resume
Context compactionPerforms in-processNotified via compaction event
Browser automationCDP via GoNot involved
Subagent spawningCreates new trajectoriesTracks trajectory state

Performance Characteristics

MetricValue
Binary size~99 MB (ARM64, includes Go runtime)
Startup time~200-500ms (spawn + handshake)
WebSocket latency<1ms (localhost)
Handshake overhead~50-100ms (stdin/stdout exchange)
Memory baseline~50-80MB (Go runtime + WebSocket server)

Troubleshooting

Binary Not Found

FileNotFoundError: localharness binary not found

Fix: Set ANTIGRAVITY_HARNESS_PATH to the binary location, or reinstall the SDK wheel for your platform.

WebSocket Connection Refused

ConnectionRefusedError: [Errno 61] Connection refused

Fix: The harness may need more time to start. The SDK retries 5 times with exponential backoff. If persistent, check that the port is not in use.

Process Hangs on Shutdown

If the harness doesn't exit on stdin EOF, the SDK escalates to SIGTERM (1s) then SIGKILL (1s). Check stderr logs for the cause.

Cross-References


Source: antigravity-sdk-python v0.1.2, local_connection.py (1792 lines), local_connection_config.py (194 lines). Decompiled binary: language_server_hub (3305 functions).