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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions .github/workflows/adk-agent.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
name: adk-agent

# Lint + type-check + unit-test the standalone Python ADK agent. Separate from the Node CI
# (ci.yml) and the corpus job (corpus.yml); path-filtered so it only runs when the agent changes.
# Unit-only: the tests mock HTTP and the runner, so there's no network or model call.
on:
pull_request:
paths: ["integrations/adk-agent/**", ".github/workflows/adk-agent.yml"]
workflow_dispatch:

jobs:
lint-type-test:
runs-on: ubuntu-latest
timeout-minutes: 10 # unit-only; cap so a hung runner fails fast instead of the 360m default
defaults:
run:
working-directory: integrations/adk-agent
steps:
- uses: actions/checkout@v4

- name: Set up uv + Python 3.11
uses: astral-sh/setup-uv@v5
with:
python-version: "3.11"

- name: Install
run: uv sync

- name: Lint (ruff)
run: uv run ruff check .

- name: Type-check (mypy)
run: uv run mypy src

- name: Test (pytest)
run: uv run pytest -q
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,3 +61,5 @@ sequence/ER/class/state graphs) from the formats + copy-paste examples here:
| a live rich visual on a screen / iPad | `termchart push --type …` |
| show activity the instant work starts | `termchart begin …` (placeholder + focus + loader) |
| narrate long work (toasts / progress / loaders) | `termchart status …` |
| read the human's console feedback / clicks | `termchart inbox --follow` (or `--wait`) |
| auto-respond to a scope's console without a harness | run the standalone Python agent in [`integrations/adk-agent/`](integrations/adk-agent/) (Google ADK) |
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -217,7 +217,9 @@ push failed: HTTP 400 — panes[0].content must be a JSON string (got object)
route polylines, for location/route views right inside a dashboard.
- **Experimental agent↔human console** (off by default) — flip the "Console (beta)" toggle to send
the agent feedback / commands and click agent-pushed suggestion chips; the agent reads them back
with `termchart inbox` and offers choices with `termchart suggest`.
with `termchart inbox` and offers choices with `termchart suggest`. Instead of a coding harness,
you can also run a **standalone Python agent** that listens to a scope's console and responds by
rendering + replying — see [`integrations/adk-agent/`](integrations/adk-agent/) (Google ADK).
- Flows re-fit on resize (iPad rotation), carry legends, and use measured layout; a server-side
**geometry lint** flags edges that run over or too close to nodes so tangled graphs get fixed.
- **Durable & retrievable.** Views live server-side, so a *new* agent session can pick up where the
Expand Down
27 changes: 27 additions & 0 deletions integrations/adk-agent/.env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Copy to .env and fill in. Never commit a real .env (it's gitignored).

# --- termchart viewer (same contract as the CLI) -----------------------------
# Viewer base URL, INCLUDING the /w/<wsid> workspace segment.
TERMCHART_VIEWER_URL=http://localhost:8080/w/me
# Bearer token for push/patch/suggest/status. If unset, the agent can still post
# text replies (the inbox POST is tokenless) but cannot render diagrams.
TERMCHART_VIEWER_TOKEN=
# The single scope this agent listens to and responds in.
TERMCHART_PROJECT=demo
TERMCHART_AGENT=driver

# --- model backend (Vertex AI via ADC is the default) ------------------------
# Run `gcloud auth application-default login` once for ADC.
GOOGLE_GENAI_USE_VERTEXAI=1
# Read from YOUR environment / ADC — never hardcode or commit a real project id.
GOOGLE_CLOUD_PROJECT=your-gcp-project
GOOGLE_CLOUD_LOCATION=us-central1
# Fallback if you set GOOGLE_GENAI_USE_VERTEXAI=0 (no GCP):
# GEMINI_API_KEY=

# --- optional knobs ----------------------------------------------------------
# Override the model (flash-tier is the default, for low latency).
# TERMCHART_ADK_MODEL=gemini-2.5-flash
# TERMCHART_ADK_POLL_WAIT_MS=25000
# TERMCHART_ADK_REQUEST_TIMEOUT_MS=30000
# TERMCHART_ADK_BACKOFF_MAX_MS=15000
9 changes: 9 additions & 0 deletions integrations/adk-agent/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.env
.venv/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
uv.lock
dist/
96 changes: 96 additions & 0 deletions integrations/adk-agent/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# termchart ADK agent

A standalone [Google ADK](https://github.com/google/adk-python) agent that **listens to one
termchart viewer console scope and responds** — by rendering/updating the diagram on the canvas
**and** posting a short text reply in the console log.

It's an alternative to driving termchart from a coding harness (Claude Code / agy) via the
`termchart` CLI: instead of a harness reading the inbox and pushing diagrams, this is a small,
purpose-built Python process that does it for one scope. Use it as a runnable reference and
customize the prompt + tools for your own agent.

## How it works

```
human types in the console ──▶ GET /inbox (long-poll) ──▶ ADK agent (Gemini) runs a turn
│ render_diagram / patch_diagram
│ suggest_chips / set_status ──▶ canvas
incoming text reply ◀── POST /inbox (kind=message) ── the agent's final answer
```

- The listener long-polls `GET /inbox` for its `project/agent` scope (the same endpoint the CLI's
`inbox --follow` uses), with the CLI's resilience: back off 1s→2s→…→15s on transient errors,
exit on a 4xx, advance the cursor each healthy round.
- Each human message (or clicked chip) runs **one ADK turn**. The model calls tools that hit the
viewer's HTTP API to render/patch/suggest/status, then its final text is posted back into the
console as a reply (a tokenless `POST /inbox`).
- **Reading the inbox marks it read** (no `peek`), so the human sees *"Read by the agent ✓"*.
- **Loop-back guard:** the agent's own replies land in the same inbox it polls, so it records each
reply's `seq` and skips it on the next read — it never responds to itself.
- **Scope discipline:** it only ever acts on its one configured scope.

## Prerequisites

- Python 3.11+ and [`uv`](https://docs.astral.sh/uv/).
- A running termchart viewer (`termchart serve`, or a deployed `…/w/<wsid>` workspace).
- For the default Vertex AI backend: Application Default Credentials —
`gcloud auth application-default login` — plus a GCP project/location.

## Setup

```bash
cd integrations/adk-agent
cp .env.example .env # then edit .env
uv sync
```

Fill in `.env`:

| Variable | Required | Notes |
|---|---|---|
| `TERMCHART_VIEWER_URL` | yes | Viewer base **including** `/w/<wsid>` |
| `TERMCHART_VIEWER_TOKEN` | for rendering | Bearer token; unset ⇒ the agent can only post text replies |
| `TERMCHART_PROJECT` / `TERMCHART_AGENT` | yes | The single scope to listen on |
| `GOOGLE_GENAI_USE_VERTEXAI` | `1` (default) | Use Vertex AI |
| `GOOGLE_CLOUD_PROJECT` | for Vertex | **Read from your env via ADC — never hardcode/commit a real id** |
| `GOOGLE_CLOUD_LOCATION` | for Vertex | e.g. `us-central1` |
| `GEMINI_API_KEY` | fallback | Used only if `GOOGLE_GENAI_USE_VERTEXAI=0` |
| `TERMCHART_ADK_MODEL` | no | Defaults to a flash-tier model (low latency); override freely |

## Run

```bash
uv run termchart-adk
```

You'll see a redacted startup line (`scope=… · Vertex AI · project=… · model=…`) and
`following <scope>…`. Open the viewer, toggle the console on, select the same scope, and type a
message — the diagram appears and a reply lands in the log.

`--project` / `--agent` / `--model` override the env; `--once` runs a single poll round (for
smoke testing).

## Customize

- **`src/termchart_adk/prompts.py`** — the system instruction + the compact diagram cheat-sheet.
- **`src/termchart_adk/tools.py`** — the tools the model can call. Add your own (each is a typed
async function with a docstring) and they become available to the agent.

## Develop

```bash
uv run pytest # fast, hermetic (mocked HTTP + a scripted runner; no network, no LLM)
uv run ruff check .
uv run mypy src
```

## Troubleshooting

- **"no TERMCHART_VIEWER_TOKEN configured"** — set the token to enable rendering; without it the
agent still posts text replies.
- **ADC errors at the first model call** — run `gcloud auth application-default login` and set
`GOOGLE_CLOUD_PROJECT` / `GOOGLE_CLOUD_LOCATION` (or switch to `GEMINI_API_KEY`).
- **`config: …` then exit** — a required variable is missing; the message names it.
- **`reconnecting in Ns…`** — the viewer was briefly unreachable; the listener retries with
backoff and recovers on its own.
57 changes: 57 additions & 0 deletions integrations/adk-agent/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
[project]
name = "termchart-adk-agent"
version = "0.1.0"
description = "A standalone Google-ADK agent that listens to a termchart viewer console and responds by rendering diagrams and posting text replies."
readme = "README.md"
requires-python = ">=3.11"
license = "MIT"
dependencies = [
"google-adk>=1.26,<2",
"google-genai>=1.0",
"httpx>=0.27",
"pydantic>=2",
"pydantic-settings>=2",
]

[project.scripts]
termchart-adk = "termchart_adk.main:main"

[dependency-groups]
dev = [
"pytest>=8",
"pytest-asyncio>=0.23",
"ruff>=0.6",
"mypy>=1.8",
]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/termchart_adk"]

[tool.pytest.ini_options]
asyncio_mode = "auto"
testpaths = ["tests"]

[tool.ruff]
line-length = 120
target-version = "py311"
src = ["src", "tests"]

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B", "ASYNC"]

[tool.mypy]
python_version = "3.11"
mypy_path = "src"
plugins = ["pydantic.mypy"]
disallow_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
no_implicit_optional = true

[[tool.mypy.overrides]]
module = ["google.*"]
ignore_missing_imports = true
20 changes: 20 additions & 0 deletions integrations/adk-agent/src/termchart_adk/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""termchart_adk — a standalone Google-ADK agent that listens to a termchart viewer
console scope and responds by rendering diagrams and posting a short text reply.

Single source of truth for the package version is ``__version__`` below.
"""

from .config import Settings
from .models import InboxEvent, InboxResponse, RenderResult, ReplyResult, SuggestItem

__version__ = "0.1.0"

__all__ = [
"Settings",
"InboxEvent",
"InboxResponse",
"RenderResult",
"ReplyResult",
"SuggestItem",
"__version__",
]
72 changes: 72 additions & 0 deletions integrations/adk-agent/src/termchart_adk/agent.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
"""Google-ADK agent construction + per-scope session + one-turn execution.

Credentials are resolved by the Google SDK from the environment (Vertex when
GOOGLE_GENAI_USE_VERTEXAI=1 + project/location; otherwise GEMINI_API_KEY). This module never
touches credentials — it only passes the configured model id.
"""

from __future__ import annotations

import logging
from typing import Any

from google.adk.agents import LlmAgent
from google.adk.apps import App
from google.adk.runners import InMemoryRunner
from google.genai import errors as genai_errors
from google.genai import types

from .config import Settings
from .prompts import SYSTEM_INSTRUCTION

logger = logging.getLogger("termchart_adk")

# ADK requires the App name to be a valid identifier (letters, digits, underscores — no hyphen).
APP_NAME = "termchart_adk"
USER_ID = "human"


def build_agent(settings: Settings, tools: list[Any]) -> LlmAgent:
"""Construct the LLM agent for the configured model with the termchart tools attached."""
return LlmAgent(
name="termchart_driver",
model=settings.model,
instruction=SYSTEM_INSTRUCTION,
tools=tools,
)


def build_runner(agent: LlmAgent) -> InMemoryRunner:
"""Wrap the agent in an in-memory runner (in-memory session + artifact services)."""
return InMemoryRunner(app=App(name=APP_NAME, root_agent=agent))


async def ensure_session(runner: InMemoryRunner, *, session_id: str) -> None:
"""Create the per-scope session once (idempotent) so the agent keeps memory across messages."""
existing = await runner.session_service.get_session(
app_name=runner.app_name, user_id=USER_ID, session_id=session_id
)
if existing is None:
await runner.session_service.create_session(
app_name=runner.app_name, user_id=USER_ID, session_id=session_id
)


async def run_turn(runner: InMemoryRunner, *, session_id: str, text: str) -> str:
"""Run one turn: feed the human's message in, let the agent call tools, return its final text."""
message = types.Content(role="user", parts=[types.Part(text=text)])
chunks: list[str] = []
try:
async for event in runner.run_async(
user_id=USER_ID, session_id=session_id, new_message=message
):
if event.is_final_response() and event.content and event.content.parts:
for part in event.content.parts:
if part.text:
chunks.append(part.text)
except genai_errors.APIError:
# A model/quota error on a single turn must not kill the long-running listener; log it
# deeply and reply with a graceful note the human sees in the console.
logger.exception("model error during turn")
return "⚠️ I hit a model error and couldn't finish that — please try again."
return "".join(chunks).strip()
Loading
Loading