chore: import upstream snapshot with attribution
@@ -0,0 +1,400 @@
|
||||
# Agent YAML spec
|
||||
|
||||
Omnigent can run an agent from a single YAML file:
|
||||
|
||||
```bash
|
||||
omnigent run path/to/agent.yaml
|
||||
```
|
||||
|
||||
Use this file to choose the harness/model, write the system prompt, and declare
|
||||
which tools, sub-agents, OS access, and policies the agent can use.
|
||||
|
||||
## Minimal agent
|
||||
|
||||
```yaml
|
||||
name: hello_agent
|
||||
prompt: |
|
||||
You are a concise assistant. Answer directly and ask a follow-up question when
|
||||
the request is ambiguous.
|
||||
|
||||
executor:
|
||||
harness: claude-sdk
|
||||
model: databricks-claude-sonnet-4-6
|
||||
auth:
|
||||
type: databricks
|
||||
profile: oss
|
||||
```
|
||||
|
||||
`prompt` may also be replaced by `instructions: AGENTS.md`; relative paths are
|
||||
resolved from the YAML file's directory.
|
||||
|
||||
## Common top-level fields
|
||||
|
||||
| Field | Required? | Purpose |
|
||||
| --- | --- | --- |
|
||||
| `name` | Recommended | Stable identifier shown in sessions and logs. |
|
||||
| `prompt` | Usually | Inline system prompt. |
|
||||
| `instructions` | Optional | Inline instructions or a path to an instructions file. If set, it takes precedence over `prompt`. |
|
||||
| `executor` | Recommended | Harness, model, and auth settings. |
|
||||
| `tools` | Optional | MCP tools, Python function tools, sub-agents, handoffs, or inherited tools. |
|
||||
| `policies` | Optional | Guardrails that inspect requests, responses, tool calls, or tool results. |
|
||||
| `params` | Optional | Typed user parameters available to tools/skills. |
|
||||
| `os_env` | Optional | Enables local OS tools such as file reads, writes, edits, and shell commands. |
|
||||
| `terminals` | Optional | Named interactive terminal environments the agent can launch. |
|
||||
| `async` | Optional | Whether async work tools are exposed. Defaults to `true`. |
|
||||
| `cancellable` | Optional | Whether the session can be cancelled. Defaults to `true`. |
|
||||
| `timers` | Optional | Whether timer tools are exposed. Defaults to `false`. |
|
||||
|
||||
## Executor
|
||||
|
||||
```yaml
|
||||
executor:
|
||||
harness: claude-sdk # claude-sdk, openai-agents, codex, cursor, kiro-native, pi, antigravity, qwen, kimi, copilot, hermes, ...
|
||||
model: databricks-claude-opus-4-7
|
||||
auth:
|
||||
type: databricks
|
||||
profile: oss # Databricks profile for model routing
|
||||
```
|
||||
|
||||
Set the Databricks profile under `executor.auth`. The older top-level
|
||||
`executor.profile` shorthand is legacy and should not be used in new specs.
|
||||
|
||||
The `cursor` harness (Cursor's `cursor-agent`) is the exception: it talks
|
||||
only to Cursor's own backend and has no custom API base-URL, so the Databricks
|
||||
gateway / `auth.type: databricks` does not apply. Authenticate it with
|
||||
`CURSOR_API_KEY` (or a prior `cursor-agent login`), optionally pinned via
|
||||
`auth: {type: api_key, api_key: ${CURSOR_API_KEY}}`, and choose a Cursor model
|
||||
id (e.g. `auto`, `gpt-5`) rather than a `databricks-*` id.
|
||||
|
||||
The `kiro-native` harness is the native Kiro CLI terminal path used by
|
||||
`omnigent kiro`. It requires `kiro-cli` on `PATH` and Kiro's own login/auth; it
|
||||
does not use Databricks, OpenAI, or Anthropic provider credentials. Plain
|
||||
`harness: kiro` is not a generic Omnigent harness id. Kiro's TUI remains the
|
||||
authoritative approval surface; supported one-time tool approvals can also be
|
||||
mirrored into Chat cards, while persistent trust choices remain explicit Kiro
|
||||
TUI/flag actions. See `kiro-native-elicitation.md`.
|
||||
|
||||
### Antigravity (Gemini)
|
||||
|
||||
`harness: antigravity` runs the agent through Google's
|
||||
[Antigravity SDK](https://pypi.org/project/google-antigravity/)
|
||||
(`pip install "omnigent[antigravity]"`). It defaults to **Gemini 3.5 Flash**
|
||||
and can also drive Claude / GPT-OSS. Authenticate with an Antigravity /
|
||||
Gemini API key, or Vertex AI (`project` / `location`) — the SDK is
|
||||
Gemini-native and has no OpenAI-compatible gateway / Databricks path.
|
||||
|
||||
```yaml
|
||||
executor:
|
||||
harness: antigravity # aliases: agy, google-antigravity
|
||||
model: gemini-3.5-flash
|
||||
auth:
|
||||
type: api_key
|
||||
api_key: ${GEMINI_API_KEY} # or ANTIGRAVITY_API_KEY
|
||||
```
|
||||
|
||||
### GitHub Copilot
|
||||
|
||||
`harness: copilot` runs the agent through the
|
||||
[GitHub Copilot SDK](https://pypi.org/project/github-copilot-sdk/)
|
||||
(`pip install "omnigent[copilot]"`). The SDK bundles the Copilot CLI it drives
|
||||
as a backing server, so no separate CLI install is needed. Like cursor and
|
||||
antigravity it talks only to GitHub's Copilot backend — there is no Databricks
|
||||
gateway / `auth.type: databricks` path. Authenticate with a **GitHub token** that
|
||||
carries Copilot access: a fine-grained PAT with the "Copilot Requests"
|
||||
permission, or an OAuth token from the GitHub CLI (`gh auth token`) / Copilot
|
||||
CLI. Resolution: spec `auth.api_key` → a token registered via `omnigent setup`
|
||||
(the `copilot:` config block) → ambient `COPILOT_GITHUB_TOKEN` / `GH_TOKEN` /
|
||||
`GITHUB_TOKEN`. Choose a Copilot model id (e.g. `claude-haiku-4.5`, `gpt-5-mini`,
|
||||
or omit for auto-select) rather than a `databricks-*` id. Classic `ghp_` PATs are
|
||||
not accepted by Copilot.
|
||||
|
||||
```yaml
|
||||
executor:
|
||||
harness: copilot # alias: github-copilot
|
||||
model: claude-haiku-4.5 # a Copilot model id; omit for auto-select
|
||||
auth:
|
||||
type: api_key
|
||||
api_key: ${GH_TOKEN} # a GitHub token with Copilot access
|
||||
```
|
||||
|
||||
To route through OpenRouter / a gateway, declare a key/gateway provider in
|
||||
`~/.omnigent/config.yaml` and reference it (`auth: {type: provider, name: …}`),
|
||||
or set `auth.base_url` to the OpenAI-compatible endpoint alongside the key.
|
||||
For Databricks, use `auth: {type: databricks, profile: …}`.
|
||||
|
||||
### Kimi Code
|
||||
|
||||
`harness: kimi` runs the agent through Moonshot AI's
|
||||
[Kimi Code CLI](https://github.com/MoonshotAI/Kimi-Code) headlessly via
|
||||
`kimi --print --output-format stream-json` per turn. Install the binary
|
||||
with `curl -fsSL https://code.kimi.com/kimi-code/install.sh | bash`
|
||||
and authenticate once with `kimi login` (OAuth or a Moonshot API key).
|
||||
|
||||
```yaml
|
||||
executor:
|
||||
harness: kimi # alias: kimi-code
|
||||
model: kimi-k2-turbo
|
||||
```
|
||||
|
||||
By default Kimi authenticates against Moonshot AI's backend — Omnigent
|
||||
declares no `executor.auth` block. To route through a gateway, either set
|
||||
`HARNESS_KIMI_GATEWAY_BASE_URL` + `HARNESS_KIMI_GATEWAY_API_KEY` in the
|
||||
shell, declare a key/gateway provider in `~/.omnigent/config.yaml`, or use
|
||||
`executor.auth: {type: databricks, profile: …}` and let Omnigent resolve
|
||||
the workspace.
|
||||
|
||||
CLI flags such as `--harness` and `--model` can override or supply missing
|
||||
executor values for a run. Databricks credentials come from the spec's
|
||||
`executor.auth` block or your `omnigent setup` provider config — there is
|
||||
no profile flag.
|
||||
|
||||
## Qwen Code
|
||||
|
||||
`harness: qwen` runs the agent through [Qwen Code](https://github.com/QwenLM/qwen)
|
||||
(`npm install -g @qwen-code/qwen-code`). It drives the `qwen` CLI in ACP mode
|
||||
(`qwen --acp`).
|
||||
|
||||
```yaml
|
||||
executor:
|
||||
harness: qwen # aliases: qwen-code
|
||||
model: qwen/qwen-2.5-coder
|
||||
```
|
||||
|
||||
CLI flags such as `--harness qwen` and `--model <id>` can override or supply
|
||||
missing executor values.
|
||||
|
||||
## Local OS access
|
||||
|
||||
Declare `os_env` only for agents that need local file/shell tools.
|
||||
|
||||
```yaml
|
||||
os_env:
|
||||
type: caller_process
|
||||
cwd: .
|
||||
sandbox:
|
||||
type: linux_bwrap
|
||||
write_paths:
|
||||
- .
|
||||
allow_network: true
|
||||
```
|
||||
|
||||
For trusted local development, examples may use `sandbox.type: none`:
|
||||
|
||||
```yaml
|
||||
os_env:
|
||||
type: caller_process
|
||||
cwd: .
|
||||
sandbox:
|
||||
type: none
|
||||
```
|
||||
|
||||
Prefer the narrowest filesystem and network access that supports the task. Do
|
||||
not pass secrets through the environment unless the tool genuinely needs them.
|
||||
|
||||
You usually don't need to choose a `sandbox.type` — omit it and Omnigent picks
|
||||
the platform default (`linux_bwrap` on Linux, `darwin_seatbelt` on macOS), so the
|
||||
same YAML works across platforms. For the full set of sandbox options, how to
|
||||
share one policy across `sys_os_*` and terminals, and how to set up network
|
||||
egress rules, see the `sandbox:` examples below and the sandbox source under `omnigent/inner/`.
|
||||
|
||||
## Tools
|
||||
|
||||
Tools are declared under `tools` by name.
|
||||
|
||||
### MCP server
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
github:
|
||||
type: mcp
|
||||
command: uv
|
||||
args:
|
||||
- run
|
||||
- python
|
||||
- -m
|
||||
- my_package.github_mcp
|
||||
tools:
|
||||
- search_issues
|
||||
- get_pull_request
|
||||
```
|
||||
|
||||
MCP tools can also point at a remote URL:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
docs:
|
||||
type: mcp
|
||||
url: https://example.com/mcp
|
||||
headers:
|
||||
Authorization: Bearer ${TOKEN}
|
||||
```
|
||||
|
||||
### Python function tool
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
summarize_file:
|
||||
type: function
|
||||
description: Summarize a local text file.
|
||||
callable: my_package.tools.summarize_file
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
path:
|
||||
type: string
|
||||
required: [path]
|
||||
```
|
||||
|
||||
For client-provided tools, use `runtime: client` and do not set `callable`.
|
||||
|
||||
### Tool sandbox containers
|
||||
|
||||
Local Python tools can run inside a container image by declaring a sandbox image.
|
||||
Use `container_image` for new specs; `docker_image` remains accepted as a
|
||||
deprecated alias for backwards compatibility. Set `container_runtime: podman` to
|
||||
run the image with Podman instead of Docker.
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
sandbox:
|
||||
container_image: python:3.12-slim
|
||||
container_runtime: podman # optional; defaults to docker
|
||||
```
|
||||
|
||||
### Sub-agent tool
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
reviewer:
|
||||
type: agent
|
||||
description: Review proposed code changes.
|
||||
prompt: |
|
||||
You are a careful code reviewer. Focus on correctness, tests, security,
|
||||
and maintainability.
|
||||
executor:
|
||||
harness: claude-sdk
|
||||
model: databricks-claude-sonnet-4-6
|
||||
os_env: inherit
|
||||
pass_history: true
|
||||
max_sessions: 2
|
||||
```
|
||||
|
||||
Each sub-agent picks its own `executor.harness` and `model`, so an orchestrator
|
||||
can mix harnesses by role — e.g. a `cursor` coder with a `claude-sdk`
|
||||
reviewer:
|
||||
|
||||
```yaml
|
||||
tools:
|
||||
coder:
|
||||
type: agent
|
||||
executor:
|
||||
harness: cursor # Cursor model id (e.g. gpt-5, auto), not a databricks-* id
|
||||
model: gpt-5
|
||||
```
|
||||
|
||||
Use `tools.<name>: inherit` to inherit a tool from a parent agent, or
|
||||
`tools.<name>: self` / `spec: self` for a sub-agent that clones the parent spec.
|
||||
|
||||
## Policies
|
||||
|
||||
Policies can inspect requests, responses, tool calls, and tool results.
|
||||
|
||||
```yaml
|
||||
policies:
|
||||
pii_guard:
|
||||
type: function
|
||||
handler: my_package.policies.pii_guard
|
||||
on: [request, response]
|
||||
```
|
||||
|
||||
A factory can be configured with `factory_params`:
|
||||
|
||||
```yaml
|
||||
policies:
|
||||
workspace_policy:
|
||||
type: function
|
||||
handler: my_package.policies.make_workspace_policy
|
||||
factory_params:
|
||||
allowed_hosts:
|
||||
- example.cloud.databricks.com
|
||||
```
|
||||
|
||||
## Terminals
|
||||
|
||||
Terminals are named interactive shell environments that the agent can launch.
|
||||
|
||||
```yaml
|
||||
terminals:
|
||||
bash:
|
||||
command: bash
|
||||
args: [-l]
|
||||
os_env: inherit
|
||||
allow_cwd_override: true
|
||||
allow_sandbox_override: false
|
||||
scrollback: 10000
|
||||
```
|
||||
|
||||
Use `os_env: inherit` to give the terminal the same sandbox as the agent, or
|
||||
alias a shared `sandbox:` block so `sys_os_*` and the terminal enforce the same
|
||||
policy. Keep `allow_sandbox_override: false` unless you intend to let the
|
||||
launcher weaken the sandbox at launch time.
|
||||
|
||||
## Complete example
|
||||
|
||||
```yaml
|
||||
name: coding_agent
|
||||
prompt: |
|
||||
You are a coding agent. Inspect files before editing, run targeted tests, and
|
||||
summarize changes with validation results.
|
||||
|
||||
executor:
|
||||
harness: claude-sdk
|
||||
model: databricks-claude-sonnet-4-6
|
||||
auth:
|
||||
type: databricks
|
||||
profile: oss
|
||||
|
||||
async: true
|
||||
cancellable: true
|
||||
|
||||
os_env:
|
||||
type: caller_process
|
||||
cwd: .
|
||||
sandbox:
|
||||
type: linux_bwrap
|
||||
write_paths: [.]
|
||||
allow_network: true
|
||||
|
||||
terminals:
|
||||
zsh:
|
||||
command: zsh
|
||||
args: [-l]
|
||||
os_env: inherit
|
||||
allow_cwd_override: true
|
||||
|
||||
tools:
|
||||
repo_search:
|
||||
type: function
|
||||
description: Search repository files for a pattern.
|
||||
callable: my_package.tools.repo_search
|
||||
parameters:
|
||||
type: object
|
||||
properties:
|
||||
query:
|
||||
type: string
|
||||
required: [query]
|
||||
```
|
||||
|
||||
## Validation tips
|
||||
|
||||
- Keep examples free of secrets, workspace URLs, customer data, and private
|
||||
Databricks-only configuration unless the example is explicitly internal.
|
||||
- Prefer `instructions: AGENTS.md` for long prompts that are shared with other
|
||||
tooling.
|
||||
- Start from a bundled example such as `examples/polly/config.yaml` or
|
||||
`examples/debby/config.yaml` and remove tools you do not need.
|
||||
- Run the YAML before publishing it:
|
||||
|
||||
```bash
|
||||
omnigent run path/to/agent.yaml -p "Say hello"
|
||||
```
|
||||
@@ -0,0 +1,193 @@
|
||||
# Omnigent bot identities & attribution setup
|
||||
|
||||
This doc explains how commits and automated PR reviews in the
|
||||
`omnigent-ai/omnigent` repo are attributed, and how the supporting GitHub App
|
||||
was set up. There are **two deliberately distinct identities** — do not
|
||||
conflate them:
|
||||
|
||||
| Identity | Used for | Why this identity |
|
||||
| --- | --- | --- |
|
||||
| `omnigent <noreply@omnigent.ai>` | Co-author trailer on commits authored by **polly's coding sub-agents** | These commits are produced by `git commit` in a worker's local worktree — they are **not** GitHub Actions runs, so a plain org co-author is the honest attribution. No GitHub App user is involved. |
|
||||
| `omnigent-ci[bot]` (GitHub App) | **CI automation**: lockfile-regen commits/PRs **and** automated PR-review comments | These actions genuinely run inside GitHub Actions, where the App's private key lives and a short-lived installation token is minted per run. The App is an org-owned, least-privilege identity. |
|
||||
|
||||
> **Why two identities and not one?** An earlier draft of this work tried to use
|
||||
> `omnigent-ci[bot]` everywhere, including the sub-agent commit trailer. That was
|
||||
> corrected: polly's workers don't run in Actions and never touch the App key, so
|
||||
> attributing their commits to the Actions-minted bot user was misleading. Local
|
||||
> work → plain org co-author; Actions-minted work → the App bot.
|
||||
|
||||
---
|
||||
|
||||
## The GitHub App: `omnigent-ci[bot]`
|
||||
|
||||
> **Naming note.** The App was registered as **`omnigent-ci`** (the bare
|
||||
> `omnigent` name was unavailable), so GitHub renders the actor as
|
||||
> **`omnigent-ci[bot]`**.
|
||||
|
||||
| Field | Value |
|
||||
| --- | --- |
|
||||
| App name | `omnigent-ci` |
|
||||
| Bot actor | `omnigent-ci[bot]` |
|
||||
| App ID | `4082516` |
|
||||
| Bot numeric user ID | `294685417` |
|
||||
| CI git author email | `294685417+omnigent-ci[bot]@users.noreply.github.com` |
|
||||
|
||||
The numeric user ID (`294685417`) is what links GitHub's no-reply commit email
|
||||
back to the bot's profile; it is distinct from the App ID (`4082516`), which is
|
||||
used only to mint installation tokens.
|
||||
|
||||
> **Why a GitHub App (not a PAT or a plain machine user)?** An App is an
|
||||
> org-owned identity with scoped, least-privilege permissions and a short-lived
|
||||
> installation token minted per run — no long-lived personal credential to leak.
|
||||
|
||||
---
|
||||
|
||||
## Org-admin setup (one-time, completed)
|
||||
|
||||
These steps required org-admin and are **done**. They are recorded here because
|
||||
they are not captured anywhere in the repo and would otherwise have to be
|
||||
reverse-engineered.
|
||||
|
||||
### 1. Create the App
|
||||
|
||||
Created at `https://github.com/organizations/omnigent-ai/settings/apps/new`:
|
||||
|
||||
- **GitHub App name:** `omnigent-ci` → actor `omnigent-ci[bot]`.
|
||||
- **Homepage URL:** any valid URL.
|
||||
- **Webhook:** **Active** unchecked — token-minting only, no webhook.
|
||||
- **Repository permissions** (least privilege):
|
||||
- **Contents:** Read and write — push branches / commits.
|
||||
- **Pull requests:** Read and write — open/update PRs **and post reviews**.
|
||||
- **Metadata:** Read-only (mandatory).
|
||||
- Everything else **No access**.
|
||||
- **Where can this App be installed?** Only on `omnigent-ai`.
|
||||
- Installed into `omnigent-ai`, scoped to the `omnigent` repo.
|
||||
|
||||
**App ID `4082516`.** A private key (`.pem`) was generated and stored as a
|
||||
secret (step 3).
|
||||
|
||||
> Optional/cosmetic: App settings → **Display information** → upload a square
|
||||
> logo. Purely visual — does not affect the user ID, attribution, or wiring.
|
||||
|
||||
### 2. Resolve the bot's numeric user ID
|
||||
|
||||
GitHub's no-reply commit email embeds a numeric user ID assigned after install:
|
||||
|
||||
```bash
|
||||
gh api users/omnigent-ci%5Bbot%5D --jq '.id'
|
||||
# -> 294685417
|
||||
```
|
||||
|
||||
(`%5Bbot%5D` is the URL-encoding of `[bot]`.)
|
||||
|
||||
### 3. Store the App credentials
|
||||
|
||||
In **`omnigent-ai/omnigent` → Settings → Secrets and variables → Actions**:
|
||||
|
||||
- **Variable** `OMNIGENT_BOT_APP_ID` = `4082516`
|
||||
- **Secret** `OMNIGENT_BOT_APP_KEY` = the App's `.pem` private key
|
||||
|
||||
> The workflows gate on `vars.OMNIGENT_BOT_APP_ID != ''`. If the variable is
|
||||
> absent or misnamed, the token-mint step is skipped and the workflow falls back
|
||||
> to `GITHUB_TOKEN` (attributing the action to `github-actions[bot]`) — so the
|
||||
> exact names matter.
|
||||
|
||||
> **`omnigent-ci[bot]` replaced the old OSS regen bot.** The previous
|
||||
> `OSS_REGEN_APP_ID` / `OSS_REGEN_APP_KEY` config and its App have been
|
||||
> **retired**; nothing in the repo references `OSS_REGEN_*` anymore.
|
||||
|
||||
---
|
||||
|
||||
## In-repo wiring (shipped)
|
||||
|
||||
### Sub-agent commit co-author trailer
|
||||
|
||||
polly never commits directly; its coding sub-agents (`claude_code`, `codex`,
|
||||
`pi`) run `git commit` / `gh pr create` in their own worktrees. Each such commit
|
||||
ends with a co-author trailer attributing it to the org:
|
||||
|
||||
```
|
||||
Co-authored-by: omnigent <noreply@omnigent.ai>
|
||||
```
|
||||
|
||||
This requirement lives in the worker IMPLEMENT instructions:
|
||||
|
||||
- `examples/polly/agents/claude_code/config.yaml`
|
||||
- `examples/polly/agents/codex/config.yaml`
|
||||
- `examples/polly/agents/pi/config.yaml`
|
||||
|
||||
> A `Co-authored-by` trailer is GitHub's lightweight attribution mechanism — it
|
||||
> attributes the commit to the org in addition to the worker author and needs no
|
||||
> signing key. It is **not** cryptographic signing (GPG/sigstore), which is a
|
||||
> separate, heavier concern.
|
||||
|
||||
> The packaged copies under `omnigent/resources/examples/polly/...` are a
|
||||
> **symlink** to the `examples/polly/...` source, so there is a single source of
|
||||
> truth — no dual copies to keep in sync.
|
||||
|
||||
### CI commits/PRs as `omnigent-ci[bot]`
|
||||
|
||||
The lockfile-regen workflows (`.github/workflows/oss-regenerate-and-smoke.yml`
|
||||
and `oss-regen-on-comment.yml`) mint the App token and set the git identity so
|
||||
regen commits/PRs are authored by the bot:
|
||||
|
||||
```yaml
|
||||
- name: Mint App token
|
||||
id: app-token
|
||||
if: vars.OMNIGENT_BOT_APP_ID != ''
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||||
```
|
||||
|
||||
```bash
|
||||
git config user.name "omnigent-ci[bot]"
|
||||
git config user.email "294685417+omnigent-ci[bot]@users.noreply.github.com"
|
||||
```
|
||||
|
||||
The push uses `steps.app-token.outputs.token || secrets.GITHUB_TOKEN`, so a
|
||||
missing App config falls back to `github-actions[bot]` rather than failing.
|
||||
|
||||
### Automated PR review posted as `omnigent-ci[bot]`
|
||||
|
||||
`.github/workflows/polly-review.yml` runs a full cross-vendor Polly review of a
|
||||
PR diff (on PR open/reopen/ready, a `/review` comment from a write-access user,
|
||||
or `workflow_dispatch`) and posts the findings as a PR comment. It mints the App
|
||||
token and posts the review **as `omnigent-ci[bot]`**:
|
||||
|
||||
```yaml
|
||||
- name: Mint App token
|
||||
id: app-token
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true' && vars.OMNIGENT_BOT_APP_ID != ''
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0
|
||||
with:
|
||||
app-id: ${{ vars.OMNIGENT_BOT_APP_ID }}
|
||||
private-key: ${{ secrets.OMNIGENT_BOT_APP_KEY }}
|
||||
|
||||
- name: Post review comment
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || github.token }}
|
||||
# ...
|
||||
```
|
||||
|
||||
> **Fail-open by design.** If the App vars are absent, the post falls back to
|
||||
> `github.token` and the review still posts (as `github-actions[bot]`). The
|
||||
> review *content* is valuable regardless of who signs it — this is deliberately
|
||||
> the opposite of a fail-closed posting gate. The workflow always checks out the
|
||||
> **trusted default branch** and fetches the PR diff via the API; PR-authored
|
||||
> code is never executed, and the minted token is scoped to the post step only.
|
||||
|
||||
---
|
||||
|
||||
## Quick reference
|
||||
|
||||
| Surface | Identity on the artifact | Where it's wired |
|
||||
| --- | --- | --- |
|
||||
| polly sub-agent commits | `omnigent <noreply@omnigent.ai>` (co-author trailer) | `examples/polly/agents/*/config.yaml` |
|
||||
| Lockfile-regen commits/PRs | `omnigent-ci[bot]` | `oss-regenerate-and-smoke.yml`, `oss-regen-on-comment.yml` |
|
||||
| Automated PR review comments | `omnigent-ci[bot]` (fallback `github-actions[bot]`) | `polly-review.yml` |
|
||||
|
||||
**Config:** variable `OMNIGENT_BOT_APP_ID` = `4082516`, secret
|
||||
`OMNIGENT_BOT_APP_KEY` = App private key. The old `OSS_REGEN_APP_*` config and
|
||||
App are retired.
|
||||
@@ -0,0 +1,529 @@
|
||||
# Policies
|
||||
|
||||
Policies are declarative gates that enforce rules on agent behavior. They evaluate agent actions at specific enforcement points and return one of three verdicts:
|
||||
|
||||
- **ALLOW** -- the action proceeds.
|
||||
- **DENY** -- the action is blocked; the agent receives an error.
|
||||
- **ASK** -- the action is paused for user approval; approved becomes ALLOW, refused becomes DENY.
|
||||
|
||||
Policies compose: multiple policies can be active at once. The engine evaluates them in declaration order. A DENY from any policy short-circuits the rest.
|
||||
|
||||
## Who configures policies
|
||||
|
||||
Policies are set at three levels. Each level serves a different persona:
|
||||
|
||||
| Level | Who | How | Evaluated |
|
||||
|-------|-----|-----|-----------|
|
||||
| **Server-wide** | Admin | `policies` in server config YAML, or REST API | Last |
|
||||
| **Agent spec** | Agent developer | `policies` in agent YAML | Middle |
|
||||
| **Session** | End user | Session settings panel in the UI | First |
|
||||
|
||||
Session policies evaluate first and can short-circuit (DENY) before spec or admin policies run.
|
||||
|
||||

|
||||
|
||||
---
|
||||
|
||||
## For server admins
|
||||
|
||||
### Setting up server-wide policies
|
||||
|
||||
Server-wide (default) policies apply to every session. They act as organizational guardrails.
|
||||
|
||||
**1. Choose policies.** Browse the builtin policy registry (see [Builtin policies](#builtin-policies) below) or install community/custom policy modules.
|
||||
|
||||
**2. Register custom policy modules** (optional). If you use policies outside the builtins, add their module paths to the server config so they appear in the registry:
|
||||
|
||||
```yaml
|
||||
# server_config.yaml
|
||||
policy_modules:
|
||||
- myorg_policies
|
||||
- github_mcp_policy
|
||||
```
|
||||
|
||||
**3. Add policies to the server config.**
|
||||
|
||||
```yaml
|
||||
# server_config.yaml
|
||||
policies:
|
||||
session_budget:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.cost.cost_budget
|
||||
factory_params:
|
||||
max_cost_usd: 10.00
|
||||
ask_thresholds_usd: [5.00]
|
||||
global_rate_limit:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.safety.max_tool_calls_per_session
|
||||
factory_params:
|
||||
limit: 200
|
||||
```
|
||||
|
||||
**4. Start the server.**
|
||||
|
||||
```bash
|
||||
omnigent server --config server_config.yaml
|
||||
```
|
||||
|
||||
After starting, you can also add or remove policies at runtime through the REST API (see [Admin policy REST API](#admin-policy-rest-api)).
|
||||
|
||||
---
|
||||
|
||||
## For agent developers
|
||||
|
||||
### Adding policies to an agent spec
|
||||
|
||||
Policies are declared under `policies` at the top level of the agent YAML. They are evaluated in declaration order.
|
||||
|
||||
```yaml
|
||||
name: github_agent
|
||||
prompt: You are a coding assistant with access to GitHub.
|
||||
|
||||
executor:
|
||||
harness: claude-sdk
|
||||
model: databricks-claude-sonnet-4-6
|
||||
|
||||
tools:
|
||||
github:
|
||||
type: mcp
|
||||
url: https://api.githubcopilot.com/mcp/
|
||||
|
||||
policies:
|
||||
limit_tool_calls:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.safety.max_tool_calls_per_session
|
||||
factory_params:
|
||||
limit: 100
|
||||
github_access:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.github.github_policy
|
||||
factory_params:
|
||||
write_repos:
|
||||
- myorg/my-repo
|
||||
write_branches:
|
||||
- "feature/*"
|
||||
google_policy:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.google.gdrive_policy
|
||||
factory_params:
|
||||
read_all: true
|
||||
allow_create: true
|
||||
```
|
||||
|
||||
### Policy declaration syntax
|
||||
|
||||
Each policy entry has:
|
||||
|
||||
| Field | Required | Description |
|
||||
|-------|----------|-------------|
|
||||
| `type` | yes | `"function"` |
|
||||
| `handler` | yes | Dotted Python import path to the callable or factory |
|
||||
| `factory_params` | no | Key-value arguments passed to a factory at build time |
|
||||
|
||||
**Direct callable** (no parameters):
|
||||
|
||||
```yaml
|
||||
approve_file_ops:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.safety.ask_on_os_tools
|
||||
```
|
||||
|
||||
**Factory** (with parameters -- called once at build time to produce the evaluator):
|
||||
|
||||
```yaml
|
||||
rate_limit:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.safety.max_tool_calls_per_session
|
||||
factory_params:
|
||||
limit: 50
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## For session users
|
||||
|
||||
### Adding policies to a running session
|
||||
|
||||
Session-level policies let you customize agent behavior for your current task. There are two ways to add them:
|
||||
|
||||
1. **UI** -- Open the information window to browse available policies and toggle them on or off.
|
||||
2. **Chat** -- Tell the agent directly, e.g. *"add a policy that asks me before running shell commands"*. The agent has a built-in `sys_add_policy` tool and will configure the policy for you.
|
||||
|
||||
Session policies evaluate before agent spec and admin policies, so they can enforce stricter rules or add additional gates for your specific workflow.
|
||||
|
||||
---
|
||||
|
||||
## Builtin policies
|
||||
|
||||
### Safety
|
||||
|
||||
#### `max_tool_calls_per_session`
|
||||
|
||||
Limits the total number of tool calls in a session. DENYs after the limit is reached.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `limit` | integer | `100` | Maximum tool calls allowed |
|
||||
|
||||
```yaml
|
||||
rate_limit:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.safety.max_tool_calls_per_session
|
||||
factory_params:
|
||||
limit: 50
|
||||
```
|
||||
|
||||
#### `ask_on_os_tools`
|
||||
|
||||
Requires user approval before any `sys_os_read`, `sys_os_write`, `sys_os_edit`, or `sys_os_shell` tool call. No parameters (direct callable).
|
||||
|
||||
```yaml
|
||||
approve_file_ops:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.safety.ask_on_os_tools
|
||||
```
|
||||
|
||||
#### `block_skills`
|
||||
|
||||
Prevents the agent from loading specific skills.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `blocked` | string[] | (required) | Skill names to block (case-insensitive) |
|
||||
|
||||
```yaml
|
||||
no_deploy_skill:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.safety.block_skills
|
||||
factory_params:
|
||||
blocked: [deploy, rollback]
|
||||
```
|
||||
|
||||
#### `enforce_sandbox`
|
||||
|
||||
Forces a specific sandbox configuration on agent start.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `sandbox_type` | string | `"linux_bwrap"` | Sandbox backend (`linux_bwrap`, `darwin_seatbelt`, `none`) |
|
||||
| `allow_network` | boolean | `true` | Allow network access |
|
||||
| `write_paths` | string[] | `null` | Writable paths (null inherits agent config) |
|
||||
| `read_paths` | string[] | `null` | Read-only paths (null inherits agent config) |
|
||||
| `env_passthrough` | string[] | `null` | Env vars allowed through sandbox |
|
||||
|
||||
#### `deny_pii_in_llm_request`
|
||||
|
||||
Scans user messages and LLM prompts for PII patterns (SSN, credit card, email, phone).
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `pii_types` | string[] | `["ssn", "credit_card", "email", "phone"]` | PII categories to scan |
|
||||
| `action` | string | `"DENY"` | Action when PII detected (`DENY` or `ASK`) |
|
||||
|
||||
### Cost
|
||||
|
||||
#### `cost_budget`
|
||||
|
||||
Gates a session on cumulative LLM spend, at the **request** phase (before the LLM turn, so text-only turns are budgeted too) and the **tool-call** phase. ASKs the first time spend crosses each soft warning threshold. At the hard limit it acts as a **downgrade gate**, not a hard stop: it DENYs (the whole turn on `request`, or each tool call on `tool_call`) only while the session is on an expensive model -- telling the user to switch to a cheaper one with `/model` -- and allows them again once the session has switched.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `max_cost_usd` | number | (required) | Hard spend limit in USD. Once reached, the turn / tool calls are blocked while the session is on an expensive model. |
|
||||
| `ask_thresholds_usd` | number[] | `null` | Soft warning checkpoints that ASK the first time spend crosses each (each must be < `max_cost_usd`) |
|
||||
| `expensive_models` | string[] | Fable + Opus + GPT-5 (excl. `-mini`/`-nano`) | Case-insensitive substring tokens for the model tiers blocked once over budget (e.g. `"opus"` matches any Opus deployment). The default's broad `gpt-5` token matches the whole GPT-5 family except the cheap `-mini`/`-nano` variants; an explicit list is matched literally with no exclusions. `[]` disables the hard limit, leaving only the soft thresholds. |
|
||||
|
||||
```yaml
|
||||
budget:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.cost.cost_budget
|
||||
factory_params:
|
||||
max_cost_usd: 5.00
|
||||
ask_thresholds_usd: [1.00, 3.00]
|
||||
expensive_models: ["opus", "gpt-5"]
|
||||
```
|
||||
|
||||
#### `user_daily_cost_budget`
|
||||
|
||||
Same ASK / downgrade-gate behavior as `cost_budget`, but the budget is the **session owner's cumulative spend across all their sessions for the current UTC day**. The soft-threshold approval is remembered per user+day, so an approved checkpoint won't re-prompt that user again today -- even from a different session. Useful as a server-wide per-user daily cap.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `max_cost_usd` | number | (required) | Hard daily limit in USD. Once the owner's spend for the day reaches it, the turn / tool calls are blocked while on an expensive model. |
|
||||
| `ask_thresholds_usd` | number[] | `null` | Soft daily warning checkpoints that ASK the first time the owner's daily spend crosses each (each must be < `max_cost_usd`) |
|
||||
| `expensive_models` | string[] | Fable + Opus + GPT-5 (excl. `-mini`/`-nano`) | Case-insensitive substring tokens for the model tiers blocked once over the daily budget. An explicit list is matched literally with no exclusions. `[]` disables the hard limit, leaving only the soft thresholds. |
|
||||
|
||||
```yaml
|
||||
# server_config.yaml -- a per-user daily cap applied to every session
|
||||
daily_budget:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.cost.user_daily_cost_budget
|
||||
factory_params:
|
||||
max_cost_usd: 25.00
|
||||
ask_thresholds_usd: [10.00, 20.00]
|
||||
```
|
||||
|
||||
### GitHub
|
||||
|
||||
#### `github_policy`
|
||||
|
||||
Controls GitHub access across MCP tools and `git`/`gh` shell commands. Restricts reads to an allowlist (unless `read_all`) and writes to specific repos/branches. Shell commands with ambiguous targets return ASK.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `read_all` | boolean | `true` | Allow all reads |
|
||||
| `read_repos` | string[] | `[]` | Repos readable when `read_all` is false (`owner/repo` or URLs) |
|
||||
| `write_repos` | string[] | `[]` | Repos the agent may write to |
|
||||
| `write_branches` | string[] | `[]` | Branches writable within allowed repos (empty = any) |
|
||||
| `mcp_tool_prefixes` | string[] | `["mcp__github__", "github__"]` | Tool-name prefixes to match |
|
||||
| `shell_tools` | string[] | `["sys_os_shell"]` | Shell tools whose commands are parsed for git/gh |
|
||||
|
||||
```yaml
|
||||
github_access:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.github.github_policy
|
||||
factory_params:
|
||||
write_repos:
|
||||
- myorg/frontend
|
||||
- myorg/backend
|
||||
write_branches:
|
||||
- "feature/*"
|
||||
- "fix/*"
|
||||
```
|
||||
|
||||
### Google Workspace
|
||||
|
||||
#### `gdrive_policy`
|
||||
|
||||
Controls Google Drive / Docs / Sheets / Slides access. Writes are restricted to files the agent created in the current session plus explicitly allowed files.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `read_all` | boolean | `true` | Allow all reads |
|
||||
| `read_files` | string[] | `[]` | File IDs or URLs readable when `read_all` is false |
|
||||
| `allow_create` | boolean | `false` | Allow creating new files |
|
||||
| `write_files` | string[] | `[]` | File IDs or URLs always writable |
|
||||
| `comment_files` | string[] | `[]` | File IDs or URLs the agent may comment on |
|
||||
| `tool_prefixes` | string[] | `["mcp__google__", "google__"]` | Tool-name prefixes to match |
|
||||
|
||||
#### `gmail_policy`
|
||||
|
||||
Controls Gmail access. Defaults to read + draft but no send.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `allow_read` | boolean | `true` | Allow reading mail |
|
||||
| `allow_send` | boolean | `false` | Allow sending mail |
|
||||
| `allow_drafts` | boolean | `true` | Allow creating/editing own drafts |
|
||||
| `allow_modify` | boolean | `false` | Allow modifying messages (labels, trash) |
|
||||
| `tool_prefixes` | string[] | `["mcp__google__", "google__"]` | Tool-name prefixes |
|
||||
|
||||
#### `gcalendar_policy`
|
||||
|
||||
Controls Google Calendar access. Defaults to read-only.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `allow_read` | boolean | `true` | Allow reading calendars/events |
|
||||
| `allow_create_events` | boolean | `false` | Allow creating events |
|
||||
| `allow_modify_events` | boolean | `false` | Allow updating/deleting events |
|
||||
| `tool_prefixes` | string[] | `["mcp__google__", "google__"]` | Tool-name prefixes |
|
||||
|
||||
### Working directory
|
||||
|
||||
#### `block_working_dir_changes`
|
||||
|
||||
Blocks shell commands that change the working directory (`cd`, `pushd`, `git -C`) or manage git worktrees. Parses chained and wrapped commands to prevent trivial bypasses.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `block_cd` | boolean | `true` | Block cd/chdir/pushd/popd and git -C |
|
||||
| `block_worktree` | boolean | `true` | Block git worktree add/move/remove |
|
||||
| `allowed_dirs` | string[] | `[]` | Directories cd may move into (including subdirectories) |
|
||||
| `action` | string | `"deny"` | Verdict for gated commands (`deny` or `ask`) |
|
||||
| `shell_tools` | string[] | `["sys_os_shell"]` | Shell tools to parse |
|
||||
|
||||
### Risk score
|
||||
|
||||
#### `risk_score_policy`
|
||||
|
||||
Accrues a per-session risk score from tool calls and sensitive data labels. Escalates guarded tools to ASK or DENY once the score exceeds a threshold.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `threshold` | integer | `50` | Score at which guarded tools escalate |
|
||||
| `tool_points` | object | `{}` | Tool name to points mapping, e.g. `{"web_search": 10}` |
|
||||
| `sensitive_labels` | object | `{}` | Data-classification label to points, e.g. `{"Highly Confidential": 30}` |
|
||||
| `guarded_tools` | string[] | `[]` | Tools gated once score reaches threshold |
|
||||
| `escalate_action` | string | `"ASK"` | Verdict for guarded tools over threshold |
|
||||
| `initial_scores_by_actor` | object | `{}` | Actor email to starting score offset |
|
||||
| `state_key` | string | `"risk_score"` | Session state key for the running score |
|
||||
|
||||
### Routing
|
||||
|
||||
#### `deny_trivial_to_expensive_model`
|
||||
|
||||
Classifies user messages as TRIVIAL or COMPLEX using the server LLM. Denies trivial tasks from using expensive models. Requires the server to have an `llm:` config block.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `expensive_models` | string[] | (required) | Model IDs to gate, e.g. `["databricks-claude-opus-4-6"]` |
|
||||
| `classification_prompt` | string | (builtin default) | System instructions for the classifier |
|
||||
|
||||
```yaml
|
||||
# server_config.yaml
|
||||
llm:
|
||||
model: databricks-gpt-5-4-mini
|
||||
|
||||
policies:
|
||||
deny_trivial_opus:
|
||||
type: function
|
||||
handler: omnigent.policies.builtins.routing.deny_trivial_to_expensive_model
|
||||
factory_params:
|
||||
expensive_models:
|
||||
- databricks-claude-opus-4-6
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Writing custom policies
|
||||
|
||||
### Policy function interface
|
||||
|
||||
A policy function receives an event dict and returns a response dict (or `None` to abstain).
|
||||
|
||||
```python
|
||||
from omnigent.policies.schema import PolicyEvent, PolicyResponse
|
||||
|
||||
def my_policy(event: PolicyEvent) -> PolicyResponse | None:
|
||||
if event["type"] != "tool_call":
|
||||
return None # abstain on non-tool phases
|
||||
tool = event["data"]["name"]
|
||||
if tool == "dangerous_tool":
|
||||
return {"result": "DENY", "reason": "This tool is blocked."}
|
||||
return {"result": "ALLOW"}
|
||||
```
|
||||
|
||||
### Factory form
|
||||
|
||||
For policies that need configuration, write a factory -- a function that accepts parameters and returns the actual evaluator:
|
||||
|
||||
```python
|
||||
def block_domains(blocked_domains: list[str]) -> callable:
|
||||
blocked = frozenset(d.lower() for d in blocked_domains)
|
||||
|
||||
def evaluate(event: PolicyEvent) -> PolicyResponse | None:
|
||||
if event["type"] != "tool_call" or event["target"] != "web_fetch":
|
||||
return None
|
||||
url = event["data"]["arguments"].get("url", "")
|
||||
for domain in blocked:
|
||||
if domain in url.lower():
|
||||
return {"result": "DENY", "reason": f"Domain {domain} is blocked."}
|
||||
return {"result": "ALLOW"}
|
||||
|
||||
return evaluate
|
||||
```
|
||||
|
||||
### Event and response examples
|
||||
|
||||
**Event dict** passed to the policy callable (example for a `tool_call` phase):
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "tool_call",
|
||||
"target": "sys_os_shell",
|
||||
"data": {
|
||||
"name": "sys_os_shell",
|
||||
"arguments": {"command": "rm -rf /tmp/data"}
|
||||
},
|
||||
"context": {
|
||||
"actor": {"run_as": "alice@example.com", "client_id": "oauth_abc"},
|
||||
"usage": {
|
||||
"input_tokens": 1520,
|
||||
"output_tokens": 340,
|
||||
"total_tokens": 1860,
|
||||
"total_cost_usd": 0.012
|
||||
}
|
||||
},
|
||||
"session_state": {"call_count": 5},
|
||||
"request_data": null
|
||||
}
|
||||
```
|
||||
|
||||
**Response dict** returned by the policy callable:
|
||||
|
||||
```json
|
||||
{
|
||||
"result": "DENY",
|
||||
"reason": "Destructive shell command blocked.",
|
||||
"state_updates": [
|
||||
{"key": "call_count", "action": "increment", "value": 1}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`result` is the only required field. Valid values: `"ALLOW"`, `"DENY"`, `"ASK"`. Return `None` to abstain.
|
||||
|
||||
`state_updates` supports four actions: `"set"`, `"increment"`, `"delete"`, `"append"`.
|
||||
|
||||
### Making policies discoverable
|
||||
|
||||
To make custom policies appear in the UI, export a `POLICY_REGISTRY` list from your module:
|
||||
|
||||
```python
|
||||
# myorg/policies.py
|
||||
|
||||
POLICY_REGISTRY = [
|
||||
{
|
||||
"handler": "myorg.policies.block_domains",
|
||||
"kind": "factory",
|
||||
"name": "Block Domains",
|
||||
"description": "Block web access to specific domains.",
|
||||
"params_schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"blocked_domains": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": "Domains to block"
|
||||
}
|
||||
},
|
||||
"required": ["blocked_domains"]
|
||||
}
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Then register the module in the server config:
|
||||
|
||||
```yaml
|
||||
policy_modules:
|
||||
- myorg.policies
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Appendix: Admin policy REST API
|
||||
|
||||
After starting the server, admins can manage default policies at runtime through these endpoints:
|
||||
|
||||
```bash
|
||||
# Create a server-wide policy
|
||||
curl -X POST http://localhost:6767/v1/policies \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "global_rate_limit",
|
||||
"type": "python",
|
||||
"handler": "omnigent.policies.builtins.safety.max_tool_calls_per_session",
|
||||
"factory_params": {"limit": 200}
|
||||
}'
|
||||
```
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/v1/policies` | Create a default policy |
|
||||
| `GET` | `/v1/policies` | List all default policies |
|
||||
| `GET` | `/v1/policies/{policy_id}` | Get a specific policy |
|
||||
| `PATCH` | `/v1/policies/{policy_id}` | Update (name, handler, enabled) |
|
||||
| `DELETE` | `/v1/policies/{policy_id}` | Remove a policy |
|
||||
|
||||
`GET /v1/policy-registry` lists all discoverable policies with parameter schemas -- useful for building admin UIs.
|
||||
@@ -0,0 +1,164 @@
|
||||
# Queue + steer design
|
||||
|
||||
Client-side message queue with edit / delete / steer / reorder, for both SDK and
|
||||
native harnesses.
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
Today every message is **POSTed the moment the user hits send** — including
|
||||
follow-ups typed while the agent is still working — and rendered immediately as an
|
||||
optimistic bubble. The runner buffers a mid-turn message behind the active turn
|
||||
and delivers it later, but the UI has already committed it. Problems:
|
||||
|
||||
- **No edit / delete / reorder.** Once POSTed the message is server-owned, so the
|
||||
user can't take back or fix a follow-up they queued in a hurry.
|
||||
- **No queued-vs-sent visibility.** A follow-up sent mid-turn looks identical to a
|
||||
normal send — the user can't tell it's waiting behind the active turn, or when
|
||||
it will be picked up.
|
||||
- **Silent cross-harness inconsistency.** The *same* action — "send a follow-up
|
||||
while the agent is working" — behaves differently per harness (mid-turn steer
|
||||
for live-queue SDKs, next-turn for everyone else) with no signal telling the
|
||||
user which they'll get.
|
||||
|
||||
The redesign fixes all three by holding the message in a **client-side queue
|
||||
before it is POSTed**: the user can edit / delete / reorder while it waits, sees
|
||||
it explicitly as "queued", and controls when it's sent (auto-flush on idle, or
|
||||
steer now).
|
||||
|
||||
## 2. Proposal
|
||||
|
||||
Move the queue **client-side**. The strip becomes a pre-POST draft buffer; a
|
||||
message is only sent to the server when it's flushed or steered.
|
||||
|
||||
```
|
||||
type → client queue "⏱ Queued" (NOT posted) → flush/steer → POST → bubble
|
||||
(strip = "not yet sent, still editable"; bubble = "sent, in flight")
|
||||
```
|
||||
|
||||
### Queue behavior
|
||||
|
||||
- **Show as queued** when the agent is **not idle** (`sessionStatus` busy) — same
|
||||
signal for SDK and native.
|
||||
- **Auto-flush head on idle (FIFO):** when the agent goes idle, send the head of
|
||||
the queue as the next turn. Type-ahead "just works" without any click.
|
||||
- Persist the queue in `localStorage` (keyed by session) so it survives a hard
|
||||
refresh. (Trade-off: no cross-device sync — acceptable for unsent drafts.)
|
||||
|
||||
### Per-message actions
|
||||
|
||||
| Action | Behavior |
|
||||
|--------|----------|
|
||||
| **Edit** | pull the message back into the composer, purely client-side; persists across navigation/refresh |
|
||||
| **Delete** | drop the message from the queue |
|
||||
| **Steer** | POST it now (jump the queue) — deliver mid-turn where the harness supports it |
|
||||
| **Reorder** | client-side drag (grip handle) to reorder the queue within a conversation |
|
||||
|
||||
### Promote-to-bubble rule
|
||||
|
||||
Promote a message from the strip into a normal chat bubble **as soon as it is
|
||||
POSTed** (on flush or steer) — *not* when the agent consumes it. Once it's sent
|
||||
there's no longer anything to edit / delete / steer / reorder, so the strip has
|
||||
no reason to hold it.
|
||||
|
||||
The gap between (a) sent to server and (b) consumed by the agent becomes an
|
||||
**implementation detail** the user need not see — because the strip no longer
|
||||
represents server state, only the still-editable client buffer. This removes the
|
||||
consume-timing dependency entirely.
|
||||
|
||||
### What "steer" means per harness
|
||||
|
||||
Steer always POSTs immediately; how it lands depends on the harness:
|
||||
|
||||
Steer always POSTs immediately (client-side, no runner change); how it lands
|
||||
depends on the harness. The steer button is shown for **all** native sessions —
|
||||
the runner delivers uniformly (POST → buffer → drain → hand to app, all natives'
|
||||
`run_turn` return right after delivery), and the app decides what to do with a
|
||||
message that arrives mid-response:
|
||||
|
||||
| Harness | Steer delivery | Mid-turn? |
|
||||
|---------|----------------|-----------|
|
||||
| claude-sdk / codex-sdk / pi-sdk | runner **live injection** (`_live_response_id` gate) | ✅ deterministic |
|
||||
| cursor-sdk / copilot-sdk | buffer & drain | ❌ next turn |
|
||||
| **codex-native** | explicit **`turn/steer`** RPC when a turn is active | ✅ deterministic *(verified)* |
|
||||
| **claude-native** | `send-keys` into the **live pane**; the TUI folds the paste into the response | ✅ verified (best-effort timing) |
|
||||
| cursor-native / hermes-native | `send-keys` paste into the **live pane** (`supports_enqueue=True`) | ⚠️ app-defined — mechanism confirmed in code, **not yet verified live** |
|
||||
| pi-native | queued to the **resident extension** (`supports_enqueue=True`) | ⚠️ app-defined — mechanism confirmed in code, not yet verified live |
|
||||
| opencode-native | HTTP prompt (`supports_enqueue=True`); the native server has **no live-steer endpoint** → admitted as a new prompt, promoted by the server's own queue at turn end | ❌ next turn (code-confirmed) |
|
||||
| qwen / goose / kimi / kiro / antigravity -native | paste / file / RPC into the app (`supports_enqueue=True`) | ⚠️ app-defined — not yet verified live |
|
||||
|
||||
> **TODO (live verification):** every native harness above reports
|
||||
> `supports_live_message_queue = True` and its delivery mechanism is confirmed
|
||||
> in code (see the enqueue path per harness), but whether the vendor app folds
|
||||
> the steered message in **mid-response** vs. at the **next turn** is confirmed
|
||||
> against a *live* runner only for claude-native + codex-native. Run a live
|
||||
> steer per harness to upgrade the ⚠️ rows. opencode-native is settled: its app
|
||||
> server exposes no live-steer endpoint, so the steered message is always
|
||||
> promoted at the next turn boundary.
|
||||
|
||||
**No runner change is required for native steer** — every native `run_turn`
|
||||
returns right after delivering the input (decoupled from the response), so the
|
||||
drain fires the next message quickly and it reaches the app while the prior
|
||||
response is likely still running; the app does its own steering. Frame the UX
|
||||
honestly: *"send now; the agent folds it into current work if it can"* — which is
|
||||
exactly how native type-ahead already feels. Do **not** promise deterministic
|
||||
mid-turn for the unverified natives.
|
||||
|
||||
**Steer is not interrupt.** In every case above, steer *does not cancel* the
|
||||
running turn — the message is folded in at the agent's next natural breakpoint
|
||||
(after the current tool/step completes), the same feel as steering native Claude
|
||||
by typing while it works. For SDK, `enqueue_session_message` adds the message to
|
||||
the running session's queue; the SDK surfaces it at its next turn-boundary — no
|
||||
teardown. This is distinct from the **Interrupt** button, which really does
|
||||
cancel the turn (`turn.cancel()`).
|
||||
|
||||
### Edges to handle
|
||||
|
||||
| Edge | Rule |
|
||||
|------|------|
|
||||
| POST fails after promote | revert the bubble to the queue (or error-badge it) |
|
||||
| Agent goes idle mid-edit | editing pins the message out of auto-flush until re-committed |
|
||||
| Native mirror-back | consume/mirror still needed as a **reconcile** signal (id-match the optimistic bubble to the real transcript item) so native round-trips don't double-render |
|
||||
|
||||
## 3. Appendix — lifecycle & topology
|
||||
|
||||
### Component topology
|
||||
|
||||
```
|
||||
┌──────────┐ HTTPS+SSE ┌──────────────┐ HTTP ┌──────────┐ HTTP/UNIX socket ┌─────────────────┐
|
||||
│ CLIENT │◄───────────►│ AP SERVER │◄──────►│ RUNNER │◄──────────────────►│ HARNESS SUBPROC │
|
||||
│ (browser)│ │ persist+relay│ │ buffer + │ (1 per conv) │ EXECUTOR=agent │
|
||||
└──────────┘ └──────────────┘ │ schedule │ │ SDK: in-process │
|
||||
└──────────┘ │ native: →app ───┼─► tmux / RPC
|
||||
└─────────────────┘
|
||||
```
|
||||
|
||||
The agent runs **inside the harness subprocess** (SDK loop) or is **bridged out**
|
||||
of it to a real app (native). It does **not** live in the runner process.
|
||||
|
||||
### Busy/idle signal (drives the queue)
|
||||
|
||||
| Harness | "running" from | "idle" from |
|
||||
|---------|----------------|-------------|
|
||||
| SDK | `response.created` → `_live_response_id` set | `response.completed` / stream-end |
|
||||
| native | `UserPromptSubmit` hook | `Stop` / `StopFailure` hook (relayed by the transcript forwarder) |
|
||||
|
||||
Both surface to the client as the same `sessionStatus` field, seeded from the
|
||||
snapshot on bind (correct after refresh, across tabs).
|
||||
|
||||
### Live-injection gate (SDK steer)
|
||||
|
||||
```python
|
||||
_can_forward = (
|
||||
not _native # native uses paste / turn-steer, not this path
|
||||
and not _awaiting_approval # don't steer a turn parked on a human gate
|
||||
and conversation_id in _live_response_id # a response is actually streaming
|
||||
)
|
||||
```
|
||||
|
||||
### Native decoupling (why paste-steer works)
|
||||
|
||||
Native `run_turn` returns as soon as `send-keys` finishes pasting (not when the
|
||||
agent finishes). `_active_turns` clears immediately, so the buffer drains the
|
||||
next message quickly and it pastes into the still-live pane — the native app then
|
||||
decides to steer it. `_native_pane_status` is the reliable liveness signal for a
|
||||
long autonomous native turn (since `_active_turns` clears early).
|
||||
@@ -0,0 +1,379 @@
|
||||
# Qwen Integration Follow-ups
|
||||
|
||||
Tracks pending work and known limitations for the Qwen Code harness
|
||||
(`harness: qwen`, driving `qwen --acp`).
|
||||
|
||||
## What works today
|
||||
|
||||
- `omnigent run --harness qwen` / `executor.harness: qwen` (alias `qwen-code`).
|
||||
- ACP executor: streaming turns, system-prompt folding, session-not-found
|
||||
reset, missing-binary handling.
|
||||
- **Permission gating** (`session/request_permission`): routed through
|
||||
Omnigent's TOOL_CALL policy + human-consent elicitation
|
||||
(`_decide_permission`), mirroring claude-sdk — a hard policy DENY rejects,
|
||||
otherwise the user is asked; default-deny on policy-ASK with no handler.
|
||||
Standalone/test use (no bridges wired) falls back to allow.
|
||||
- `omnigent setup` → **Qwen Code** row: installs the CLI and guides auth
|
||||
(env vars or interactive `/auth`).
|
||||
- Auth via the CLI's own ambient credentials (see Auth model below).
|
||||
- **Provider / gateway routing (clean env).** A spec `auth:` / `providers:`
|
||||
entry is translated to `HARNESS_QWEN_GATEWAY_*` vars and the executor exports
|
||||
`OPENAI_BASE_URL` / `OPENAI_API_KEY` (from the gateway's bearer-token command,
|
||||
run once at session start) / `OPENAI_MODEL` into the `qwen --acp` subprocess.
|
||||
Verified end-to-end against an OpenAI-compatible endpoint. **Caveat:** this is
|
||||
authoritative only when qwen has no conflicting ambient `~/.qwen/settings.json`
|
||||
— see Pending work for the precedence limitation.
|
||||
- **Cost / token tracking.** Per-turn token usage is parsed from qwen's ACP
|
||||
stream and emitted on `TurnComplete.usage` (and fed to the cost observer).
|
||||
qwen rides usage out-of-band on an `agent_message_chunk` whose text is empty
|
||||
and whose `_meta.usage` carries `{inputTokens, outputTokens, totalTokens,
|
||||
thoughtTokens, cachedReadTokens}` (qwen-code `emitUsageMetadata`). The
|
||||
executor sums these across a turn's internal model calls and splits
|
||||
`cachedReadTokens` out of `input_tokens` (qwen's `inputTokens` is cache-
|
||||
inclusive; cost wants the non-cached portion) — see `_accumulate_usage`.
|
||||
Verified end-to-end against a live `qwen --acp` turn.
|
||||
- **Context status.** The UI context meter shows used/total for qwen. The
|
||||
numerator (per-turn context consumed) comes from `_meta.usage.totalTokens`
|
||||
via cost/token tracking above; the denominator (the model's context-window
|
||||
*limit*) comes from a curated Qwen lookup in `get_model_context_window`
|
||||
(`_QWEN_CONTEXT_WINDOWS`) — qwen models are absent from litellm and the MLflow
|
||||
catalog, so without it they fell back to the wrong 128K default
|
||||
(qwen3-coder-plus is 1M). A spec's `executor.context_window` still overrides;
|
||||
unrecognized qwen models keep the 128K fallback.
|
||||
- **In-session model selection (`/model`).** Switching models mid-session
|
||||
works. The model is fixed in the `qwen --acp` subprocess env
|
||||
(`HARNESS_QWEN_MODEL`) at spawn, so on a `/model` change the runner's
|
||||
`HarnessProcessManager` respawns the harness with the new value — a fresh
|
||||
`QwenExecutor` then opens a new `session/new` carrying the new model. Context
|
||||
survives the respawn because the first turn of the new session replays the
|
||||
prior conversation (see History replay below).
|
||||
- **History replay on a fresh ACP session.** When the `qwen --acp` subprocess
|
||||
is (re)spawned — first turn, a `/model` switch, or a `Session not found`
|
||||
reset — qwen holds none of the earlier conversation (it lived in the dead
|
||||
process). `run_turn` normally sends only the latest user turn, so the first
|
||||
turn of any fresh session folds the prior transcript into the prompt as a
|
||||
labeled `Conversation so far:` block (`_history_prefix`), mirroring
|
||||
`ClaudeSDKExecutor._build_prompt`. Keeps a mid-conversation model switch from
|
||||
dropping the thread. (Same fix applied to the goose ACP harness.)
|
||||
- **OS sandbox.** When the spec's `os_env.sandbox` is not `none`, the whole
|
||||
`qwen` process tree is wrapped in the platform sandbox (bwrap / seatbelt) at
|
||||
spawn (`_sandbox_launch_path`), confining qwen's own file/shell tools to the
|
||||
spec's read/write roots — an OS-level guarantee independent of the per-tool
|
||||
permission gate.
|
||||
- **File I/O delegation (`fs/*`).** When an `os_env` is configured, the executor
|
||||
advertises `clientCapabilities.fs` in `initialize`, so qwen routes its file
|
||||
reads/writes back to us as `fs/read_text_file` / `fs/write_text_file` requests
|
||||
(qwen's `AcpFileSystemService` swaps in only when the capability is set). The
|
||||
handlers execute the I/O through the Omnigent `OSEnvironment`, so the spec's
|
||||
sandbox read/write roots are enforced at the Python layer — and the bytes flow
|
||||
through Omnigent rather than qwen touching disk directly. Disabled (qwen uses
|
||||
its own tools) when there's no `os_env` or it's a `fork` env (a forked tree's
|
||||
path would diverge from the qwen subprocess cwd). Binary/non-UTF-8 reads are
|
||||
refused; missing-file reads map to qwen's ENOENT code. (Same fix applied to
|
||||
the goose ACP harness.) See the Pending item below for what's still out of
|
||||
scope (event recording / TOOL_RESULT-phase content policy).
|
||||
|
||||
## Pending work
|
||||
|
||||
Functionality not yet supported, by priority. (How to build each lives in code
|
||||
comments; this is the *what*, not the *how*.)
|
||||
|
||||
### High
|
||||
|
||||
- [x] **Native TUI variant (`qwen-native` / `native-qwen`).** Implemented — the
|
||||
live `qwen` TUI runs in a runner-owned tmux pane embedded in the web UI, driven
|
||||
by `omnigent qwen`. Unlike the goose/cursor `tmux send-keys` native harnesses,
|
||||
it uses qwen's built-in remote-control protocol: web-UI turns are appended to
|
||||
qwen's `--input-file` (a `{"type":"submit"}` line, routed through the same
|
||||
`submitQuery` path the keyboard uses, so it renders in the TUI), and the
|
||||
transcript is mirrored back by tailing qwen's structured `--json-file` event
|
||||
stream (Anthropic stream-json shape). Interrupt/stop still go through the pane
|
||||
(`Escape` / kill) since the input-file watcher has no interrupt command. See
|
||||
`docs/QWEN_NATIVE_DESIGN.md`. **Still a follow-up (PR2):** usage parsing from
|
||||
the `result`/`assistant` events is not yet emitted on `TurnComplete.usage`
|
||||
(see the status-line item below for the model/ring/cost consequences).
|
||||
|
||||
- [x] **Tool-approval elicitation card (TUI → web).** Implemented — qwen's
|
||||
in-terminal tool-approval prompt now also renders as an approval card in the
|
||||
web chat, and answering either surface resolves the other. qwen emits a
|
||||
structured `{"type":"control_request","request":{"subtype":"can_use_tool",
|
||||
"tool_name","tool_use_id","input"},"request_id"}` on `--json-file` **and**
|
||||
accepts a `{"type":"confirmation_response","request_id","allowed"}` on
|
||||
`--input-file`, *coexisting* with its own TUI prompt (whichever answers first
|
||||
wins; qwen's `dual-output.md` confirms `control_request` is emitted whenever a
|
||||
tool needs approval — the earlier "default mode doesn't emit these" note was
|
||||
wrong).
|
||||
- **Mirror:** `omnigent/qwen_native_permissions.py` —
|
||||
`supervise_qwen_approval_mirror` tails the *same* `--json-file` the
|
||||
transcript forwarder reads (seeded at EOF so only new prompts park), POSTs
|
||||
each `can_use_tool` to the server's `qwen-permission-request` hook, and on
|
||||
the web verdict writes `confirmation_response` to the input file (no
|
||||
keystrokes). It's the structured analog of cursor-native's pane-scraping
|
||||
mirror. Wired alongside the forwarder under one supervised task in
|
||||
`runner/app.py::_auto_create_qwen_terminal` (`_supervise_qwen_native_bridges`).
|
||||
- **Server hook:** `POST /v1/sessions/{id}/hooks/qwen-permission-request`
|
||||
(`qwen_permission_request_hook`, modeled on the cursor hook) publishes the
|
||||
standard `response.elicitation_request` (`policy_name=qwen_native_permission`,
|
||||
`phase=pre_tool_use`) and parks via `_publish_and_wait_for_harness_elicitation`.
|
||||
This always surfaces a card whenever the TUI prompts — the explicit goal —
|
||||
rather than routing through `/policies/evaluate` (which would auto-resolve
|
||||
and skip the card when no TOOL_CALL policy matches qwen's tool names).
|
||||
- **Loser release:** qwen emits a `control_response` for a `request_id`
|
||||
whether the TUI or an external `confirmation_response` answered. The mirror
|
||||
watches for it: if it lands while the web card is still parked (TUI answered
|
||||
first), it POSTs `external_elicitation_resolved` to clear the card and skips
|
||||
the stale `confirmation_response`; if the card answered first, the task is
|
||||
already done and the `control_response` just cleans up. Still worth a live
|
||||
E2E to confirm timing under a real `qwen --acp` turn.
|
||||
|
||||
- [ ] **Composer status line: real model + context ring (Web UI).** For
|
||||
native-qwen the composer's model/effort chip is currently **hidden** (web UI
|
||||
flag `nativeVendorOwnsModel` in `chatStore.sessionBindingPatch` →
|
||||
`ComposerStatusLine` in `web/src/pages/ChatPage.tsx`). It was showing the
|
||||
bound spec's *default* model (`claude-sonnet-4-6`) because the qwen-native-ui
|
||||
spec sets no model and qwen picks its model inside the vendor TUI (OpenAI-compat
|
||||
env / qwen's own `/model`), so Omnigent's `llmModel` was a misleading default.
|
||||
Hiding it is the interim; the real fix is to **surface qwen's actual model**
|
||||
(and effort/approval-mode if meaningful). The data is already on qwen's
|
||||
`--json-file` stream — `assistant` message events carry `message.model` (e.g.
|
||||
`openai/gpt-oss-120b:free`) and the `system/session_start` event carries model
|
||||
metadata. The forwarder (`omnigent/qwen_native_forwarder.py`) could parse it
|
||||
and report it onto the session so the chip reflects qwen's reality.
|
||||
- **Context ring + cost tracking also missing**, same root cause: native-qwen
|
||||
doesn't yet parse/forward token usage, so `tokensUsed` / `contextWindow` stay
|
||||
null (the ring renders only when `contextWindow > 0 && tokensUsed != null`)
|
||||
and the session cost stays $0 (cost is derived from per-turn usage × model
|
||||
price). The usage *is* on the stream, though — verified live (`qwen`
|
||||
v0.18.2): each turn's final `assistant` event carries `message.usage`
|
||||
(`{input_tokens, output_tokens, cache_read_input_tokens, total_tokens}`), so
|
||||
the forwarder could parse it and POST `external_session_usage`. The ACP
|
||||
`qwen` harness already does this — see "Cost / token tracking" in *What works
|
||||
today* (`_accumulate_usage`); native-qwen needs the equivalent off the
|
||||
`--json-file` stream. Parse `result.usage` (`input_tokens` / `output_tokens`
|
||||
/ `cache_read_input_tokens` / `total_tokens`) in
|
||||
`omnigent/qwen_native_forwarder.py`, split `cache_read_input_tokens` out of
|
||||
`input_tokens` (qwen's `input_tokens` is cache-inclusive; cost wants the
|
||||
non-cached portion), and report it onto the session so the cost observer +
|
||||
context ring pick it up; the context-window *limit* comes from the curated
|
||||
`_QWEN_CONTEXT_WINDOWS` lookup. One usage-parsing change feeds the model
|
||||
chip, the ring, and cost together.
|
||||
|
||||
- [x] **Restore qwen's TUI history on resume.** `omni qwen --resume <conv_id>`
|
||||
used to relaunch a **blank** `qwen` TUI (only the web chat kept history, via the
|
||||
forwarder). Fixed, using the **same `external_session_id` convention as
|
||||
claude-/codex-/pi-native** (so it's consistent and fork-capable):
|
||||
`_auto_create_qwen_terminal` persists the qwen session id on the Omnigent
|
||||
session (`_persist_qwen_external_session_id` → `PATCH /v1/sessions/{id}`), reads
|
||||
it back from the snapshot (`launch_config.external_session_id`) on the next
|
||||
launch, and it's stamped as `omnigent.fork.source_external_session_id` for fork
|
||||
history carry-over. qwen is cleaner than claude/codex here — it lets us *assign*
|
||||
the id via `--session-id`, so we mint a deterministic one
|
||||
(`qwen_session_id_for_conversation`, UUIDv5 of the `conv_id`) up front instead
|
||||
of capturing a vendor-generated id, and a failed persist self-heals (the id is
|
||||
recomputable). Launch is fresh `--session-id <id>` the first time, `--resume
|
||||
<id>` once qwen has an on-disk recording — the recording check
|
||||
(`qwen_session_recording_exists`, scoped to the launch workspace's qwen project
|
||||
slug at `~/.qwen/projects/<slug>/chats/<id>.jsonl`) is the `--resume` guard,
|
||||
since `--resume` on an id not recorded *under that cwd* shows qwen's blocking
|
||||
"No saved session found" screen — qwen resolves `--resume` per-project, so the
|
||||
check must be workspace-scoped, not a cross-project glob (also keeps
|
||||
never-messaged / pre-convention sessions on the clean fresh path). **No forwarder change
|
||||
needed:** verified that on `--resume` qwen restores history into the TUI from
|
||||
its own checkpoint and emits *only new* events to `--json-file`, so the
|
||||
transcript is never re-mirrored — qwen sidesteps the double-mirror problem that
|
||||
forced goose-native to start fresh.
|
||||
|
||||
- [x] **Carry history into qwen on fork / switch-agent (incl. cross-harness).**
|
||||
Forking a session — or switching its agent — into qwen-native now seeds the new
|
||||
qwen session with the prior conversation, the same way claude-/codex-/pi-native
|
||||
do. qwen-native is registered in `_FORK_HISTORY_NATIVE_HARNESSES`
|
||||
(`server/routes/sessions.py`), so both the fork and switch-agent routes stamp
|
||||
`omnigent.fork.carry_history` and clear `external_session_id` on the clone. On
|
||||
the clone's first launch, `_auto_create_qwen_terminal` calls
|
||||
`_build_qwen_fork_recording`, which fetches the clone's copied Omnigent items
|
||||
(`fetch_all_session_items_for_pi_resume` — harness-neutral) and rebuilds qwen's
|
||||
on-disk recording via `qwen_session_records_from_session_items` +
|
||||
`write_qwen_session_recording`, then forces `--resume`. Because it rebuilds from
|
||||
Omnigent items (not the source's vendor transcript), it works **cross-harness**
|
||||
(claude/pi/codex → qwen). **Key on-disk-format finding:** qwen resolves
|
||||
`--resume <id>` from *three* files, not the `.jsonl` alone — it also needs
|
||||
`chats/<id>.runtime.json` (session index entry) and the project `meta.json`; a
|
||||
bare recording yields the blocking "No saved session found" screen (verified on
|
||||
v0.18.2). The synthesized recording emits only `user`/`assistant` message
|
||||
records (the `system` snapshot records qwen writes live are optional for
|
||||
resume); tool calls are dropped (text turns carry the context). The rebuild is
|
||||
gated on a NULL `external_session_id` so it runs only on the first launch — once
|
||||
the minted id is persisted, later relaunches take the normal resume path and
|
||||
never clobber qwen's live recording (which by then holds post-fork turns). The
|
||||
minted id is the clone's own deterministic `qwen_session_id_for_conversation`,
|
||||
so the resume path recomputes it. Mirrors pi-native's fork rebuild
|
||||
(`_resolve_pi_external_session_id` case 2).
|
||||
|
||||
### Medium
|
||||
|
||||
- [x] **Compaction via `/compact` (web → TUI), with spinner + divider.**
|
||||
Implemented, mirroring cursor-native PR #1259 — the web composer's `/compact`
|
||||
now drives qwen's `/compress` in the TUI, with a "Compacting conversation…"
|
||||
spinner that resolves to the "Conversation compacted" divider when qwen
|
||||
actually finishes. Works for both explicit `/compact` and auto-compaction.
|
||||
- **Server (existing, harness-agnostic):** `/compact` → forwards `{"type":
|
||||
"compact"}` to the bound runner; a 200 means the control was handled in the
|
||||
terminal (server skips its own AP-side compaction, which 400s on the
|
||||
LLM-less native pseudo-agent).
|
||||
- **Runner (`_handle_qwen_native_compact`):** publishes
|
||||
`response.compaction.in_progress` (raises the spinner), submits `/compress`
|
||||
via the **input file** (`submit_user_message`), returns 200; on failure
|
||||
publishes `response.compaction.failed` (dismisses the spinner) + 503. Unlike
|
||||
cursor's bracketed-paste, qwen's input-file `submit` routes through
|
||||
`RemoteInputWatcher` → `submitQuery` (the keyboard's own path), which
|
||||
processes the slash command directly — no autocomplete-dropdown trap, and no
|
||||
`/compress` user bubble on the stream (verified live, `qwen` v0.18.2).
|
||||
- **Completion signal — the chat recording, not the stream.** qwen emits **no**
|
||||
compression event on the `--json-file` stream (`session_start`'s
|
||||
`supported_events` omits it; the green "compressed from…" TUI line is an
|
||||
internal `addItem`, never streamed). But it writes a `{"type":"system",
|
||||
"subtype":"chat_compression","systemPayload":{"info":{originalTokenCount,
|
||||
newTokenCount,compressionStatus}}}` record to its on-disk recording
|
||||
(`~/.qwen/projects/<slug>/chats/<id>.jsonl`) the instant compression
|
||||
finishes. `supervise_qwen_compaction_mirror` tails that recording (seeded at
|
||||
EOF so a resumed session's prior records don't re-fire) and POSTs
|
||||
`external_compaction_status` — `completed` on `compressionStatus == 1`,
|
||||
`failed` on the `COMPRESSION_FAILED_*` codes (2/3) — which the server
|
||||
republishes as `response.compaction.completed/failed`.
|
||||
- **Note on the ACP `qwen` harness:** the in-process executor compresses
|
||||
internally over ACP and is opaque to us (same boundary as the LLM-phase
|
||||
policy exclusion below), so this item is **native-qwen only**.
|
||||
- **Follow-up:** the context ring won't shrink after compaction until usage is
|
||||
forwarded as `external_session_usage` (see the "Composer status line" item) —
|
||||
the recording's `newTokenCount` could feed that.
|
||||
|
||||
- [ ] **Provider routing: settings.json precedence + token refresh.** The
|
||||
base injection now works (see What works today), but two gaps remain before
|
||||
it's robust on a developer machine:
|
||||
- **Ambient settings win.** qwen prefers a user-level `~/.qwen/settings.json`
|
||||
(`security.auth.selectedType` + `modelProviders`) over the injected
|
||||
`OPENAI_*` env vars, so on a host where someone ran `qwen /auth`, the spec's
|
||||
gateway is silently ignored. qwen exposes no config-dir flag, so making the
|
||||
gateway authoritative needs HOME / config-dir isolation for the subprocess.
|
||||
- **No token refresh.** The bearer token is snapshotted once at session start;
|
||||
qwen has no refresh hook, so a short-lived rotating token (Databricks
|
||||
gateway) can expire over a long session. Static keys / stable gateways are
|
||||
unaffected.
|
||||
- [ ] **Databricks path.** Verify the `databricks-*` profile route end-to-end
|
||||
(the env plumbing exists; only the OpenAI-compatible gateway has been tested).
|
||||
The profile route derives the base URL + auth from **ucode state**, so it
|
||||
depends on ucode provisioning a `qwen` agent for the workspace. To test:
|
||||
- *Quick (no ucode):* point a gateway straight at Databricks' OpenAI-compatible
|
||||
serving endpoint — `gateway_base_url = https://<host>/serving-endpoints`,
|
||||
`gateway_auth_command = databricks auth token --profile <p> --output json |
|
||||
jq -r .access_token`, `model = <served-endpoint-name>` — run a turn from a
|
||||
**clean `HOME`** (so `~/.qwen/settings.json` can't take precedence).
|
||||
- *Full route:* spec with `executor.profile: <db-profile>` (or a
|
||||
`databricks-*` model), then `omni run`; confirm the runner log's
|
||||
`qwen gateway routing:` line shows the Databricks base URL + profile.
|
||||
- [x] **Omnigent tools.** Qwen-native now exposes the shared Omnigent MCP relay
|
||||
(`omnigent.claude_native_bridge serve-mcp`, `mcpServers.omnigent`,
|
||||
`trust: true`) to qwen via the `--mcp-config <path>` launch flag (the
|
||||
claude-native model). qwen connects to it on boot, `/mcp` lists it, and the
|
||||
model can call Omnigent's builtin tools (`sys_*`, `load_skill`, `web_fetch`, …).
|
||||
The config lives in the per-session bridge dir, **not** the workspace, so we
|
||||
drop no file in the user's repo, concurrent same-workspace sessions can't
|
||||
collide, and CLI-provided servers are ungated (no "Untrusted MCP server"
|
||||
prompt → no pre-approval step). The token + config are written by
|
||||
`qwen_native_bridge.write_mcp_config`; the live tool surface is advertised by
|
||||
the `tool_relay.json` that `ensure_comment_relay` writes. The `bridge.json`
|
||||
bearer token is written through `_ensure_secure_bridge_dir` (the same
|
||||
owner-only ancestor validation the shared relay applies to token-bearing
|
||||
trees). Permission gating on qwen's *own* tool calls already works.
|
||||
- [ ] **File I/O recording / content policy.** Omnigent now *executes* delegated
|
||||
file reads/writes through the `OSEnvironment` (see "File I/O delegation" in
|
||||
What works today), so the bytes flow through Omnigent and the sandbox roots are
|
||||
enforced. Still missing on top of that: (a) emitting the I/O into Omnigent's
|
||||
event stream (ToolCall-style records) so it shows in history, and (b) running
|
||||
TOOL_RESULT-phase content policy on the read/written content. Both build on the
|
||||
`_handle_fs_read` / `_handle_fs_write` handlers — the byte-level hook now
|
||||
exists; this is wiring the recording/policy layers onto it.
|
||||
|
||||
> LLM-phase policy (`PHASE_LLM_REQUEST` / `PHASE_LLM_RESPONSE`) is intentionally
|
||||
> out of scope: qwen's model calls happen internally over ACP and are opaque to
|
||||
> us. Only tool-call-phase policy is feasible, and it is wired.
|
||||
|
||||
### Low
|
||||
|
||||
- [ ] **More attachment types.** Text files and images now reach the agent;
|
||||
still unsupported are binary documents (PDF, etc.) and audio input.
|
||||
- [ ] **Session resilience:** cancel a turn mid-flight, recover when the `qwen`
|
||||
subprocess crashes, and resume a session across separate runs.
|
||||
- *Done in this pass:* dead qwen-native terminals now recreate on attach
|
||||
instead of failing 4404, so the embedded pane recovers after a crash or
|
||||
deferred-start failure.
|
||||
- [ ] **Vision/audio quality** depends on the model: text-only routes (e.g.
|
||||
`qwen3-coder:free`) can't see forwarded images. Worth surfacing model
|
||||
capability to users picking an agent.
|
||||
|
||||
## Known limitations & behavior
|
||||
|
||||
### Model capability vs. file attachments
|
||||
|
||||
Tool-calling reliability depends on the model. Weak/free routes (notably
|
||||
`qwen/qwen3-coder:free`) **lose the tool-calling thread when a message carries a
|
||||
file attachment**: instead of emitting a structured tool call (which would reach
|
||||
our `session/request_permission` gate), they narrate the shell command as prose
|
||||
(e.g. printing `Command: rm …` as text). The omni run is deterministic about
|
||||
this — every `input_file` turn skips policy/elicitation; every text-only turn
|
||||
reaches them. `qwen3-coder-plus` keeps tool-calling across the same prompts.
|
||||
|
||||
Mitigation: `_text_from_blocks` fences inlined file content with a labeled
|
||||
`--- attached file: <name> ---` header/footer so the model reads it as an
|
||||
attachment, not instructions (bare-appending raw content reproduced the
|
||||
prose-narration leak even on `:free`). This reduces but does not eliminate the
|
||||
fragility — for reliable tool use with attachments, prefer a stronger model.
|
||||
|
||||
### Auth model
|
||||
|
||||
Qwen has **no CLI login** — its `auth` subcommand was removed (`qwen login`
|
||||
doesn't exist; `qwen auth status` prints "removed" and exits 0). Auth is:
|
||||
|
||||
- **Headless / ACP:** env vars — `OPENAI_API_KEY` + `OPENAI_BASE_URL` +
|
||||
`OPENAI_MODEL`, or `BAILIAN_CODING_PLAN_API_KEY`, or `OPENROUTER_API_KEY`.
|
||||
- **Interactive:** run `qwen` and use `/auth` (API key or Alibaba Cloud
|
||||
Coding Plan), persisted under `~/.qwen/`.
|
||||
|
||||
Qwen OAuth was discontinued 2026-04-15; the installed CLI may still mention it
|
||||
(version skew), but the service is gone. The `HarnessInstallSpec` deliberately
|
||||
leaves `login_args` / `logout_args` / `status_args` unset so
|
||||
`harness_cli_logged_in/login/logout` stay no-ops for qwen.
|
||||
|
||||
### ACP constraints
|
||||
|
||||
- Qwen runs its own tools internally (not yet bridged — see Pending work).
|
||||
- Qwen assigns its own `sessionId`; ours is a hint.
|
||||
- ACP has no system-prompt field, so the spec `prompt:` is folded into the
|
||||
first user turn.
|
||||
- Server-initiated requests are dispatched by method: `request_permission`
|
||||
goes through the policy + elicitation gate (see What works today); everything
|
||||
else (including `fs/*`) → JSON-RPC method-not-found. We do **not** advertise
|
||||
`clientCapabilities.fs` in `initialize`, so qwen never delegates file ops to
|
||||
us — it uses its own file tools. (fs delegation handlers were removed as dead
|
||||
code; re-add them with the capability — see Pending work.)
|
||||
|
||||
## Reference
|
||||
|
||||
### ACP session lifecycle (`qwen --acp`, JSON-RPC over NDJSON)
|
||||
|
||||
1. `initialize` — capability handshake (once per subprocess).
|
||||
2. `session/new { cwd, mcpServers }` — server returns its own `sessionId`.
|
||||
3. `session/prompt { sessionId, prompt }` — streaming `session/update`
|
||||
notifications flow back; the final response resolves the request.
|
||||
4. The subprocess is kept alive across turns (no per-turn respawn).
|
||||
|
||||
### Model override
|
||||
|
||||
Spec model → provider default → catalog default; `/model` overrides via
|
||||
`HARNESS_QWEN_MODEL`.
|
||||
|
||||
### Env vars consumed by the harness wrap
|
||||
|
||||
`HARNESS_QWEN_MODEL`, `HARNESS_QWEN_CWD`, `HARNESS_QWEN_PATH`,
|
||||
`HARNESS_QWEN_OS_ENV`. (Gateway/Databricks vars are computed but not yet
|
||||
consumed — see Pending work. No skills-bridge vars are emitted.)
|
||||
@@ -0,0 +1,418 @@
|
||||
# `native-qwen` — Design Proposal
|
||||
|
||||
A terminal-native Qwen Code harness (`harness: qwen-native`, alias `native-qwen`)
|
||||
that embeds the **live interactive `qwen` TUI** in the Omnigent web UI, instead of
|
||||
driving `qwen --acp` as a piped request/response subprocess (the existing `qwen`
|
||||
harness — see [QWEN_FOLLOWUPS.md](./QWEN_FOLLOWUPS.md)).
|
||||
|
||||
This is the High-priority "Native TUI variant" item from the follow-ups doc.
|
||||
|
||||
## Key insight
|
||||
|
||||
Unlike goose / cursor / claude-native (which can only `tmux send-keys` into a
|
||||
pane and scrape its output), `qwen` ships a **built-in remote-control protocol**.
|
||||
Verified against `qwen` v0.18.1 (`RemoteInputWatcher` + dual-output in `cli.js`):
|
||||
|
||||
- **Inbound** — `--input-file <path>`: qwen `watchFile`s it and parses appended
|
||||
JSONL commands.
|
||||
- **Outbound** — `--json-file <path>` / `--json-fd <n>`: structured JSON events
|
||||
stream out **while the TUI still renders normally** in the terminal.
|
||||
|
||||
This lets Omnigent inject turns atomically *and* recover two things the other
|
||||
native harnesses surrender to the vendor: **per-tool permission gating** and
|
||||
**token/usage tracking**.
|
||||
|
||||
## Protocol surface (verified from the binary)
|
||||
|
||||
```jsonc
|
||||
// us → qwen (append a line to --input-file)
|
||||
{"type":"submit","text":"<user message>"}
|
||||
{"type":"confirmation_response","request_id":"<id>","allowed":true|false}
|
||||
|
||||
// qwen → us (lines emitted to --json-file / --json-fd)
|
||||
{"type":"control_request",
|
||||
"request":{"subtype":"can_use_tool","tool_name":"...","tool_use_id":"...",
|
||||
"input":{...},"blocked_path":null},
|
||||
"request_id":"<id>"}
|
||||
// ...plus assistant / tool / result / usage stream-json events
|
||||
```
|
||||
|
||||
Relevant launch flags: `-m/--model`, `--openai-api-key`, `--openai-base-url`,
|
||||
`--system-prompt` / `--append-system-prompt`, `--approval-mode`,
|
||||
`-c/--continue`, `-r/--resume`, `--include-partial-messages`.
|
||||
|
||||
## Architecture: hybrid — tmux pane for *display*, files for *control*
|
||||
|
||||
The web UI embeds a terminal pane for native harnesses (`UI_MODE=terminal`), so
|
||||
`qwen` still runs inside a runner-owned **tmux pane** purely so the user sees the
|
||||
live TUI. But message injection and event capture flow through the **files**, not
|
||||
`send-keys`. The one exception is **interrupt**: the input-file watcher only
|
||||
accepts `submit` + `confirmation_response`, so Stop sends `Escape` to the pane.
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph CLI["omnigent qwen (CLI wrapper)"]
|
||||
W[qwen_native.py<br/>daemon bind · terminal-ready poll · tmux attach]
|
||||
end
|
||||
|
||||
subgraph Runner["Omnigent Runner"]
|
||||
AC["_auto_create_qwen_terminal<br/>(runner/app.py)"]
|
||||
BR["qwen_native_bridge.py<br/>writes tmux.json + IN/OUT paths"]
|
||||
FW["qwen_native_forwarder.py<br/>tails --json-file"]
|
||||
POL["TOOL_CALL policy<br/>+ human elicitation<br/>(reuses ACP _decide_permission)"]
|
||||
end
|
||||
|
||||
subgraph Harness["qwen-native harness process"]
|
||||
EX["QwenNativeExecutor.run_turn()<br/>supports_streaming=False<br/>supports_live_message_queue=True"]
|
||||
end
|
||||
|
||||
subgraph Term["tmux pane (embedded in web UI)"]
|
||||
Q["qwen TUI<br/>-m MODEL --openai-*<br/>--input-file IN --json-file OUT"]
|
||||
end
|
||||
|
||||
UI["Web chat UI"]
|
||||
|
||||
W --> AC
|
||||
AC --> BR
|
||||
BR -->|launch| Q
|
||||
BR -. tmux.json .-> FW
|
||||
|
||||
UI -->|user turn| EX
|
||||
EX -->|append {type:submit}| IN[(IN file)]
|
||||
IN -->|watchFile| Q
|
||||
|
||||
Q -->|assistant / tool / usage events| OUT[(OUT file)]
|
||||
OUT --> FW
|
||||
FW -->|mirror transcript| UI
|
||||
|
||||
Q -->|control_request: can_use_tool| OUT
|
||||
FW -->|request_id| POL
|
||||
POL -->|allowed?| EX
|
||||
EX -->|append {type:confirmation_response}| IN
|
||||
|
||||
UI -.->|Stop| BR
|
||||
BR -.->|Escape / kill-session| Q
|
||||
```
|
||||
|
||||
## Turn lifecycle (sequence)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Web UI
|
||||
participant EX as QwenNativeExecutor
|
||||
participant IN as --input-file
|
||||
participant Q as qwen TUI
|
||||
participant OUT as --json-file
|
||||
participant FW as forwarder
|
||||
participant POL as policy + elicitation
|
||||
|
||||
UI->>EX: user turn
|
||||
EX->>IN: {"type":"submit","text":...}
|
||||
EX-->>UI: TurnComplete(response=None)
|
||||
IN-->>Q: watchFile picks up line
|
||||
Q->>OUT: assistant text chunks
|
||||
OUT-->>FW: tail
|
||||
FW-->>UI: mirror transcript
|
||||
|
||||
Note over Q: model wants to run a tool
|
||||
Q->>OUT: control_request {can_use_tool, request_id}
|
||||
OUT-->>FW: tail
|
||||
FW->>POL: evaluate tool_name + input
|
||||
POL-->>FW: allow / deny (DENY rejects; ASK → user)
|
||||
FW->>IN: {"type":"confirmation_response", request_id, allowed}
|
||||
IN-->>Q: watchFile picks up line
|
||||
Q->>OUT: tool_result + assistant continuation
|
||||
OUT-->>FW: tail
|
||||
FW-->>UI: mirror
|
||||
```
|
||||
|
||||
## ASCII view (same design, for non-mermaid renderers)
|
||||
|
||||
```
|
||||
┌──────────────────────────┐
|
||||
│ Web Chat UI │
|
||||
│ (embeds the tmux pane) │
|
||||
└──────────────────────────┘
|
||||
│ ▲ ▲ ┊ Stop
|
||||
user turn│ │transcript │ ┊ button
|
||||
▼ │ │ ▼
|
||||
┌───────────────────────────────┐ ┌──────────────────────────────┐
|
||||
│ QwenNativeExecutor │ │ qwen_native_forwarder │
|
||||
│ run_turn(): append "submit" │ │ tails OUT (--json-file): │
|
||||
│ supports_streaming = False │ │ • assistant/tool/usage →UI │
|
||||
│ live_message_queue = True │ │ • can_use_tool → policy │
|
||||
└───────────────────────────────┘ └──────────────────────────────┘
|
||||
│ append │ ▲ allow? ▲ tail
|
||||
│ {"type":"submit", │ │ │
|
||||
│ "text":...} ▼ │ │
|
||||
│ ┌─────────────────────┐ │
|
||||
│ ┌─────────────────────│ TOOL_CALL policy │ │
|
||||
│ │ append │ + human elicitation│ │
|
||||
│ │ {"type": │ (ACP _decide_ │ │
|
||||
│ │ "confirmation_ │ permission reuse) │ │
|
||||
│ │ response", └─────────────────────┘ │
|
||||
│ │ request_id,allowed} │
|
||||
▼ ▼ │
|
||||
╔═══════════════════╗ ╔═══════════════════╗
|
||||
║ IN file ║ ║ OUT file ║
|
||||
║ (--input-file) ║ ║ (--json-file) ║
|
||||
╚═══════════════════╝ ╚═══════════════════╝
|
||||
│ watchFile ▲ emits events
|
||||
▼ │
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ qwen TUI (live, interactive) │
|
||||
│ launched as: qwen -m MODEL --openai-* \ │
|
||||
│ --input-file IN --json-file OUT │
|
||||
│ │
|
||||
│ • renders normally in the tmux pane ← user SEES this │
|
||||
│ • RemoteInputWatcher reads IN • dual-output writes OUT │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
▲ runs inside
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ tmux pane (runner-owned) ── Escape/kill-session on Stop ──┐ │
|
||||
│ created by _auto_create_qwen_terminal → qwen_native_bridge │ │
|
||||
│ bridge writes tmux.json (socket+target) + IN/OUT paths ◄────┘
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
▲ launched by
|
||||
│
|
||||
┌──────────────────────────────────────────────────────────────────┐
|
||||
│ omnigent qwen (CLI wrapper, qwen_native.py) │
|
||||
│ daemon bind · terminal-ready poll · attach local TTY │
|
||||
└──────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
Two channels, two purposes:
|
||||
|
||||
- **Display** — qwen runs in a runner-owned **tmux pane** the web UI embeds, so the
|
||||
user watches the real TUI live.
|
||||
- **Control** — instead of racy `tmux send-keys`, Omnigent writes JSONL to the **IN
|
||||
file** and reads structured events from the **OUT file**.
|
||||
|
||||
## Does the user's message still show in the terminal?
|
||||
|
||||
**Yes.** This is the most important thing to get right about the file-based design,
|
||||
and it's verified from the binary. qwen wires the input-file handler to the *same*
|
||||
function the keyboard uses:
|
||||
|
||||
```js
|
||||
// qwen cli.js — RemoteInput consumer
|
||||
setSubmitFn((text) => submitQuery(text));
|
||||
```
|
||||
|
||||
`submitQuery` is the canonical submit path — the one invoked when a user types in
|
||||
the box and presses Enter. So a `{"type":"submit","text":...}` line is **not** a
|
||||
hidden side-channel; qwen renders it in the TUI transcript exactly like typed
|
||||
input (the user-message bubble appears, then the streaming reply). Because the web
|
||||
UI embeds that same pane, the message shows in **both** surfaces, identically.
|
||||
|
||||
The difference from `tmux send-keys` is *how the text arrives*, not *whether it
|
||||
shows*:
|
||||
|
||||
| | `tmux send-keys` (goose/cursor) | `--input-file` (qwen-native) |
|
||||
|---|---|---|
|
||||
| Path into qwen | simulate keystrokes into the input box, then Enter | qwen calls `submitQuery(text)` directly |
|
||||
| Renders in TUI? | yes (you literally see it typed) | yes (rendered via the normal submit path) |
|
||||
| Failure modes | paste/Enter races, settle-detection, draft-clearing | none of those — atomic line append |
|
||||
|
||||
So we lose the keystroke *simulation*, but not the on-screen *display*.
|
||||
|
||||
## Why this beats the goose-native model
|
||||
|
||||
| Capability | goose / cursor / claude-native | **qwen-native** |
|
||||
|---|---|---|
|
||||
| Message injection | `tmux send-keys` + settle/paste-commit polling (racy) | append one JSONL line (atomic) |
|
||||
| Transcript / output | scrape the tmux pane | structured `--json-file` events |
|
||||
| **Tool permission gating** | **surrendered to vendor** | **Omnigent gates** via `can_use_tool` → policy → `confirmation_response` |
|
||||
| Token / usage | none | usage events from the stream |
|
||||
| Model / auth / gateway | env-only (`~/.qwen/settings.json` can win) | CLI flags (`-m`, `--openai-*`) — precedence fight moot |
|
||||
|
||||
## Pros & cons
|
||||
|
||||
### Pros
|
||||
|
||||
- **No injection races.** Appending a JSONL line is atomic; we skip goose-native's
|
||||
settle-detection, paste-commit polling, and draft-clearing entirely — and the
|
||||
whole class of "message silently dropped because Enter folded into the paste"
|
||||
bugs goes away.
|
||||
- **Structured I/O.** The forwarder parses real events (`--json-file`) instead of
|
||||
diffing scraped pane text, so transcript fidelity, tool calls, and usage are
|
||||
reliable rather than best-effort regex over ANSI.
|
||||
- **Permission gating works** — the headline win. `can_use_tool` control requests
|
||||
let Omnigent run its TOOL_CALL policy + human elicitation and answer with
|
||||
`confirmation_response`, reusing the ACP harness's `_decide_permission`. The
|
||||
other native harnesses surrender this to the vendor.
|
||||
- **Token / usage tracking** comes back for free from the event stream.
|
||||
- **Clean model / auth / gateway.** `-m`, `--openai-api-key`, `--openai-base-url`
|
||||
are explicit launch flags, so the `~/.qwen/settings.json`-precedence fight that
|
||||
dogs the ACP path (see QWEN_FOLLOWUPS "Pending work") is moot.
|
||||
- **Message still displays in the terminal** (see section above) — no UX regression
|
||||
vs. send-keys.
|
||||
|
||||
### Cons / trade-offs
|
||||
|
||||
- **Two extra files per session** (IN + OUT) to create, secure (`0700`), and clean
|
||||
up — more lifecycle surface than a pure tmux pane.
|
||||
- **Interrupt isn't in the protocol.** The input-file watcher only accepts `submit`
|
||||
and `confirmation_response`, so Stop still falls back to `Escape` on the tmux
|
||||
pane (we keep a foot in the tmux world anyway, for display).
|
||||
- **Couples us to qwen's dual-output / RemoteInput protocol**, which is newer and
|
||||
less battle-tested than typing keystrokes. If qwen changes the JSONL schema we
|
||||
break; `tmux send-keys` would not. Mitigated by pinning behavior to the verified
|
||||
v0.18.1 shape and a live smoke test in CI.
|
||||
- **Diverges from the other native harnesses.** Reviewers expecting the goose/cursor
|
||||
send-keys pattern will see a different bridge; the upside (gating + usage) is worth
|
||||
documenting so the divergence reads as intentional.
|
||||
- **Still needs tmux** for the embedded display, so we don't actually shed the tmux
|
||||
dependency — we just stop using it as the *control* channel.
|
||||
|
||||
### Mitigations
|
||||
|
||||
Mapping each con to how we address it. Most are solvable; two are acceptable
|
||||
trade-offs; one is inherent to the embedded-terminal UX.
|
||||
|
||||
| Con | Status | How we address it |
|
||||
|---|---|---|
|
||||
| Two extra files to manage | **Solved** | Reuse the existing bridge-dir pattern (`/tmp/omnigent-<uid>/qwen-native/<hash>`, `0700`) and the runner's session-close hook (goose-native already has stop/cleanup sites). Make **OUT a FIFO** (qwen's `--json-file` accepts a FIFO / `/dev/fd/N`) so it streams and never grows on disk; IN stays a small append-only file rotated/cleared on close. |
|
||||
| Interrupt not in the protocol | **Acceptable** | `Escape` via the tmux pane is reliable and we keep tmux for display anyway, so this costs nothing extra. Optional follow-up: upstream an `interrupt` command to qwen's `RemoteInputWatcher` so Stop becomes fully file-based too. |
|
||||
| Coupled to qwen's dual-output schema | **Solved (defense in depth)** | (1) **Graceful degradation** — detect `qwen --version`/flag support at launch; if `--input-file`/`--json-file` are absent on an older qwen, fall back to the goose-style `tmux send-keys` bridge. We support both; file-based is just the default when available. (2) Defensive parser that ignores unknown event types (like the existing forwarders). (3) A CI **smoke test** that launches the real binary and asserts the protocol shape, so a version bump that breaks it fails loudly. |
|
||||
| Diverges from other native harnesses | **Solved (design)** | Define a small shared native-bridge contract (`inject` / `interrupt` / `kill`) that both the file-based (qwen) and send-keys (goose/cursor) bridges implement, so reviewers see one shape with two backends. The win (gating + usage) is documented so the divergence reads as intentional. |
|
||||
| Still needs tmux for display | **Inherent** | Any *embedded live terminal* needs a real terminal — unavoidable for the native-TUI goal. Upside: because we already parse the full JSON event stream, a future **pure-web rendering mode** (Omnigent renders qwen with no tmux) becomes possible as a separate option — but that is explicitly out of scope here. |
|
||||
|
||||
## Files to add (mirrors goose-native; bridge is file-based, not send-keys)
|
||||
|
||||
| New file | Role |
|
||||
|---|---|
|
||||
| `omnigent/inner/qwen_native_executor.py` | `submit` via input-file; `supports_streaming=False`, `supports_live_message_queue=True` |
|
||||
| `omnigent/inner/qwen_native_harness.py` | `create_app()` factory |
|
||||
| `omnigent/qwen_native_bridge.py` | input-file append (`submit` / `confirmation_response`), tmux.json, `inject_interrupt` (Escape), `kill_session`, `build_qwen_native_spawn_env` |
|
||||
| `omnigent/qwen_native_forwarder.py` | tail `--json-file`, mirror transcript, drive the permission gate |
|
||||
| `omnigent/qwen_native.py` | `omnigent qwen` wrapper (clone `goose_native.py`) |
|
||||
|
||||
## Registration touch-points (one-liners, beside the goose entries)
|
||||
|
||||
- `omnigent/runtime/harnesses/__init__.py` → `"qwen-native": "omnigent.inner.qwen_native_harness"`
|
||||
- `omnigent/harness_aliases.py` → add `qwen-native` + `native-qwen` to `NATIVE_HARNESSES`
|
||||
- `omnigent/native_coding_agents.py` + `omnigent/_wrapper_labels.py` → `QWEN_NATIVE_*`
|
||||
- `omnigent/onboarding/harness_install.py` → `_HARNESS_NAME_TO_KEY` → existing `QWEN_KEY`
|
||||
- `omnigent/runner/app.py` → `_auto_create_qwen_terminal` + the ~7 goose-native dispatch sites
|
||||
- `omnigent/cli.py` → `omnigent qwen` command
|
||||
|
||||
> Naming: keep `qwen` = ACP (piped); add `qwen-native` / `native-qwen` for the TUI,
|
||||
> mirroring how `goose` and `goose-native` coexist. The default can be flipped later.
|
||||
|
||||
## Sequencing
|
||||
|
||||
- **PR 1 — core:** the 5 files + registration; launch `qwen` in the pane, submit
|
||||
via input-file, tail json-file to the web UI. Live smoke test against the real
|
||||
binary.
|
||||
- **PR 2 — polish:** wire `can_use_tool` → policy/elicitation → `confirmation_response`
|
||||
(reuse ACP `_decide_permission`); usage parsing; interrupt/stop; `-c/-r` resume;
|
||||
attachments; update `QWEN_FOLLOWUPS.md` (native-qwen Pending → Works).
|
||||
|
||||
## Live-verification items — resolved against `qwen` v0.18.1
|
||||
|
||||
Verified end-to-end via a PTY-driven TUI run (`--input-file` + `--json-file`):
|
||||
|
||||
1. **`--json-file` event shape** — confirmed it's the Anthropic/claude-sdk
|
||||
stream-json envelope, identical to `--output-format stream-json`:
|
||||
- `{"type":"system","subtype":"init", session_id, model, tools, slash_commands,
|
||||
permission_mode, ...}`
|
||||
- `{"type":"stream_event","event":{...Anthropic streaming deltas...}}`
|
||||
- `{"type":"user"|"assistant","message":{role, content:[{type:"thinking"|"text"|
|
||||
"tool_use"...}]}}`
|
||||
- `{"type":"result","subtype":"success","result":"<final>","usage":{input_tokens,
|
||||
output_tokens,cache_read_input_tokens,total_tokens},"permission_denials":[...]}`
|
||||
- tool gating arrives as `{"type":"control_request","request":{"subtype":
|
||||
"can_use_tool",...},"request_id":...}` (answered via `confirmation_response`).
|
||||
So the forwarder can reuse the existing claude-sdk/claude-native stream-json
|
||||
parsing rather than inventing a parser.
|
||||
2. **`--input-file` must pre-exist** — qwen `watchFile`s the path; the bridge
|
||||
`touch`es it before launch. A `{"type":"submit","text":...}` line is picked up
|
||||
and qwen emits a matching `user` event.
|
||||
3. **Display confirmed** — the submitted text renders in the pane (the user bubble
|
||||
appears), proving the file-based path is not a hidden side-channel.
|
||||
4. **`--json-file` is TUI-only** — headless `-p` ignores it (it uses
|
||||
`--output-format stream-json` instead), which is exactly the native-TUI case.
|
||||
|
||||
Still to confirm at integration time: that `Escape` to the pane interrupts a
|
||||
running turn (the input-file watcher has no interrupt command, so Stop uses the
|
||||
pane — same as goose-native).
|
||||
|
||||
## Boot-order race + readiness gate (verified fix)
|
||||
|
||||
qwen's `RemoteInputWatcher` initializes its read offset (`bytesRead`) to the
|
||||
**current size of `--input-file`** when it starts watching — synchronously in the
|
||||
watcher's constructor, during TUI boot, *before* the React app mounts and wires
|
||||
`setSubmitFn`. If the executor appends a `submit` line *before* that runs, qwen
|
||||
takes its offset past the line and never reads it: the message is silently
|
||||
dropped. The **first turn of a fresh session hits this every time**, because the
|
||||
harness turn fires while `qwen` is still starting up (it takes seconds to boot).
|
||||
|
||||
Fix (`qwen_native_bridge.wait_for_ready` + `QwenNativeExecutor._ensure_ready`):
|
||||
before its first append, the executor blocks until qwen's first `system` event
|
||||
(`subtype:"session_start"`) appears on `--json-file`. That event is emitted
|
||||
*after* the watcher's constructor has run, so its presence guarantees the offset
|
||||
was taken on the still-empty input file — a subsequent append is reliably
|
||||
detected. The wait is latched (once-per-session); warm turns don't re-block.
|
||||
A side benefit: once qwen is watching, *any* later append lands beyond the
|
||||
offset, so even a session that lost its first message recovers on the next one.
|
||||
|
||||
Schema note: `qwen` v0.18.1-preview.1 renamed the first system event
|
||||
`subtype` from `init` → `session_start` (and wraps payload in `data`). The
|
||||
forwarder ignores `system` events (it mirrors only `user`/`assistant`), and the
|
||||
`user`/`assistant` event shape (`uuid` + `message.content[].text`) is unchanged,
|
||||
so only the readiness probe keys on `system` — matched loosely by `"type":
|
||||
"system"`.
|
||||
|
||||
## Quitting the TUI (clean-exit handling)
|
||||
|
||||
The user drives the qwen TUI directly, so quitting it (Ctrl+C / `/quit`) is a
|
||||
normal end-of-session, not a crash. The runner's generic required-terminal-exit
|
||||
classifier infers "clean" from the last PTY status (`session_was_idle`), but
|
||||
qwen's "powering down" redraw on quit trips the activity watcher and flips the
|
||||
status to `running` in the instant before the process exits — so a quit was
|
||||
misclassified as a crash and rendered the scary `required_terminal_exited` card.
|
||||
|
||||
Fix (`_publish_terminal_exit` in `runner/app.py`): a qwen required-terminal exit
|
||||
is treated as a clean shutdown (release the harness, no `failed` card). This is
|
||||
safe because genuine *boot* failures never reach this path — they surface via
|
||||
`_auto_create_qwen_terminal`'s error handler → `_publish_native_terminal_start_error`
|
||||
— so a qwen terminal exit here is always post-boot, i.e. user-initiated.
|
||||
|
||||
## Session resume (verified)
|
||||
|
||||
On `omni qwen --resume <conv_id>` (or a runner restart), `_auto_create_qwen_terminal`
|
||||
restores the qwen TUI's own history so the embedded pane shows the prior
|
||||
conversation instead of a blank prompt. It follows the **same
|
||||
`external_session_id` convention as claude-/codex-/pi-native** so it's consistent
|
||||
and fork-capable:
|
||||
|
||||
- **Persisted id (the convention).** The qwen session id is recorded on the
|
||||
Omnigent session via `external_session_id` (`_persist_qwen_external_session_id`
|
||||
→ `PATCH /v1/sessions/{id}`), read back from the snapshot on the next launch
|
||||
(`launch_config.external_session_id`), and stamped as
|
||||
`omnigent.fork.source_external_session_id` so a fork carries history — exactly
|
||||
like the other resuming native harnesses.
|
||||
- **Minting the id.** qwen is cleaner than claude/codex here: it lets us *assign*
|
||||
the id via `--session-id`, so instead of capturing a vendor-generated id off the
|
||||
event stream we mint a deterministic one — `qwen_session_id_for_conversation`
|
||||
(UUIDv5 of the `conv_id`). Being recomputable means a failed persist self-heals
|
||||
on the next launch.
|
||||
- **Fresh vs resume guard.** qwen records to `~/.qwen/projects/<slug>/chats/<id>.jsonl`
|
||||
(`--chat-recording`, on by default) and resolves `--resume <id>` **per-project**
|
||||
(the cwd's slug), not globally. So `qwen_session_recording_exists(id, workspace)`
|
||||
checks that file under the *launch workspace's* slug (`<slug>` = the realpath
|
||||
with every non-alphanumeric char → `-`); if present launch `--resume <id>`,
|
||||
else `--session-id <id>`. Scoping to the workspace is essential: a check across
|
||||
all projects would pick `--resume` for a recording made under a *different*
|
||||
workspace and land the user on qwen's blocking "No saved session found" screen
|
||||
(moved/renamed repo, or resume from another cwd). A false negative (slug drift)
|
||||
only degrades to a clean fresh launch. This also covers the never-messaged edge
|
||||
and pre-convention sessions.
|
||||
- **No double-mirror.** Verified that on `--resume` qwen rebuilds the TUI display
|
||||
from its on-disk checkpoint but emits **only new** events to `--json-file` — it
|
||||
does not replay the prior transcript. So the forwarder (cursor reset on
|
||||
re-launch) sees only new messages; the web chat keeps its already-persisted
|
||||
history and gains the new exchange, with no duplicate bubbles. This is why qwen
|
||||
can restore TUI history where goose-native deliberately starts fresh.
|
||||
@@ -0,0 +1,126 @@
|
||||
# Antigravity-native harness: RPC core rework
|
||||
|
||||
**Status:** Design (approved direction; pending implementation plan)
|
||||
**Date:** 2026-06-22
|
||||
**Supersedes:** the runtime core of PR #892 (antigravity-native). Periphery from #892 is reused.
|
||||
**Source of truth for wire shapes:** live-verified against agy 1.0.10 — see memory `agy-rpc-interaction-bridge.md`.
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
The current native harness (PR #892) drives the `agy` CLI through a tmux terminal with two indirect channels:
|
||||
|
||||
- **Read path** — tails agy's JSONL transcript (`~/.gemini/antigravity-cli/brain/<id>/.system_generated/logs/transcript_full.jsonl`) and mirrors steps into the session.
|
||||
- **Write path** — types web turns into the agy TUI via tmux `send-keys`.
|
||||
|
||||
This works for plain text turns but has a **fragility class** and **functional gaps**:
|
||||
|
||||
1. **Out-of-order / non-contiguous `step_index`** in the JSONL forced a durable SET resume cursor, gap-free-prefix delivery, and audit gating — and still produced the **live double-render** (delta preview vs committed message) plus a **user-message duplication** (direct `/events` post vs the forwarder mirroring agy's `USER_INPUT`). Root-caused as inherent to the transcript-mirror approach; *predates* the out-of-order change (verified by running both revisions).
|
||||
2. **No interactive-prompt support.** agy's `ask_question` (multi-select) and tool `request-review` approvals are TUI widgets with no web response path; the session sits `idle` while agy blocks.
|
||||
3. **No real interrupt** (`interrupt_session` is stubbed to `False`).
|
||||
|
||||
A spike found agy exposes a **structured connect-RPC surface** that replaces all three channels cleanly. This design reworks the harness core onto that RPC, eliminating the transcript-mirror fragility and adding interactions + interrupt, while keeping the terminal-first UX (agy still runs in a tmux terminal).
|
||||
|
||||
## 2. Validated RPC surface
|
||||
|
||||
connect-RPC service `exa.language_server_pb.LanguageServerService`, TLS HTTP/2 on a loopback port, self-signed (`verify=False`), JSON accepted (`Content-Type: application/json`). All of the following are live-verified except where noted.
|
||||
|
||||
- **Identity:** `cascadeId == conversationId == brain-dir UUID` (one id). `GetCascadeId`/`GetCascadeStatus`/`ListCascadeIds` do **not** exist (404).
|
||||
- **Port discovery:** reuse `omnigent/antigravity_native_rpc.py` — `discover_language_server_port(pid)` / `_candidate_agy_rpc_ports()` + `_conversation_matches` (Heartbeat → 200; `GetConversationMetadata` echoes `rootConversationId`).
|
||||
- **Read / detect:**
|
||||
- `GetCascadeTrajectorySteps {cascadeId}` (unary) → `steps[]` — one-shot.
|
||||
- `StreamAgentStateUpdates {conversationId}` (connect server-stream, `application/connect+json`, 5-byte `[flag][BE-len]` framing) → first frame `update.mainTrajectoryUpdate.stepsUpdate.steps[]`, then long-polls — live.
|
||||
- (`StreamCascadeReactiveUpdates` is **deprecated** — returns `{"error":{"message":"reactive state is deprecated"}}`. Do not use.)
|
||||
- **Step shape:** `status` ∈ `CORTEX_STEP_STATUS_{RUNNING,WAITING,DONE,ERROR}`; `type` ∈ `CORTEX_STEP_TYPE_{PLANNER_RESPONSE,RUN_COMMAND,...}`; `requestedInteraction` (`askQuestion` | `permission`) when `WAITING`; `runCommand.{commandLine, proposedCommandLine, cwd, exitCode, combinedOutput.full}`; `metadata.sourceTrajectoryStepInfo.{trajectoryId, stepIndex, cascadeId}`; `completedInteractions[].response` (echoes the delivered answer).
|
||||
- **Answer interaction:** `HandleCascadeUserInteraction {cascadeId, interaction:{trajectoryId, stepIndex, <variant>}}` (unary) → `200 {}`.
|
||||
- **Question:** `interaction.askQuestion.responses:[{question:"<verbatim>", selectedOptionIds:["<option id>"]}]` (`writeInResponse` for write-ins). Option `id` is `"1".."N"`, not the text.
|
||||
- **Approval:** `interaction.permission.{allow: true|false}`. **No `approvalId`** — keyed solely by `trajectoryId+stepIndex` (the binary-tag `approvalInteraction.{approvalId,approve}` is a *different* approval kind, not the run_command path).
|
||||
- `trajectoryId` **and** `stepIndex` MUST be **inside** `interaction` (proto-JSON silently drops top-level extras).
|
||||
- **Interrupt:** `CancelCascadeSteps` (and `ForceStopCascadeTree`).
|
||||
- **Turn send (open):** `SendAgentMessage` records as a `SYSTEM_MESSAGE`, not `USER_INPUT` — so RPC turn-sending mis-attributes the user message. Turns therefore stay on tmux `send-keys` unless a proper user-turn RPC is found (see §7).
|
||||
|
||||
### 2.1 Timeout gotcha (critical)
|
||||
|
||||
A `WAITING` interaction **times out** server-side (→ `CORTEX_STEP_STATUS_ERROR`), after which agy **auto-retries with a fresh `WAITING` step at a higher `stepIndex`**. Omnigent elicitations wait on a human (potentially slow), so:
|
||||
|
||||
- The bridge must **re-read the freshest `WAITING` step at delivery time** — never trust the `trajectoryId/stepIndex` captured at detection.
|
||||
- On a timeout-retry, the bridge must **re-surface / update** the elicitation against the new step.
|
||||
- `HTTP 500 "input not registered for step N"` is **overloaded**: it means *either* a missing `trajectoryId` *or* a step that already timed out. Disambiguate by checking the step's `status` before treating it as a shape error.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
agy still runs in a runner-owned tmux terminal (terminal-first UX preserved). The transcript-tail reader and send-keys interaction path are replaced by RPC. New/changed units:
|
||||
|
||||
1. **RPC client** (`antigravity_native_rpc.py`, extended) — typed JSON wrappers: `get_trajectory_steps(port, cascade_id)`, `stream_agent_state_updates(port, conversation_id)`, `handle_user_interaction(port, cascade_id, interaction)`, `cancel_cascade_steps(port, cascade_id)`. Reuses the existing discovery/loopback/Heartbeat/GetConversationMetadata helpers.
|
||||
2. **Step → item mapper** (pure, unit-testable) — trajectory `step` → omnigent conversation-item events (`message`, `function_call`, `function_call_output`, status edges). Replaces `step_to_events` over JSONL. Because RPC steps are structured and carry stable ids + explicit `status`, this drops the JSONL parsing, the `forwarded_steps` SET cursor, the gap-free-prefix logic, and the out-of-order handling — and **fixes the double-render** (one structured assistant item per step; no delta-vs-committed race). It also **skips `USER_INPUT` steps**: the user turn is already persisted by the direct `POST /events` input (authoritative), so re-emitting it from the trajectory is the source of the **user-message duplication** — the mapper must not mirror it (mirrors claude-native).
|
||||
3. **Read driver** — polls `GetCascadeTrajectorySteps` (or consumes `StreamAgentStateUpdates`) and posts mapped items; dedup by `stepIndex`/step identity. Replaces the transcript-tail forwarder loop.
|
||||
4. **Interaction bridge** — on a `WAITING` step, surface an omnigent elicitation (reuse the existing registry / `response.elicitation_request` SSE / `/resolve` / web UI). On resolve, run the **tight detect→deliver loop**: re-read the freshest `WAITING` step, build the `interaction` (`askQuestion` or `permission`), POST `HandleCascadeUserInteraction`; handle timeout/re-ask.
|
||||
5. **Executor** — `run_turn` keeps tmux `send-keys` for turns (§7); `interrupt_session` → `CancelCascadeSteps` (real interrupt).
|
||||
6. **Reused from #892 unchanged** — onboarding/agy-auth + Gemini provider, harness registration/aliases, the runner-owned terminal infra + auto-create + reattach fixes, the Docker agy-version pin, the web picker/agent card, model catalog/override wiring.
|
||||
|
||||
## 4. Data flows
|
||||
|
||||
- **Assistant output (read):** read driver polls/streams steps → mapper → post `message`/`function_call`/`function_call_output` + status edges. Single structured item per step (no double-render).
|
||||
- **User turn (write):** executor `send-keys` types the turn into agy's TUI (records as `USER_INPUT`). *(Unchanged from #892 until §7 resolves.)*
|
||||
- **Interaction (question/approval):** read driver sees a `WAITING` step → interaction bridge surfaces an elicitation → user resolves in the web UI → bridge re-reads the freshest `WAITING` step and POSTs `HandleCascadeUserInteraction` → agy proceeds (step → `DONE`, `completedInteractions.response` echoes the answer).
|
||||
- **Interrupt:** `interrupt_session` → `CancelCascadeSteps {cascadeId}`.
|
||||
|
||||
## 5. What is removed
|
||||
|
||||
- JSONL transcript tailing + partial-line buffering + UTF-8 hold-back.
|
||||
- The durable `forwarded_steps` SET cursor, gap-free-prefix delivery, out-of-order suppression, the `<=`-floor legacy materialization.
|
||||
- The delta (`output_text_delta`) + committed-message double-emission (source of the live double-render).
|
||||
- The forwarder mirroring of `USER_INPUT` that duplicated the direct `/events` user post.
|
||||
- tmux `send-keys` for *interactions* (kept only for turns, pending §7).
|
||||
|
||||
## 6. What is reused (from #892)
|
||||
|
||||
Onboarding/auth, Gemini provider config, harness registration/aliases, runner-owned terminal + auto-create + the reattach/no-double-forward fixes, the Docker `AGY_EXPECTED_VERSION` pin, the web Antigravity picker/agent card, model catalog/override/effort wiring. The three review fixes already committed on the branch (`1cd8f5aa`, `874f8f5c`, `708ee883`) stay relevant (terminal/launch infra + test hygiene).
|
||||
|
||||
## 7. Open questions (resolve in the plan)
|
||||
|
||||
1. **Turn send.** Keep tmux `send-keys` (proven, records `USER_INPUT`) vs. find a proper user-turn RPC (the obvious `SendAgentMessage` mis-records as `SYSTEM_MESSAGE`). Default: keep send-keys; small spike to look for a user-turn RPC (e.g. a queued-user-input method).
|
||||
2. **Poll vs stream for read.** `StreamAgentStateUpdates` (live, lower latency, connect-stream framing) vs `GetCascadeTrajectorySteps` polling (simpler). Likely stream with poll fallback.
|
||||
3. **Elicitation ↔ agy-timeout reconciliation.** Concrete policy for re-surfacing on timeout-retry and for the deny/cancel path (`permission.allow=false`; multi-select; write-in).
|
||||
4. **#892 packaging.** Evolve the existing branch (reuse periphery + fixes) vs a fresh PR cherry-picking the periphery. Lean: evolve the branch; keep it draft until the RPC core lands.
|
||||
|
||||
## 8. Testing
|
||||
|
||||
- **Unit:** step→item mapper from recorded RPC step fixtures (question, approval, run_command, planner, tool-output); interaction-builder shapes.
|
||||
- **Integration (live agy):** question round-trip, approval round-trip, interrupt — mirroring the spikes (assert `200 {}`, step→`DONE`, `completedInteractions.response`, trajectory growth / command output).
|
||||
- **Timeout handling:** simulate a timed-out `WAITING` step (status `ERROR`) → bridge re-reads and delivers to the retry step; assert no spurious 500 propagation.
|
||||
- **Parity / regression:** adapt the existing native-harness suites; confirm the double-render and user-dup are gone (persisted + live single render).
|
||||
|
||||
## 9. Risks
|
||||
|
||||
- agy RPC is undocumented/unstable across versions — mitigated by the Docker version pin and the version-gated build.
|
||||
- Port discovery timing (agy must be up + bound) — reuse the existing discovery + retry.
|
||||
- The timeout gotcha (§2.1) is the main correctness-sensitive area — covered by the tight detect→deliver loop + tests.
|
||||
- Terminal-first UX: agy still runs in the tmux terminal, so the TUI and RPC both drive the same cascade (verified compatible — TUI and RPC interaction delivery coexist).
|
||||
|
||||
## 10. Phase 2 — Full RPC parity with codex/claude (live-verified, agy 1.0.10)
|
||||
|
||||
Spikes (`/tmp/agy-turnsend-spike.md`, `/tmp/agy-parity-feasibility.md`; memory `agy-rpc-interaction-bridge`, `agy-rpc-parity-streaming`) confirmed agy exposes everything needed to reach full codex/claude parity over RPC. This resolves §7 open question 1 (turn-send) and adds streaming + telemetry. **All shapes version-volatile — resolve model enums at runtime, never hardcode.**
|
||||
|
||||
### 10.1 Turn-send (resolves §7-Q1; replaces tmux send-keys)
|
||||
`SendUserCascadeMessage {cascadeId, items:[{text:<turn>}], cascadeConfig:{plannerConfig:{planModel:<MODEL enum>}}}` → `200 {}`; records `CORTEX_STEP_TYPE_USER_INPUT` + `metadata.source==CORTEX_STEP_SOURCE_USER_EXPLICIT` + `userInput.userResponse==<text>` (byte-for-byte what the reader keys on). Gotchas: text in `items[].text` (NOT flat `message`); model REQUIRED per-turn in `cascadeConfig.plannerConfig` (omit → ERROR "neither PlanModel nor RequestedModel specified"). Resolve `planModel` at runtime via `GetAvailableModels {}` or by echoing the read-side `userInput.userConfig.plannerConfig.requestedModel.model`. `SendAgentMessage`=SYSTEM_MESSAGE (wrong); queue methods retired.
|
||||
|
||||
### 10.2 Streaming deltas (live typing — output_text_delta parity)
|
||||
`StreamAgentStateUpdates` connect server-stream. Request body MUST be connect-enveloped: `[flag=0x00][BE-uint32 len][{"conversationId":<conv>}]`, header `Content-Type: application/connect+json`. Response frames `[flag][BE-len][json]`: flag 0=data, flag 2=trailer.
|
||||
- Partial text path: `update.mainTrajectoryUpdate.stepsUpdate.steps[]` where `type==PLANNER_RESPONSE`, at **`plannerResponse.modifiedResponse`** — GROWS across frames while `status==CORTEX_STEP_STATUS_GENERATING`. `plannerResponse.thinking` streams first (reasoning). **Trap:** `plannerResponse.response` is ABSENT during generation, populated only on the DONE commit (where `response==modifiedResponse`). (This validates Task 4's `modifiedResponse` preference.)
|
||||
- Discriminator = step `status`: GENERATING ⇒ partial (emit `output_text_delta` = `modifiedResponse` minus last forwarded prefix, keyed by `metadata.sourceTrajectoryStepInfo.stepIndex`); DONE ⇒ final committed `message` (from `response`; `metadata.modelUsage` set). Frames are CUMULATIVE snapshots → harness owns prefix-diffing. On connect, a snapshot of prior steps replays (DONE) → dedup against already-forwarded committed steps.
|
||||
- Reconciler: deltas precede the committed item (flush-barrier style, like codex) so the SPA retires the live preview rather than double-rendering. Keep the unary `GetCascadeTrajectorySteps` poll as snapshot/reconnect fallback (same `plannerResponse` shape).
|
||||
|
||||
### 10.3 Token usage (external_session_usage parity)
|
||||
Per model call: `step.metadata.modelUsage = {model, inputTokens, outputTokens, thinkingOutputTokens, responseOutputTokens, cacheReadTokens}` (string ints; output=thinking+response). Cumulative context estimate (monotonic): `trajectory.generatorMetadata[].chatModel.chatStartMetadata.contextWindowMetadata.estimatedTokensUsed`. No top-level usage RPC. Per-turn = sum `modelUsage` over the turn's steps; emit as each PLANNER step hits DONE.
|
||||
|
||||
### 10.4 Model / effort (external_model_change parity)
|
||||
Current model per-turn: `step.userInput.userConfig.plannerConfig.requestedModel.model` (+ `step.metadata.{generatorModel, modelUsage.model}`). Enum→displayName via `GetAvailableModels {}` → `response.models[key].{model, displayName, recommended, supportsThinking, thinkingBudget}`. Effort is NOT separate — it's encoded in the model enum (Gemini: M20/M132/M187 = Medium/High/Low) or a Thinking variant (Claude: M26 = Opus 4.6 Thinking). Detect change by diffing the per-turn enum vs previous (no "model-changed" step). LIVE-CONFIRMED: TUI /model switch flips the next turn's `requestedModel`/`generatorModel`.
|
||||
|
||||
### 10.5 New-conversation rotation (/clear → session rotation parity)
|
||||
TUI `/clear` creates a NEW cascadeId/brain-dir UUID immediately; old conv retained (still reachable), not deleted. Detect via `GetAllCascadeTrajectories {}` → `trajectorySummaries{<conv>:{status, stepCount, trajectoryId, lastUserInputTime, lastModifiedTime, lastUserInputStepIndex}}` (a new convId key, or the bound conv going stale while a sibling advances); each stream frame's `update.conversationId` also names the active conv. On rotation, re-discover the active conv (max `lastUserInputTime`) and rotate the omnigent session (mirrors codex `thread/started`). `StartCascade` is the programmatic cold-start (not needed for human /clear).
|
||||
|
||||
### 10.6 Phase-2 task plan
|
||||
- **T-A** RPC client: `send_user_cascade_message` + model resolution (`get_available_models`, resolve current enum). **T-B** executor `run_turn` → RPC turn-send (drop send-keys).
|
||||
- **T-C** RPC client: `stream_agent_state_updates` (connect-stream framing). **T-D** reader streaming mode: deltas (modifiedResponse prefix-diff + thinking) + DONE commit + snapshot dedup + poll fallback.
|
||||
- **T-E** usage events, **T-F** model-change events, **T-G** /clear rotation — reader additions (read from step.metadata / GetAllCascadeTrajectories).
|
||||
- Integrates with Task 10 (interrupt+T-B), Task 11 (runner wiring of streaming reader+bridge+rotation), Task 13 (e2e parity: streaming single-render, usage, model-change, rotation, turn-send).
|
||||
@@ -0,0 +1,266 @@
|
||||
# Antigravity-native RPC Core Rework — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Rework the antigravity-native harness runtime onto agy's connect-RPC surface — structured trajectory-step reads, interaction bridging (questions + approvals), and real interrupt — replacing the JSONL transcript-tail + send-keys-interaction core.
|
||||
|
||||
**Architecture:** agy keeps running in a runner-owned tmux terminal (terminal-first UX). A new RPC client (extending `antigravity_native_rpc.py`) drives: read via `GetCascadeTrajectorySteps`/`StreamAgentStateUpdates`, interactions via `HandleCascadeUserInteraction` (surfaced as omnigent elicitations), and interrupt via `CancelCascadeSteps`. A pure step→item mapper replaces `step_to_events`. Turn-send stays on tmux send-keys (pending a confirmed user-turn RPC). The transcript forwarder + durable cursor are retired.
|
||||
|
||||
**Tech Stack:** Python 3.13+ (uv), httpx (connect-RPC over HTTP/2, JSON), pytest, existing omnigent server elicitation registry + SSE, agy 1.0.10.
|
||||
|
||||
**Design spec:** `docs/antigravity-native-rpc-core-design.md`. **Verified wire shapes:** memory `agy-rpc-interaction-bridge.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Python deps via `uv` only (never pip); JS/TS via `bun`. Latest stable deps.
|
||||
- Pre-commit gate (must pass): `uv run ruff check --fix && uv run ruff format && uv run mypy --strict . && uv run pytest`. Never disable a lint/type rule — fix the root cause.
|
||||
- agy pinned: `AGY_EXPECTED_VERSION=1.0.10` (Docker build fails on mismatch). All RPC shapes are version-sensitive.
|
||||
- connect-RPC: JSON (`Content-Type: application/json`), `verify=False`, every URL passes `_assert_loopback_url`. Reuse `antigravity_native_rpc.py` discovery (`discover_language_server_port` / `_candidate_agy_rpc_ports` / `_conversation_matches`).
|
||||
- Identity: `cascadeId == conversationId == brain-dir UUID` (no separate id lookup).
|
||||
- Interaction envelope: `HandleCascadeUserInteraction {cascadeId, interaction:{trajectoryId, stepIndex, <variant>}}` — `trajectoryId`+`stepIndex` MUST be inside `interaction`. Question variant `askQuestion.responses:[{question, selectedOptionIds:[id]}]`; approval variant `permission:{allow:bool}`.
|
||||
- Timeout gotcha: `WAITING` interactions expire (→ `ERROR`, agy auto-retries at a higher `stepIndex`). Always re-read the freshest `WAITING` step at delivery; `500 "input not registered"` ⇒ missing `trajectoryId` **or** a stale step — check `status` first.
|
||||
- Frequent commits; TDD (red→green) per task; commit messages end with the `Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>` trailer.
|
||||
|
||||
## File Structure
|
||||
|
||||
- `omnigent/antigravity_native_rpc.py` (modify) — add interaction/step/cancel RPC methods alongside the existing discovery helpers.
|
||||
- `omnigent/antigravity_native_steps.py` (create) — pure step→item mapper (replaces the mapping half of `step_to_events`) + the `WAITING`-interaction extractor.
|
||||
- `omnigent/antigravity_native_reader.py` (create) — read driver: poll/stream trajectory steps → post items; owns dedup + the interaction-bridge loop.
|
||||
- `omnigent/antigravity_native_interactions.py` (create) — interaction bridge: detect→elicitation→deliver, with the timeout/re-read loop.
|
||||
- `omnigent/server/routes/_antigravity_elicitation.py` (create) — parse an agy `WAITING` interaction → `ElicitationRequestParams`, and map an `ElicitationResult` back to an `interaction` payload (mirrors `_codex_elicitation.py`).
|
||||
- `omnigent/inner/antigravity_native_executor.py` (modify) — `interrupt_session` → `CancelCascadeSteps`; (turns unchanged pending Task 1).
|
||||
- `omnigent/runner/app.py` (modify) — auto-create the RPC reader instead of (or alongside, during cutover) the transcript forwarder.
|
||||
- `omnigent/antigravity_native_forwarder.py` (delete at cutover, Task 12).
|
||||
- Tests mirror each module under `tests/`.
|
||||
|
||||
---
|
||||
|
||||
### Task 1 (Spike): Confirm turn-send + read-mode, enumerate step types
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/claude/antigravity-rpc-spike-notes.md` (findings)
|
||||
- Create: `tests/fixtures/antigravity/steps/*.json` (recorded trajectory-step fixtures)
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: the recorded step fixtures (one per agy step type) consumed by Tasks 4–5; decisions for Tasks 2 (turn-send), 6 (poll-vs-stream).
|
||||
|
||||
- [ ] **Step 1:** Stand up a live agy on the running omnigent (`:6767`, HOST_ID `host_1b3116a52bf74d668d3179652c54cdc7`, `HOME=/Users/bryanli`) per memory `agy-rpc-interaction-bridge.md`; discover the RPC port via `discover_language_server_port`.
|
||||
- [ ] **Step 2:** Drive turns/tools/questions to exercise each step type; save each distinct `GetCascadeTrajectorySteps` step (USER_INPUT, PLANNER_RESPONSE w/ text, PLANNER_RESPONSE w/ tool_calls incl. `ask_question`, RUN_COMMAND WAITING + DONE, other MODEL/result steps, status edges) as a fixture JSON.
|
||||
- [ ] **Step 3:** Determine turn-send: check for a user-turn RPC (e.g. a queued-user-input / `SendAllQueuedMessages` path) that records as `USER_INPUT` (not `SYSTEM_MESSAGE`). Record verdict: RPC turn-send viable, or keep tmux send-keys.
|
||||
- [ ] **Step 4:** Determine read-mode: exercise `StreamAgentStateUpdates {conversationId}` (connect server-stream framing) vs `GetCascadeTrajectorySteps` polling; record latency + reliability; pick the default (likely stream + poll fallback).
|
||||
- [ ] **Step 5:** Write findings + decisions to the notes doc; commit fixtures + notes. **Acceptance:** every step type has a fixture; turn-send + read-mode decisions are recorded with evidence.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: RPC client — trajectory steps + cancel (unary)
|
||||
|
||||
**Files:**
|
||||
- Modify: `omnigent/antigravity_native_rpc.py`
|
||||
- Test: `tests/test_antigravity_native_rpc.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `get_trajectory_steps(port:int, cascade_id:str) -> list[dict]`; `cancel_cascade_steps(port:int, cascade_id:str) -> bool`. Both reuse `_rpc_url`/`_sync_client`/`_assert_loopback_url`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** (MockTransport, mirroring `test_conversation_matches_*`):
|
||||
|
||||
```python
|
||||
def test_get_trajectory_steps_posts_cascade_id_and_returns_steps(monkeypatch):
|
||||
seen = {}
|
||||
def handler(req):
|
||||
seen["url"] = str(req.url); seen["body"] = req.content
|
||||
return httpx.Response(200, json={"steps": [{"stepIndex": 0, "status": "CORTEX_STEP_STATUS_DONE"}]})
|
||||
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(handler))
|
||||
steps = rpc.get_trajectory_steps(52548, "conv-uuid")
|
||||
assert seen["url"].endswith("/LanguageServerService/GetCascadeTrajectorySteps")
|
||||
assert json.loads(seen["body"]) == {"cascadeId": "conv-uuid"}
|
||||
assert steps[0]["stepIndex"] == 0
|
||||
|
||||
def test_cancel_cascade_steps_true_on_200(monkeypatch):
|
||||
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(lambda r: httpx.Response(200, json={})))
|
||||
assert rpc.cancel_cascade_steps(52548, "conv-uuid") is True
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify FAIL** — `uv run pytest tests/test_antigravity_native_rpc.py -k "trajectory_steps or cancel_cascade" -v` → fail (undefined).
|
||||
- [ ] **Step 3: Implement** `get_trajectory_steps` (POST `{"cascadeId": cascade_id}` to `GetCascadeTrajectorySteps`, parse `.get("steps", [])`) and `cancel_cascade_steps` (POST `{"cascadeId": cascade_id}` to `CancelCascadeSteps`, return `resp.status_code < 400`), both via `_sync_client` + `_assert_loopback_url`, mirroring `_conversation_matches`.
|
||||
- [ ] **Step 4: Run, verify PASS.**
|
||||
- [ ] **Step 5: Commit** (`feat(antigravity-native): RPC client — trajectory steps + cancel`).
|
||||
|
||||
---
|
||||
|
||||
### Task 3: RPC client — `handle_user_interaction` (unary)
|
||||
|
||||
**Files:** Modify `omnigent/antigravity_native_rpc.py`; Test `tests/test_antigravity_native_rpc.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `handle_user_interaction(port:int, cascade_id:str, *, trajectory_id:str, step_index:int, payload:dict) -> None` (raises `AntigravityRpcError` on non-200, carrying the body so callers can detect the overloaded `"input not registered"`). `payload` is the variant dict, e.g. `{"askQuestion": {...}}` or `{"permission": {"allow": True}}`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests:**
|
||||
|
||||
```python
|
||||
def test_handle_user_interaction_nests_traj_and_step_inside_interaction(monkeypatch):
|
||||
seen = {}
|
||||
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(
|
||||
lambda r: seen.setdefault("body", r.content) and None or httpx.Response(200, json={})))
|
||||
rpc.handle_user_interaction(52548, "c", trajectory_id="t", step_index=14,
|
||||
payload={"permission": {"allow": True}})
|
||||
assert json.loads(seen["body"]) == {
|
||||
"cascadeId": "c",
|
||||
"interaction": {"trajectoryId": "t", "stepIndex": 14, "permission": {"allow": True}}}
|
||||
|
||||
def test_handle_user_interaction_raises_on_500(monkeypatch):
|
||||
monkeypatch.setattr(rpc, "_HTTP_TRANSPORT", httpx.MockTransport(
|
||||
lambda r: httpx.Response(500, json={"message": "input not registered for step 14"})))
|
||||
with pytest.raises(rpc.AntigravityRpcError) as ei:
|
||||
rpc.handle_user_interaction(52548, "c", trajectory_id="t", step_index=14, payload={"permission": {"allow": True}})
|
||||
assert "input not registered" in str(ei.value)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run, verify FAIL.**
|
||||
- [ ] **Step 3: Implement** `AntigravityRpcError(Exception)` (if absent) and `handle_user_interaction`: build `{"cascadeId": cascade_id, "interaction": {"trajectoryId": trajectory_id, "stepIndex": step_index, **payload}}`, POST to `HandleCascadeUserInteraction`; on `>= 400` raise `AntigravityRpcError(body_text)`.
|
||||
- [ ] **Step 4: Run, verify PASS.**
|
||||
- [ ] **Step 5: Commit** (`feat(antigravity-native): RPC client — handle_user_interaction`).
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Step→item mapper — assistant text, tool calls, results, status
|
||||
|
||||
**Files:** Create `omnigent/antigravity_native_steps.py`; Test `tests/test_antigravity_native_steps.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 1 step fixtures.
|
||||
- Produces: `map_step_to_events(step:dict, *, conversation_id:str, allocator:_ToolCallIdAllocator) -> list[OutboundEvent]` (reuses `OutboundEvent` + `_ToolCallIdAllocator` from the forwarder module, to be relocated here at cutover). Skips `USER_INPUT` steps. Emits exactly one `message` per assistant text step (NO delta).
|
||||
|
||||
- [ ] **Step 1: Write failing tests** against the fixtures: assert PLANNER_RESPONSE text → one `external_conversation_item` `message` (role assistant, single `output_text`, **no** `output_text_delta`); USER_INPUT → `[]` (skipped — fixes user-dup); RUN_COMMAND DONE → `function_call` + `function_call_output` with `runCommand.combinedOutput.full`; status edges (RUNNING/IDLE) emitted per the verified `status` field. (Use the exact assertions from the recorded fixtures.)
|
||||
- [ ] **Step 2: Run, verify FAIL.**
|
||||
- [ ] **Step 3: Implement** `map_step_to_events` per the fixtures (port the non-delta logic from `step_to_events`; drop the delta emission and the USER_INPUT mirror; key tool-call ids via the allocator).
|
||||
- [ ] **Step 4: Run, verify PASS.**
|
||||
- [ ] **Step 5: Commit** (`feat(antigravity-native): pure step→item mapper (no delta, skip USER_INPUT)`).
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Mapper — `WAITING` interaction extractor
|
||||
|
||||
**Files:** Modify `omnigent/antigravity_native_steps.py`; Test `tests/test_antigravity_native_steps.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `pending_interaction(step:dict) -> PendingInteraction | None` where `PendingInteraction` = `{kind: "ask_question"|"permission", trajectory_id, step_index, spec: dict}` (spec = the `askQuestion`/`permission` block + `runCommand`/question text). Returns `None` unless `status == CORTEX_STEP_STATUS_WAITING`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** from the WAITING fixtures: ask_question WAITING → `kind=="ask_question"`, `spec.questions[].options[].{id,text}`, `trajectory_id`/`step_index` from `metadata.sourceTrajectoryStepInfo`; run_command WAITING → `kind=="permission"`, `spec.resource.{action,target}`; DONE step → `None`.
|
||||
- [ ] **Step 2–4:** Run FAIL → implement `pending_interaction` (read `requestedInteraction`, branch on `askQuestion`/`permission`, pull ids from `metadata.sourceTrajectoryStepInfo`) → run PASS.
|
||||
- [ ] **Step 5: Commit** (`feat(antigravity-native): WAITING-interaction extractor`).
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Read driver — poll/stream steps → post items
|
||||
|
||||
**Files:** Create `omnigent/antigravity_native_reader.py`; Test `tests/test_antigravity_native_reader.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `get_trajectory_steps` (Task 2), `map_step_to_events` (Task 4), `pending_interaction` (Task 5), `_native_post_delivery.post_session_event_with_retry`.
|
||||
- Produces: `async supervise_reader(bridge_dir, session_id, *, client, poll_interval_s=...) -> None` — discovers the port, loops reading steps, maps + posts new ones (dedup by `stepIndex` identity), and hands `WAITING` steps to the interaction bridge (Task 8). Read-mode (poll vs `StreamAgentStateUpdates`) per Task 1 Step 4.
|
||||
|
||||
- [ ] **Step 1: Write failing tests** with a fake step source (monkeypatch `get_trajectory_steps` to return a scripted sequence) + a captured post sink: assert each new step posts exactly once (re-reads dedup), USER_INPUT posts nothing, and a WAITING step invokes the bridge callback once.
|
||||
- [ ] **Step 2–4:** Run FAIL → implement the loop (dedup set over `(trajectoryId, stepIndex)`; call the bridge for WAITING) → run PASS.
|
||||
- [ ] **Step 5: Commit** (`feat(antigravity-native): RPC read driver`).
|
||||
|
||||
---
|
||||
|
||||
### Task 7: Server — agy elicitation adapter
|
||||
|
||||
**Files:** Create `omnigent/server/routes/_antigravity_elicitation.py`; Test `tests/server/test_antigravity_elicitation.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Produces: `to_elicitation_params(pending:dict) -> ElicitationRequestParams` (ask_question → form with the options; permission → accept/decline with the command preview); `to_interaction_payload(kind:str, result:ElicitationResult, spec:dict) -> dict` (form/accept → `{"askQuestion": {"responses": [...]}}` or `{"permission": {"allow": True}}`; decline/cancel → `{"permission": {"allow": False}}`). Mirrors `_codex_elicitation.py`.
|
||||
|
||||
- [ ] **Step 1: Write failing tests:** ask_question pending → form params with each option; an `ElicitationResult(action="accept", content={"selectedOptionIds":["2"]})` → `{"askQuestion":{"responses":[{"question":..., "selectedOptionIds":["2"]}]}}`. permission pending → accept/decline params; accept → `{"permission":{"allow":True}}`, decline → `{"permission":{"allow":False}}`.
|
||||
- [ ] **Step 2–4:** Run FAIL → implement both mappers → run PASS.
|
||||
- [ ] **Step 5: Commit** (`feat(server): antigravity elicitation adapter`).
|
||||
|
||||
---
|
||||
|
||||
### Task 8: Interaction bridge — detect → elicit → deliver (timeout loop)
|
||||
|
||||
**Files:** Create `omnigent/antigravity_native_interactions.py`; Test `tests/test_antigravity_native_interactions.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `pending_interaction` (Task 5), `handle_user_interaction` (Task 3), the elicitation adapter (Task 7), the server hook/registry (`_publish_and_wait_for_harness_elicitation` via a new `POST /v1/sessions/{id}/hooks/antigravity-elicitation-request`, mirroring codex).
|
||||
- Produces: `async bridge_interaction(port, cascade_id, pending, *, client, get_steps) -> None` — publishes the elicitation (deterministic id `(cascade_id, trajectory_id, step_index)`), awaits the resolution (long-poll), then **re-reads the freshest WAITING step** and POSTs `handle_user_interaction`; on the overloaded `"input not registered"` (step timed out → status ERROR), re-reads and re-publishes against the retry step.
|
||||
|
||||
- [ ] **Step 1: Write failing tests:** (a) happy path — pending question → elicitation published → resolved("2") → `handle_user_interaction` called with the freshest step's `{trajectoryId, stepIndex}` and `askQuestion.responses[0].selectedOptionIds==["2"]`. (b) timeout path — first delivery raises `AntigravityRpcError("input not registered")` while `get_steps` now shows a NEW WAITING step at a higher index → bridge re-reads and re-delivers to the new step (assert second call uses the new `stepIndex`). (c) permission accept → `{"permission":{"allow":True}}`.
|
||||
- [ ] **Step 2–4:** Run FAIL → implement the detect→elicit→deliver loop with the re-read-on-stale handling (check step `status` to disambiguate the 500) → run PASS.
|
||||
- [ ] **Step 5: Commit** (`feat(antigravity-native): interaction bridge with timeout re-read`).
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Server — antigravity elicitation hook endpoint
|
||||
|
||||
**Files:** Modify `omnigent/server/routes/sessions.py`; Test `tests/server/test_app.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: Task 7 adapter, `_publish_and_wait_for_harness_elicitation`.
|
||||
- Produces: `POST /v1/sessions/{id}/hooks/antigravity-elicitation-request` — body = the pending-interaction dict; parks + awaits the verdict (deterministic id), returns the `ElicitationResult` (or 200 empty on timeout). Mirrors `POST /hooks/codex-elicitation-request`.
|
||||
|
||||
- [ ] **Step 1–4:** TDD: failing route test (post a pending question, resolve via `/elicitations/{eid}/resolve`, assert the hook returns the verdict) → implement the route → pass.
|
||||
- [ ] **Step 5: Commit** (`feat(server): antigravity elicitation hook endpoint`).
|
||||
|
||||
---
|
||||
|
||||
### Task 10: Executor — real interrupt via `CancelCascadeSteps`
|
||||
|
||||
**Files:** Modify `omnigent/inner/antigravity_native_executor.py`; Test `tests/inner/test_antigravity_native_executor.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `cancel_cascade_steps` (Task 2), port discovery, bridge state (cascade id).
|
||||
- Produces: `interrupt_session` returns `True` after a successful `CancelCascadeSteps`.
|
||||
|
||||
- [ ] **Step 1: Write failing test:** `interrupt_session` (monkeypatched `cancel_cascade_steps` → True) returns `True`; on RPC error returns `False`.
|
||||
- [ ] **Step 2–4:** Run FAIL → implement (discover port from bridge state's conversation id, call `cancel_cascade_steps`) → run PASS.
|
||||
- [ ] **Step 5: Commit** (`feat(antigravity-native): real interrupt via CancelCascadeSteps`).
|
||||
|
||||
---
|
||||
|
||||
### Task 11: Runner — auto-create the RPC reader
|
||||
|
||||
**Files:** Modify `omnigent/runner/app.py`; Test `tests/runtime/.../test_*`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `supervise_reader` (Task 6) + the interaction bridge (Task 8).
|
||||
- Produces: the runner's antigravity auto-create starts the RPC reader (+ bridge) instead of the transcript forwarder. Single reader per session (same task-registry guard as the old forwarder).
|
||||
|
||||
- [ ] **Step 1: Write failing test:** auto-create starts the reader task (not the forwarder); reattach/cleanup semantics preserved (mirror the existing forwarder-task tests).
|
||||
- [ ] **Step 2–4:** Run FAIL → swap the forwarder spawn for the reader spawn → run PASS.
|
||||
- [ ] **Step 5: Commit** (`feat(antigravity-native): runner auto-creates RPC reader`).
|
||||
|
||||
---
|
||||
|
||||
### Task 12: Cutover — retire the transcript forwarder + durable cursor
|
||||
|
||||
**Files:** Delete `omnigent/antigravity_native_forwarder.py`; Modify `omnigent/antigravity_native_bridge.py` (drop `forwarded_steps`/cursor), `omnigent/inner/antigravity_native_executor.py` (drop send-keys-only-for-interactions notes), references; relocate `OutboundEvent`/`_ToolCallIdAllocator` into `antigravity_native_steps.py`; delete `tests/test_antigravity_native_forwarder.py`.
|
||||
|
||||
**Interfaces:** None new — removes dead code once the reader path is green.
|
||||
|
||||
- [ ] **Step 1:** Grep for `antigravity_native_forwarder` / `forwarded_steps` / `update_forwarded_steps` references; confirm only the reader path remains.
|
||||
- [ ] **Step 2:** Delete the forwarder module + its tests; remove the cursor fields/methods from the bridge; relocate the shared types.
|
||||
- [ ] **Step 3:** Run the full gate: `uv run ruff check --fix && uv run ruff format && uv run mypy --strict . && uv run pytest` (targeted antigravity suites + server).
|
||||
- [ ] **Step 4:** Commit (`refactor(antigravity-native): retire transcript forwarder + durable cursor (RPC reader supersedes)`).
|
||||
|
||||
---
|
||||
|
||||
### Task 13: Live end-to-end parity verification
|
||||
|
||||
**Files:** Create `tests/e2e/antigravity_native_rpc_e2e.py` (or a documented manual script) — gated on a live agy.
|
||||
|
||||
- [ ] **Step 1:** Live run on `:6767`: a text turn renders **once** (no double-render); the user prompt appears **once** (no dup) — both live and after refresh.
|
||||
- [ ] **Step 2:** A question turn surfaces a web elicitation; resolving it answers agy (trajectory proceeds).
|
||||
- [ ] **Step 3:** A tool turn surfaces an approval elicitation; accepting runs the command (output mirrored); declining blocks it.
|
||||
- [ ] **Step 4:** Interrupt mid-turn cancels the cascade.
|
||||
- [ ] **Step 5:** Record results; commit the script/notes (`test(antigravity-native): live RPC parity verification`).
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** read path (Tasks 4,6), interactions (Tasks 5,7,8,9), interrupt (Task 10), removal of forwarder/cursor/delta/user-dup (Tasks 4,12), reuse of periphery (unchanged), turn-send decision (Task 1), timeout gotcha (Task 8), poll-vs-stream (Tasks 1,6). Covered.
|
||||
- **Open questions → tasks:** turn-send RPC = Task 1/2; poll-vs-stream = Task 1/6; elicitation↔timeout = Task 8; #892 packaging = process decision (evolve branch; out of code scope).
|
||||
- **Type consistency:** `handle_user_interaction(payload=variant dict)` (Task 3) ↔ `to_interaction_payload` returns that variant dict (Task 7) ↔ bridge passes it (Task 8). `pending_interaction` shape (Task 5) ↔ consumed by reader (Task 6) + adapter (Task 7) + bridge (Task 8). Consistent.
|
||||
- **Placeholders:** Tasks 1, 4–6, 9, 11 reference recorded fixtures/exact assertions to be filled from Task 1's fixtures (a real dependency, not a placeholder); all RPC shapes are concrete from the spec/memory.
|
||||
@@ -0,0 +1,125 @@
|
||||
# Antigravity-native RPC core — spike notes (Task 1)
|
||||
|
||||
**Date:** 2026-06-22
|
||||
**agy version:** 1.0.10 (`/Users/bryanli/.local/bin/agy --version` → `1.0.10`)
|
||||
**Host:** standalone attended agy in a dedicated tmux session `agy-spike` (no `--dangerously-skip-permissions`), launched with `HOME=/Users/bryanli`. NOT the `:6767` omnigent; fully isolated from the `rdv-*` sessions.
|
||||
**Conversation captured:** `2399249c-4a48-40f1-bf3b-4c6e5d3a5a0e` (= cascadeId = brain-dir UUID).
|
||||
**RPC port discovered:** `53485` via `discover_language_server_port(17262)` (PID 17262), confirmed by `_conversation_matches(port, conv) → True`.
|
||||
|
||||
This task adds **fixtures + this notes doc only** (no production code). It records, with live evidence:
|
||||
1. the distinct `GetCascadeTrajectorySteps` step shapes Tasks 4/5 will map and assert on (saved under `tests/fixtures/antigravity/steps/`);
|
||||
2. the **turn-send** verdict (Step 3);
|
||||
3. the **read-mode** verdict + latency/reliability (Step 4).
|
||||
|
||||
All shapes here were captured **verbatim from the live RPC** (re-serialized pretty-printed; no content edits) unless explicitly labelled synthesized.
|
||||
|
||||
---
|
||||
|
||||
## 1. Fixtures captured
|
||||
|
||||
All fixtures are the single `steps[]` element as returned by
|
||||
`POST .../LanguageServerService/GetCascadeTrajectorySteps` with request body
|
||||
`{"cascadeId": "<conv>"}` (Content-Type `application/json`, `verify=False`).
|
||||
|
||||
| Fixture file | `type` | `status` | Live? | What Tasks 4/5 assert on |
|
||||
|---|---|---|---|---|
|
||||
| `user_input.json` | `CORTEX_STEP_TYPE_USER_INPUT` | `DONE` | live | `userInput.userResponse`, `userInput.items[].text`, `metadata.source = CORTEX_STEP_SOURCE_USER_EXPLICIT`. **The mapper SKIPS this** (user turn already persisted by `/events`). NB: `metadata.sourceTrajectoryStepInfo.stepIndex` is **absent** here (step 0 → proto omits the zero default; treat missing as 0). |
|
||||
| `conversation_history.json` | `CORTEX_STEP_TYPE_CONVERSATION_HISTORY` | `DONE` | live | system step, `conversationHistory: {}` — mapper skips (non-renderable). |
|
||||
| `planner_response_text.json` | `CORTEX_STEP_TYPE_PLANNER_RESPONSE` | `DONE` | live | `plannerResponse.response` + `plannerResponse.modifiedResponse` (assistant text), `plannerResponse.messageId`, `plannerResponse.stopReason`. Carries a large `plannerResponse.thinkingSignature` (opaque; ignore). → `message` item. |
|
||||
| `planner_response_tool_call_ask_question.json` | `CORTEX_STEP_TYPE_PLANNER_RESPONSE` | `DONE` | live | `plannerResponse.toolCalls[].{id, name:"ask_question", argumentsJson}` (+ optional `plannerResponse.thinking`). The tool-call carrier → `function_call` item. |
|
||||
| `planner_response_tool_call_run_command.json` | `CORTEX_STEP_TYPE_PLANNER_RESPONSE` | `DONE` | live | `plannerResponse.toolCalls[].{id, name:"run_command", argumentsJson}` — distinct tool-call variant. |
|
||||
| `run_command_waiting.json` | `CORTEX_STEP_TYPE_RUN_COMMAND` | `WAITING` | live | **permission-pending shape**: `requestedInteraction.permission.{resource.{action:"command", target:"pwd"}, persistSuggestionType, suggestedPersistPattern, actionDescription}`; `runCommand.{commandLine, proposedCommandLine, cwd, blocking, waitMsBeforeAsync}` (no `exitCode` yet); `metadata.sourceTrajectoryStepInfo.{trajectoryId, stepIndex}`. |
|
||||
| `run_command_done.json` | `CORTEX_STEP_TYPE_RUN_COMMAND` | `DONE` | live | `runCommand.exitCode` (0), `runCommand.combinedOutput.full`, `runCommand.{commandLine, proposedCommandLine, cwd}`; `completedInteractions[].request.permission.resource.{action,target}` + `completedInteractions[].response = {trajectoryId, stepIndex, permission:{allow:true}}`. |
|
||||
| `ask_question_waiting.json` | `CORTEX_STEP_TYPE_ASK_QUESTION` | `WAITING` | live | **ask-question-pending shape**: `requestedInteraction.askQuestion.questions[].{question, options[].{id,text}}` (option `id` = `"1".."N"`); also `metadata.toolCall.{id,name:"ask_question",argumentsJson,originalName}` and a top-level `askQuestion` block (same content). `metadata.sourceTrajectoryStepInfo.{trajectoryId, stepIndex, metadataIndex}`. |
|
||||
| `ask_question_done.json` | `CORTEX_STEP_TYPE_ASK_QUESTION` | `DONE` | live | answered shape: `completedInteractions[].response.askQuestion.responses[].{question, selectedOptionIds:["4"]}`. |
|
||||
| `list_directory_done.json` | `CORTEX_STEP_TYPE_LIST_DIRECTORY` | `DONE` | live | tool-result step: `listDirectory.{directoryPathUri, results}` — another distinct tool step the mapper must classify. |
|
||||
| `checkpoint.json` | `CORTEX_STEP_TYPE_CHECKPOINT` | `DONE` | live | system step (`checkpoint` block, `metadata.modelUsage`/`retryInfos`) — mapper skips. |
|
||||
| `run_command_error.json` | `CORTEX_STEP_TYPE_RUN_COMMAND` | `ERROR` | **synthesized** (see §1.1) | timed-out `WAITING`→`ERROR` permission step. `metadata.internalMetadata.statusTransitions` ends with the `WAITING`→`ERROR` flip; `requestedInteraction.permission` still present. Carries a `_fixtureProvenance` marker. Models the §2.1 timeout gotcha. |
|
||||
|
||||
**Field-path cheatsheet for Tasks 4/5** (paths are stable across every step):
|
||||
- `step.type`, `step.status`
|
||||
- `step.metadata.sourceTrajectoryStepInfo.{trajectoryId, stepIndex, cascadeId}` (`stepIndex` omitted when 0)
|
||||
- `step.metadata.source` (`CORTEX_STEP_SOURCE_{USER_EXPLICIT, MODEL, SYSTEM}`)
|
||||
- `step.requestedInteraction.{askQuestion | permission}` (only when `WAITING`)
|
||||
- `step.requestedInteraction.askQuestion.questions[].options[].{id, text}`
|
||||
- `step.requestedInteraction.permission.{resource.{action,target}, actionDescription, suggestedPersistPattern, persistSuggestionType}`
|
||||
- `step.plannerResponse.{response, modifiedResponse, messageId, stopReason, toolCalls[].{id,name,argumentsJson}}`
|
||||
- `step.runCommand.{commandLine, proposedCommandLine, cwd, exitCode, combinedOutput.full, blocking, waitMsBeforeAsync}`
|
||||
- `step.completedInteractions[].{request, response}` (response echoes the delivered answer)
|
||||
|
||||
Status enum observed live: `CORTEX_STEP_STATUS_{DONE, WAITING, ERROR}` (and transient `PENDING/RUNNING/GENERATING` in `metadata.internalMetadata.statusTransitions`).
|
||||
Step-type enum observed live (9 distinct): `USER_INPUT, CONVERSATION_HISTORY, PLANNER_RESPONSE, CHECKPOINT, RUN_COMMAND, LIST_DIRECTORY, ASK_QUESTION, VIEW_FILE, CODE_ACTION` (VIEW_FILE / CODE_ACTION observed in the trajectory but not all saved as fixtures — the mapper only needs the type/status discriminator + the per-type payload key, which follows the same `camelCase(type)` convention, e.g. `viewFile`, `codeAction`).
|
||||
|
||||
### 1.1 ERROR fixture provenance
|
||||
|
||||
`run_command_error.json` is the **one synthesized fixture** (all others are verbatim live captures). It was **derived from the live `run_command_waiting.json`** (same conversation `2399249c…`, same real `trajectoryId`/`stepIndex`) by flipping `status` `WAITING`→`ERROR` and appending the `WAITING`→`ERROR` `statusTransition` — i.e. exactly the timeout flip described in design §2.1. The WAITING shape and the timeout-flip behavior are both live-verified; only this exact ERROR *snapshot* is synthesized. The fixture carries an explicit `_fixtureProvenance` string so it can never be mistaken for a verbatim capture (drop/ignore that key when asserting shape).
|
||||
|
||||
Why synthesized rather than captured: I made several honest live attempts and none produced an ERROR within a reasonable window:
|
||||
- left an `ASK_QUESTION` `WAITING` step unanswered for ~3 min → stayed `WAITING` (no timeout);
|
||||
- left a `RUN_COMMAND` permission `WAITING` step (`echo hello-spike`, index 42) unanswered for >5 min → stayed `WAITING` (no timeout);
|
||||
- `CancelCascadeSteps {cascadeId}` returned `200 {}` but did **not** flip the `WAITING` step (see §4).
|
||||
|
||||
So in agy 1.0.10 the `WAITING`-interaction timeout window is **long (minutes), not seconds** — the §2.1 gotcha is real (the prior memory hit it via slow human delivery) but it is not a quick way to elicit an ERROR step in a spike. Treating ERROR as the labelled-synthesized fallback (per the task brief) was the right call rather than blocking the task.
|
||||
|
||||
---
|
||||
|
||||
## 2. Step 3 — turn-send verdict
|
||||
|
||||
**Verdict: KEEP tmux `send-keys` for user turns. Do NOT use an RPC to send turns.** (Confirms the prior memory + design §2/§7.)
|
||||
|
||||
Evidence:
|
||||
- A turn typed via `tmux send-keys -t agy-spike '<text>' Enter` is recorded as a `CORTEX_STEP_TYPE_USER_INPUT` step with **`metadata.source = CORTEX_STEP_SOURCE_USER_EXPLICIT`** and `userInput.userResponse == "<text>"` (see `user_input.json`). This is exactly what the read path keys on, so send-keys turns are attributed correctly.
|
||||
- `SendAgentMessage` (the only message-injection RPC on the surface) is documented (memory + design) to record the turn as a `SYSTEM_MESSAGE` ("not actually sent by the user"), which the mapper would then skip/mis-attribute — so it cannot drive user turns. I did **not** re-issue `SendAgentMessage` in this spike (no need to perturb the live session to re-confirm a settled, documented negative; and the mapper already skips USER_INPUT regardless).
|
||||
- I scanned the live RPC surface for a *proper* user-turn method (a queued-user-input / "send all queued messages" path). The methods exercised/observed on `LanguageServerService` this session were `Heartbeat`, `GetConversationMetadata`, `GetCascadeTrajectorySteps`, `HandleCascadeUserInteraction`, `StreamAgentStateUpdates`. No `SendAllQueuedMessages` / `EnqueueUserInput` / `SubmitUserTurn`-style method was found that records as `USER_INPUT`. **No viable user-turn RPC exists in 1.0.10.**
|
||||
|
||||
Implication for the plan: the executor's `run_turn` stays on tmux `send-keys` (design §5/§7 unchanged). Only **interactions** (answers/approvals) and **interrupt** move to RPC.
|
||||
|
||||
### 2.1 Important live finding — attended TUI keeps its OWN prompt in parallel with RPC
|
||||
|
||||
When agy runs **attended** (auto-exec OFF) and you drive turns by `send-keys`, the **TUI maintains its own permission/question prompt in-process, in parallel with the RPC step state.** Observed live:
|
||||
- An RPC `HandleCascadeUserInteraction` approval flips the trajectory step to `DONE` and the command runs (verified: `run_command_done.json` has `exitCode:0` + output) — but the **TUI prompt for that same interaction can stay open**, and a subsequent `send-keys` lands in that TUI prompt's filter/amend buffer instead of starting a new turn (observed: a follow-up turn got concatenated into the persist-pattern option text). Pressing `Escape` clears the stale TUI prompt (the TUI then reports "User declined the tool call" for *its* prompt, harmlessly — the RPC-approved command had already run).
|
||||
|
||||
Consequences for the production design:
|
||||
- This is a **non-issue for the real bridge**, which is RPC-driven for interactions and does NOT type interaction answers via the TUI. It is a strong **reason to deliver interactions over RPC, not send-keys**.
|
||||
- But it means a turn `send-keys`d **while a prior interaction's TUI prompt is still open** can be swallowed. The runner-owned terminal in production should ensure the TUI is at an idle `>` prompt before send-keys'ing a new turn (the read driver already knows the trajectory is idle — no `WAITING`/`RUNNING` step — which is the right gate). Worth a note in Task 11/12.
|
||||
|
||||
---
|
||||
|
||||
## 3. Step 4 — read-mode verdict
|
||||
|
||||
**Verdict: default to `StreamAgentStateUpdates` (server-stream) with `GetCascadeTrajectorySteps` polling as the fallback / reconcile path.** (Matches the memory lean + design §6.)
|
||||
|
||||
### 3.1 `GetCascadeTrajectorySteps` (poll) — reliability baseline
|
||||
- Unary `POST {"cascadeId": conv}` → `200 {"steps":[...]}`. Rock-solid every call this session (dozens of calls, 0 failures). Returns the **complete** step list each time (full snapshot), with explicit per-step `status` — trivial to dedup by `stepIndex`/identity. Typical round-trip a few ms on loopback.
|
||||
- This is the **simplest correct** read path and the natural reconcile-on-reconnect mechanism. The whole point of the RPC rework (design §3) is that these structured snapshots remove the JSONL cursor/gap logic and fix the double-render.
|
||||
|
||||
### 3.2 `StreamAgentStateUpdates` (server-stream) — latency win, framing caveat
|
||||
- **Request MUST be connect-enveloped.** This is a correction to the memory note: sending a bare JSON body `{"conversationId": conv}` to `StreamAgentStateUpdates` returns a single connect error frame:
|
||||
`{"error":{"code":"invalid_argument","message":"... protocol error: promised 576941934 bytes in enveloped message, got 53 bytes ..."}}`
|
||||
— the server reads the first 5 bytes of the JSON as the connect envelope header. The body must be framed as `[flag:1=0x00][len:BE-uint32][json-bytes]` (same 5-byte envelope as the response frames). Content-Type `application/connect+json`.
|
||||
- With the **enveloped** request: `200`, the stream **stays open and long-polls**. First frame carrying steps arrived **~0.13 s after a turn was sent** (measured: `first_steps_frame_at = 0.132 s`); the stream then emits a burst of incremental `update` frames as steps progress (`update.mainTrajectoryUpdate.stepsUpdate.steps[]`), each `flag=0`, then blocks (long-poll) when the trajectory goes idle. A trailing `flag=2` frame carries the connect end-of-stream / error envelope.
|
||||
- **Reliability caveat:** because the stream blocks when idle, a naive reader must use a read timeout / heartbeat and reconnect, and must **reconcile via a `GetCascadeTrajectorySteps` snapshot on (re)connect** to avoid missing a transition that happened during a gap. The connect framing (envelope on both request and response) is fiddly to get exactly right (cost me one iteration), so the client wrapper must own it and be unit-tested against the captured frames.
|
||||
|
||||
### 3.3 Recommendation
|
||||
- **Default: stream** for low-latency detection of `WAITING` interactions and step progress (~130 ms vs a poll interval), **with poll as the fallback**: (a) reconcile snapshot on every (re)connect, (b) fall back to pure polling if the stream errors/regresses. This matches design §6 ("polls `GetCascadeTrajectorySteps` *or* consumes `StreamAgentStateUpdates`").
|
||||
- **Acceptable de-scope:** if the connect server-stream framing proves too costly to harden in the implementation tasks, **ship poll-first** (a tight `GetCascadeTrajectorySteps` loop, e.g. 250–500 ms while a turn is active) and add the stream as a follow-up. Polling alone is fully correct (full snapshots + explicit status); the only thing lost is sub-second push latency. The interaction bridge's tight detect→deliver loop (design §2.1) already re-reads the freshest `WAITING` step at delivery time, so poll-first does not compromise interaction correctness.
|
||||
|
||||
---
|
||||
|
||||
## 4. Other live confirmations (for Tasks 2/3/5/8/10)
|
||||
|
||||
- **Approval round-trip (Task 3/8):** `HandleCascadeUserInteraction {cascadeId, interaction:{trajectoryId, stepIndex, permission:{allow:true}}}` → `200 {}`; the `RUN_COMMAND` step flipped `WAITING`→`DONE` with `exitCode:0` and real `combinedOutput.full`. `trajectoryId`+`stepIndex` come from the WAITING step's `metadata.sourceTrajectoryStepInfo`. (Exactly the memory shape; `permission.allow`, no `approvalId`.)
|
||||
- **Answer round-trip (Task 3/8):** `HandleCascadeUserInteraction {... interaction:{trajectoryId, stepIndex, askQuestion:{responses:[{question:"<verbatim>", selectedOptionIds:["4"]}]}}}` → `200 {}`; the `ASK_QUESTION` step flipped to `DONE` and the cascade proceeded autonomously. `selectedOptionIds` uses the option `id` (`"1".."N"`), not the text.
|
||||
- **Tool cwd:** agy executes `run_command` in its own scratch dir (`combinedOutput.full` for `pwd` = `/Users/bryanli/.gemini/antigravity-cli/scratch`), NOT the agy launch CWD. Benign, but worth knowing for any cwd-sensitive parity check.
|
||||
- **`GetConversationMetadata` ownership probe** still works as the discovery module expects (`metadata.rootConversationId` echo) — port discovery via `omnigent/antigravity_native_rpc.py` worked first try.
|
||||
- **`CancelCascadeSteps` (Task 10) — accepts `{cascadeId}` but does NOT cancel a WAITING-for-interaction step.** `POST CancelCascadeSteps {"cascadeId": conv}` → `200 {}` (so, contrary to the old `antigravity_native_rpc.interrupt_turn` worry, the *conversation/cascade id alone is accepted* as the request key — no internal invocation id was needed for a `200`). **However** the live `RUN_COMMAND` `WAITING` step did **not** change status after the call (still `WAITING`, no new `statusTransition`). So for Task 10: `CancelCascadeSteps {cascadeId}` is wired-up-able with just the conversation id, but its effect on a step that is `WAITING` on a human interaction is a **no-op** here — it likely targets in-flight `RUNNING`/generating steps, not interaction-pending ones. **Task 10 must verify cancel against a RUNNING step** (e.g. cancel mid-generation, or mid-long-command) to confirm it actually interrupts, and should pair cancel-of-an-interaction with delivering a **deny** (`permission.allow:false` / `askQuestion` skip) to actually unblock a `WAITING` step. Whether `ForceStopCascadeTree` behaves differently was not tested.
|
||||
|
||||
---
|
||||
|
||||
## 5. Concerns / follow-ups
|
||||
|
||||
- **ERROR fixture is synthesized** (the only one) — see §1.1. The `WAITING` timeout window in 1.0.10 is minutes-long, so a real ERROR snapshot wasn't elicitable in the spike window. If Tasks 4/5 want a verbatim ERROR step, capture one opportunistically during the Task 13 live run (let an interaction sit, or hit a real tool error) and replace the fixture.
|
||||
- **`CancelCascadeSteps` is a no-op on WAITING-for-interaction steps** (§4) — Task 10 must validate the real interrupt against a `RUNNING` step, and unblock `WAITING` steps with a deny rather than a cancel. Don't assume `200 {}` == "interrupted".
|
||||
- **Connect stream framing** (request envelope, §3.2) is a sharp edge — the Task 2/6 client wrapper must own request+response enveloping and be unit-tested against captured frames; do not hand it to callers. If hardening it slips, ship poll-first (§3.3) — fully correct, only loses sub-second latency.
|
||||
- **Attended TUI vs RPC interaction** (§2.1): production runner should gate `send-keys` turns on an idle trajectory (no `WAITING`/`RUNNING` step); surface in Task 11/12.
|
||||
- Step payload key follows `camelCase(type)` (e.g. `RUN_COMMAND`→`runCommand`, `VIEW_FILE`→`viewFile`); the mapper can rely on this convention but should default-skip unknown types rather than assume a payload key exists.
|
||||
@@ -0,0 +1,154 @@
|
||||
# Cursor-native cost / token-usage tracking
|
||||
|
||||
**Status:** Prototype (implemented; behind a live e2e validation)
|
||||
**Date:** 2026-06-25
|
||||
**Harness:** `cursor-native` (the `omnigent cursor` interactive TUI wrapper)
|
||||
|
||||
## 1. Motivation
|
||||
|
||||
The web UI already renders a **Session cost** badge and a per-model **token
|
||||
usage breakdown** (`AgentInfo.tsx` → `chatStore` → `session.usage` SSE event).
|
||||
claude-native and codex-native feed it; cursor-native did not — its info
|
||||
popover showed everything *except* cost/usage. This adds that feed.
|
||||
|
||||
## 2. Why it was thought impossible (and what changed)
|
||||
|
||||
The earlier investigation concluded cursor-native couldn't report usage:
|
||||
|
||||
- **SQLite chat store** (`~/.cursor/chats/<md5(cwd)>/<chat-id>/store.db`,
|
||||
tailed by `cursor_native_forwarder.py`) — message blobs only; **no** token or
|
||||
cost data.
|
||||
- **`~/.cursor/projects/<ws>/agent-transcripts/<id>.jsonl`** — exists, but the
|
||||
`turn_ended` record is just `{type, status}`; **no** usage.
|
||||
- **Headless `--print --output-format stream-json`** emits a `result.usage`
|
||||
object — but that's the SDK/headless path, not the interactive TUI the
|
||||
cursor-native harness drives.
|
||||
|
||||
**What changed:** cursor-agent ships a Claude-Code-style **hooks** system, and
|
||||
the `stop` (and `afterAgentResponse`) hooks fire **in the interactive TUI** with
|
||||
per-turn token usage. Verified live against `cursor-agent 2026.06.24` by driving
|
||||
the real TUI through a PTY with a registered hook — captured `stop` payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"conversation_id": "6eb5549f-…", "generation_id": "0b1b8c24-…",
|
||||
"model": "claude-4-sonnet", "status": "completed", "loop_count": 0,
|
||||
"input_tokens": 23666, "output_tokens": 5,
|
||||
"cache_read_tokens": 23617, "cache_write_tokens": 47,
|
||||
"session_id": "6eb5549f-…", "hook_event_name": "stop",
|
||||
"transcript_path": "…/agent-transcripts/6eb5549f-….jsonl"
|
||||
}
|
||||
```
|
||||
|
||||
> Note: Cursor's public hooks docs (cursor.com/docs/hooks) lag the binary — they
|
||||
> document `afterAgentResponse` as `{text}` only and omit `stop`. This CLI
|
||||
> version emits both with the token fields above. The forum confirms usage
|
||||
> shipped to the CLI ~Feb 2026.
|
||||
|
||||
Hooks are delivered the payload as JSON on **stdin** and run only in the
|
||||
interactive loop (a `-p`/headless run does **not** fire them — also verified).
|
||||
|
||||
## 3. Data flow
|
||||
|
||||
```
|
||||
cursor-agent TUI ── stop hook (per turn, JSON on stdin) ──▶
|
||||
python -m omnigent.cursor_native_usage record-usage --bridge-dir <dir>
|
||||
└─ appends one normalized line to <bridge_dir>/cursor_usage.jsonl
|
||||
▲
|
||||
│ (runner-owned poll loop, ~0.7s)
|
||||
supervise_cursor_usage_forwarder
|
||||
└─ tails cursor_usage.jsonl, sums per-turn counts → cumulative totals
|
||||
└─ POST /v1/sessions/{id}/events type=external_session_usage
|
||||
{ cumulative_input_tokens, cumulative_output_tokens,
|
||||
cumulative_cache_read_input_tokens, model }
|
||||
│
|
||||
server _persist_external_session_usage (SET semantics, monotonic)
|
||||
└─ prices tokens via fetch_model_pricing(model) [if catalog-priced]
|
||||
└─ broadcasts session.usage SSE → chatStore → AgentInfo.tsx
|
||||
```
|
||||
|
||||
This reuses the **exact** server contract claude/codex-native already use
|
||||
(`external_session_usage` → `_persist_native_cumulative_usage`), so **no server
|
||||
or frontend changes are required**.
|
||||
|
||||
## 4. Components added
|
||||
|
||||
| File | Change |
|
||||
|---|---|
|
||||
| `omnigent/cursor_native_usage.py` | **New.** Hook recorder (`record-usage`, stdlib-only) + cumulative accumulator + runner-owned poller/supervisor + `clear_cursor_usage_state`. |
|
||||
| `omnigent/cursor_native_bridge.py` | `build_hooks_config` / `write_hooks_config` — write `<workspace>/.cursor/hooks.json` registering the `stop` hook (sibling of `write_mcp_config`). |
|
||||
| `omnigent/runner/app.py` | In the cursor terminal setup: `write_hooks_config(...)`, `clear_cursor_usage_state(...)`, and `supervise_cursor_usage_forwarder(...)` added to the existing `_supervise_cursor_native_bridges` gather. |
|
||||
|
||||
### Hook recorder (`record-usage`)
|
||||
- Reads the `stop` payload from stdin, normalizes to
|
||||
`{generation_id, model, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens}`,
|
||||
appends one line to `cursor_usage.jsonl` (`O_APPEND`).
|
||||
- **Always** prints `{}` and exits 0 — usage capture must never block/fail a
|
||||
turn. Imports only the stdlib so the hook stays fast (cursor blocks turn-end
|
||||
on it).
|
||||
|
||||
### Accumulator semantics
|
||||
- cursor reports **per-turn** counts; session billing is their **sum** (each
|
||||
turn is billed for the full context it re-sent, so summing per-turn
|
||||
`input_tokens` — cache reads included — is the correct cumulative input).
|
||||
- Deduped by `generation_id`, so re-reading the append-only log every poll (and
|
||||
after a supervisor restart) never double-counts. State persisted to
|
||||
`<bridge_dir>/cursor_usage_forwarder.json`; written only **after** a
|
||||
successful POST so a failed flush retries.
|
||||
|
||||
### Token field mapping
|
||||
cursor's `input_tokens` is **inclusive** of cache-read + cache-write (the TUI
|
||||
subtracts them for display). We forward it inclusive as
|
||||
`cumulative_input_tokens` and pass `cumulative_cache_read_input_tokens`; the
|
||||
server splits the cache-read portion out and prices it at the cache-read rate.
|
||||
|
||||
## 5. Cost vs. tokens (known limitation)
|
||||
|
||||
`external_session_usage` prices tokens server-side via
|
||||
`fetch_model_pricing(model)`. The `model` from the hook is **cursor's id**
|
||||
(e.g. `claude-4-sonnet`, `composer-2.5`), which often does **not** match the
|
||||
MLflow catalog:
|
||||
|
||||
| cursor model id | catalog resolves? | result |
|
||||
|---|---|---|
|
||||
| `claude-4-sonnet` | provider=anthropic, **no exact price** (catalog has `claude-sonnet-4-5`) | tokens shown, cost "—" |
|
||||
| `composer-2.5` | no provider (Cursor's own model) | tokens shown, cost "—" |
|
||||
| `gpt-5` | priced | tokens **and** cost |
|
||||
|
||||
So **token usage always populates**; **dollar cost** appears only for models
|
||||
whose cursor id matches the catalog. We intentionally forward the **raw** cursor
|
||||
id rather than guess a version alias (a wrong version = wrong rate, which is
|
||||
worse than showing "—").
|
||||
|
||||
**Follow-up for full cost:** add a cursor→catalog model alias map (e.g.
|
||||
`claude-4-sonnet → claude-sonnet-4-5`) — either in `cursor_native_usage` before
|
||||
POST, or as a cursor-aware branch in `fetch_model_pricing`. Out of scope for
|
||||
this prototype.
|
||||
|
||||
## 6. Other caveats / follow-ups
|
||||
|
||||
- **Cache-write tokens** aren't separately priced: the server's native
|
||||
cumulative path splits out only `cache_read`, so cursor's `cache_write_tokens`
|
||||
stay in the input bucket and price at the full input rate. Minor; matches the
|
||||
field set the server accepts today.
|
||||
- **Same-workspace concurrent sessions:** `hooks.json` is workspace-scoped (like
|
||||
`mcp.json`), so the last-launched session's `--bridge-dir` wins. Usage would
|
||||
route to that session. Same limitation the MCP config already has; the store
|
||||
forwarder's claim logic doesn't cover hooks.
|
||||
- **Trust gate:** project hooks load only in a trusted workspace. The
|
||||
cursor-native flow already trusts the workspace (trust modal + cli-config), so
|
||||
this is satisfied in practice — worth confirming in the e2e check.
|
||||
- **Hook latency:** cursor waits for the hook at turn-end. The recorder is a
|
||||
short-lived `python -I -m …` (stdlib only); negligible, but a compiled
|
||||
shim could remove the interpreter-spawn cost if it ever matters.
|
||||
|
||||
## 7. Testing
|
||||
|
||||
- **Unit/logic (done, offline):** drove the real `record-usage` CLI with two
|
||||
captured `stop` payloads → correct `cursor_usage.jsonl`; accumulator produced
|
||||
the expected cumulative POST body; verified dedup (re-read ≠ double-count) and
|
||||
skip-empty.
|
||||
- **e2e (pending):** launch `omnigent cursor`, run a couple of turns, confirm
|
||||
the Session-cost / token-usage popover updates in the web UI. The
|
||||
`cursor-sdk-e2e-dev` skill spins up a live server; cursor-native needs the TUI
|
||||
path (PTY-driven), so reuse the cursor-native e2e harness.
|
||||
@@ -0,0 +1,150 @@
|
||||
# Cursor-native Elicitation — Transcript-based Surfacing
|
||||
|
||||
**Status:** implemented
|
||||
**Supersedes:** [`cursor-native-tui-mirror-plan.md`](./cursor-native-tui-mirror-plan.md) (pane-scrape design)
|
||||
**Code:** `omnigent/cursor_native_permissions.py`, the `cursor-permission-request` hook in
|
||||
`omnigent/server/routes/sessions.py`, runner wiring in `omnigent/runner/app.py`,
|
||||
`web/.../ApprovalCard.tsx`.
|
||||
|
||||
## Goal / behavior
|
||||
|
||||
Surface an Omnigent **elicitation card whenever the `cursor-agent` TUI gates a tool call or
|
||||
asks a question**, answerable from the web **or** the embedded TUI. Cursor's own native gate
|
||||
stays the source of truth — **no `--force`, no JS-bundle modification**. The failure mode is
|
||||
benign: if detection ever breaks, the embedded TUI prompt still works and the user answers
|
||||
there.
|
||||
|
||||
Two interaction kinds are surfaced (both ride cursor's per-call "pending" mechanism):
|
||||
|
||||
1. **Tool-approval gates** — shell commands, file edit/create (`ApplyPatch`), `Delete`, MCP
|
||||
tools, etc. Rendered as an approve/reject card; answered with a keystroke.
|
||||
2. **`AskQuestion`** — cursor's structured multiple-choice tool. Rendered as the existing
|
||||
`AskUserQuestion` form; answered by driving the TUI picker.
|
||||
|
||||
## Approach: detect in the transcript, deliver via the pane
|
||||
|
||||
```
|
||||
cursor chat store.db (~/.cursor/chats/<md5(cwd)>/<chat-id>/store.db)
|
||||
│ pending tool call written as an assistant `tool-call` content part with
|
||||
│ providerOptions.cursor.pendingToolCallStartedAtMs and no matching tool-result
|
||||
▼
|
||||
[runner] supervise_cursor_transcript_elicitations (tails the SAME store the forwarder mirrors)
|
||||
│ read_cursor_pending_tool_calls → settle-debounce → POST /hooks/cursor-permission-request
|
||||
▼
|
||||
[server] publish response.elicitation_request → PARK (_publish_and_wait_for_harness_elicitation)
|
||||
▼
|
||||
[web] ApprovalCard / AskUserQuestionForm renders → user answers
|
||||
▼
|
||||
[server] return the verdict to the parked POST
|
||||
▼
|
||||
[runner] send tmux keystrokes into the pane:
|
||||
approval → `y` / `Escape`(+`Enter` to submit the rejection reason)
|
||||
question → picker navigation (Down × index, Space, Enter), or type into "Other"
|
||||
```
|
||||
|
||||
If the pending call vanishes from the store while still parked (the user answered in the TUI,
|
||||
or it executed), the runner POSTs `external_elicitation_resolved` to clear the card.
|
||||
|
||||
### Detection signal (the key fact)
|
||||
|
||||
Each `toolCallId` is classified by how its `tool-call` part appears in the store:
|
||||
|
||||
- **pending** — appears in an object **with** `providerOptions.cursor.pendingToolCallStartedAtMs`
|
||||
(cursor is blocking on it),
|
||||
- **committed** — appears **without** the marker (cursor finalized it to run — auto-approved,
|
||||
or approved and now executing),
|
||||
- **resolved** — has a `tool-result`.
|
||||
|
||||
> **active elicitation = pending AND NOT committed AND NOT resolved.**
|
||||
|
||||
The committed exclusion is the structural discriminator that removes the auto-approve flash
|
||||
*without a timing guess*: empirically a call genuinely blocked on the human appears **only**
|
||||
with the marker until answered (verified — a pending `Delete`: marker-only, zero no-marker
|
||||
appearances), while an auto-approved/committed call appears without it. The pending call lives
|
||||
**only inside cursor's binary protobuf checkpoint frames** — not as a plain-JSON `blobs` row —
|
||||
so the reader (`read_cursor_pending_tool_calls`) byte-scans each blob for embedded JSON objects
|
||||
rather than `json.loads`-ing the whole row.
|
||||
|
||||
### Settle / debounce (small backstop)
|
||||
|
||||
With the committed-exclusion above doing the real work, the settle window is just a short
|
||||
backstop (`_ELICITATION_SETTLE_S` = 0.5s) for the sub-poll race where cursor's marker frame is
|
||||
observed a tick before its committed frame. It is intentionally short so a genuinely-gated
|
||||
prompt that resolves quickly — e.g. a cursor **Auto-review retry** — still surfaces a card
|
||||
rather than being suppressed. (An earlier 1.5s window suppressed exactly such a retry; the
|
||||
discriminator is what let it shrink safely.)
|
||||
|
||||
### Keystroke delivery
|
||||
|
||||
The pane is still used to *deliver* the verdict. Two gotchas, both handled in
|
||||
`_send_cursor_keys`:
|
||||
|
||||
- **Send keys one at a time** with a short gap and a longer settle before `Enter` — the cursor
|
||||
TUI re-renders between keys and **drops a back-to-back burst** sent in one `tmux send-keys`
|
||||
call. (Single-key approvals were unaffected, which is why this only surfaced with the
|
||||
multi-key `AskQuestion` picker.)
|
||||
- **Reject is a two-step.** Cursor's tool-reject doesn't dismiss on the decline key alone — it
|
||||
opens a *"Reason for rejection (Enter to submit, Esc to cancel)"* sub-prompt. The approval
|
||||
decline path sends the decline key **then `Enter`** to submit an empty reason, so the TUI
|
||||
doesn't park at the reason input. (The `AskQuestion` picker's "Esc to skip" dismisses
|
||||
cleanly, so the question decline is a single key.)
|
||||
|
||||
### AskQuestion specifics
|
||||
|
||||
- Rendered via the existing web form: the runner stamps the full questions as the **structured
|
||||
`ask_user_question` hook field** (uncapped), with an `AskUserQuestion(...)` `content_preview`
|
||||
as the ≤1024-char legacy fallback. cursor's `prompt`/`label` are mapped to the web's
|
||||
`question`/`label`; each question `id` is preserved.
|
||||
- Answered by translating the chosen option labels (keyed by question `id`) into picker
|
||||
keystrokes; a value matching no option targets the trailing "Other (type to answer)" row.
|
||||
|
||||
## Why this replaced the pane-scrape plan
|
||||
|
||||
The original plan (`cursor-native-tui-mirror-plan.md`) chose to **scrape the rendered TUI pane**
|
||||
and answer with keystrokes. Its central justification:
|
||||
|
||||
> "The transcript JSONL and the chat `store.db` contain only the user message while an approval
|
||||
> is pending (the decision lives in memory), so a clean file-tail channel is not available —
|
||||
> scraping the pane is required."
|
||||
|
||||
**That premise was incorrect — and it was an investigation gap, not a cursor-version change.**
|
||||
Empirically, cursor chat stores from **June 18–19** (the same `2026.06.19` era the plan was
|
||||
written against) already contain `pendingToolCallStartedAtMs` — the exact signal this design
|
||||
keys on. The pending decision *is* persisted; it just lives inside the **binary protobuf
|
||||
checkpoint frames**, which don't decode as a plain-JSON blob. An inspection that reads the
|
||||
store the way the forwarder does (`_blob_to_item` → `json.loads`, skipping binary blobs as
|
||||
"Merkle-tree node, not a message") sees only the user message and concludes the decision is
|
||||
in-memory. Byte-scanning the frames for embedded JSON reveals the pending tool call.
|
||||
|
||||
### What the transcript channel wins over pane-scraping
|
||||
|
||||
- **No prompt-wording allowlist.** Pane-scraping recognized prompts by verb regex
|
||||
(`run|allow|approve|…`), so it silently missed prompts whose accept verb fell outside it —
|
||||
e.g. the file-deletion gate *"Delete this file? → Delete (y) / Keep (n)"* (the bug that
|
||||
motivated this rewrite). The transcript path captures **every** gated tool kind uniformly.
|
||||
- **Solves the plan's "tricky part" (dedup).** The plan flagged identity for identical
|
||||
consecutive commands as the hard problem and pointed at a "hook-assisted hybrid" to borrow a
|
||||
stable `tool_use_id`. The transcript gives us cursor's stable `toolCallId` directly — used as
|
||||
the dedup key and to mint the elicitation id — so that edge case disappears.
|
||||
- **Structured data** (`toolName` + `args`) instead of regex-parsed pane text.
|
||||
|
||||
### What we kept from the plan
|
||||
|
||||
- Cursor's native gate remains authoritative; no bundle modification; benign failure mode.
|
||||
- The pane is still the delivery channel for the verdict keystroke.
|
||||
- The server hook, parking machinery (`_publish_and_wait_for_harness_elicitation`),
|
||||
`external_elicitation_resolved`, and the web `ApprovalCard` are reused unchanged (the
|
||||
`AskQuestion` form reuses Claude's `AskUserQuestion` renderer).
|
||||
|
||||
## Known gaps / follow-ups
|
||||
|
||||
- **Duplicate cursor sessions in one cwd.** The forwarder arbitrates a single owner
|
||||
(`_chat_claimed_by_other`); the elicitation detector does not, so two same-cwd sessions could
|
||||
double-surface. Low likelihood; not yet addressed.
|
||||
- **Store schema is private and version-sensitive.** Confirmed against cursor-agent 2026.06.24
|
||||
(and the marker present back to 2026.06.18). Failure stays benign (TUI gate authoritative).
|
||||
- **Keystroke delivery assumes the pane still shows the prompt** and the picker's key bindings
|
||||
(`Down`/`Space`/`Enter`, highlight resets per question). Verified live; re-check on cursor
|
||||
upgrades.
|
||||
- **Workspace-trust modal** (first-run) is not a tool call, so it isn't surfaced — answerable
|
||||
only in the TUI.
|
||||
@@ -0,0 +1,194 @@
|
||||
# Cursor-native TUI Mirror — Elicitation Plan
|
||||
|
||||
> **⚠️ SUPERSEDED — historical.** The shipped implementation uses **transcript-based**
|
||||
> detection (tailing cursor's chat `store.db` for pending tool calls), not pane scraping. See
|
||||
> [`cursor-native-elicitation.md`](./cursor-native-elicitation.md) for the current design.
|
||||
>
|
||||
> This plan's core premise — *"the `store.db` contains only the user message while an approval
|
||||
> is pending (the decision lives in memory)"* (see "Why this approach") — turned out to be
|
||||
> **incorrect, and it was an investigation gap rather than a cursor-version difference.** The
|
||||
> pending tool call *is* persisted (carrying `providerOptions.cursor.pendingToolCallStartedAtMs`),
|
||||
> but inside cursor's **binary protobuf checkpoint frames**, which don't decode as a plain-JSON
|
||||
> `blobs` row — so an inspection that only reads clean-JSON blobs (as the forwarder does) sees
|
||||
> just the user message. Empirically, that marker is present in chat stores back to 2026.06.18
|
||||
> (this plan's `2026.06.19` era). The transcript channel is more reliable than pane-scraping
|
||||
> (no prompt-wording allowlist) and gives a stable `toolCallId`, which solves the dedup problem
|
||||
> this plan flagged as "the tricky part." Kept from this plan: native gate stays authoritative,
|
||||
> no bundle modification, tmux keystroke delivery, benign failure mode.
|
||||
|
||||
**Status:** superseded (was: proposed)
|
||||
**Baseline:** `origin/main`
|
||||
**Tracking issue:** omnigent-ai/omnigent#1032 (cursor-native tool-call elicitations not surfaced in web UI)
|
||||
|
||||
## Goal / behavior
|
||||
|
||||
Surface an Omnigent **elicitation card whenever cursor's native TUI shows an approval
|
||||
prompt**, answerable either from the web (→ a tmux keystroke into the pane) **or** from the
|
||||
embedded TUI directly (→ the card auto-resolves). Cursor's own native gate remains the source
|
||||
of truth — **no `--force`, no JS-bundle modification**.
|
||||
|
||||
The failure mode is benign by design: if scraping ever breaks (e.g. a cursor upgrade changes
|
||||
the prompt), the embedded TUI prompt still works and the user approves there — they lose *the
|
||||
card that time*, not *the gate*.
|
||||
|
||||
## Today (on `origin/main`)
|
||||
|
||||
The cursor-native harness launches the `cursor-agent` TUI in a runner-owned tmux pane and
|
||||
injects each web-UI turn into that pane (`cursor_native_bridge.inject_user_message`); a
|
||||
forwarder (`supervise_cursor_forwarder`) mirrors the TUI's chat messages back to the session.
|
||||
|
||||
There is **no tool-approval surfacing**: when cursor wants to run a tool (shell, write, etc.)
|
||||
it shows its own in-terminal approval prompt, and that prompt is the **sole gate**
|
||||
(`cursor_native_executor.py` has no elicit/permission/approval path; the harness docstring
|
||||
states the TUI's own approval is authoritative). The web UI shows the embedded pane but emits
|
||||
no `response.elicitation_request` for cursor-native tool calls — so there is no first-class
|
||||
approval card, no chat-timeline record, and no out-of-terminal way to answer.
|
||||
|
||||
This plan adds that surfacing.
|
||||
|
||||
> **Branch note:** the working branch `cursor-native-omnigent-mcp-e2e` carries an in-progress
|
||||
> *JS-bundle preload* attempt (`cursor_native_permissions.py`, untracked) that monkeypatches
|
||||
> cursor's `pending-decision-store.ts`. This design **replaces** that approach; it is not part
|
||||
> of the `origin/main` baseline and nothing below depends on it.
|
||||
|
||||
## Why this approach
|
||||
|
||||
Investigation established that cursor exposes **no clean way** to learn when it is asking:
|
||||
|
||||
- `cursor-agent` has **no permission-request hook** (`PermissionRequest → null`); the
|
||||
`preToolUse` hook fires for *every* tool call, before cursor's native gate, with no signal
|
||||
about whether cursor would prompt, and a hook `allow` does **not** suppress the native
|
||||
prompt.
|
||||
- Cursor's "ask vs auto-run" decision is **multi-factor and partly backend-computed** — a
|
||||
server-side shell parser (`agent.v1.ShellCommandParsingResult`), an optional server-side LLM
|
||||
"Smart Mode" classifier (`SmartModeClassifier`, takes `conversation_context`), Statsig-seeded
|
||||
defaults, team allow/blocklists, plus built-in rules (`rm` delete-protection, parser-miss →
|
||||
forced prompt, a hardcoded 12-binary compound list). It is **not reproducible** from
|
||||
documented config (`permissions.allow/deny` + `approvalMode`) alone.
|
||||
|
||||
So "card exactly when cursor asks" can only come from **observing cursor's real prompt**. With
|
||||
the JS-bundle monkeypatch ruled out, the remaining faithful channel is the **rendered TUI**:
|
||||
detect the prompt by scraping the pane, answer it with keystrokes.
|
||||
|
||||
Empirically verified (live, against `cursor-agent 2026.06.19`): driving the prompt entirely
|
||||
from outside works — `capture-pane` shows the approval block, `send-keys y` approves, and the
|
||||
command executes. The transcript JSONL and the chat `store.db` contain **only the user
|
||||
message** while an approval is pending (the decision lives in memory), so a clean file-tail
|
||||
channel is not available — scraping the pane is required.
|
||||
|
||||
## Data flow
|
||||
|
||||
```
|
||||
cursor TUI (tmux pane)
|
||||
│ renders "Run this command? … (y) … (esc or n)"
|
||||
▼
|
||||
[runner] cursor-native approval scraper ← NEW (runner supervisor)
|
||||
│ poll _capture_pane → detect block → parse op + advertised keys → dedup
|
||||
│ POST /sessions/{id}/hooks/cursor-permission-request ← NEW route
|
||||
▼
|
||||
[server] publish response.elicitation_request → PARK (reuse _publish_and_wait_for_harness_elicitation)
|
||||
▼
|
||||
[web] ApprovalCard renders (no frontend change) → user clicks Approve / Decline
|
||||
▼
|
||||
[server] return accept/decline to the parked POST
|
||||
▼
|
||||
[runner] scraper sends `y` / `Escape` via _run_tmux send-keys → cursor proceeds / blocks
|
||||
```
|
||||
|
||||
If the prompt disappears **without** our keystroke (the user answered in the embedded TUI), the
|
||||
scraper POSTs `external_elicitation_resolved` to un-park the card.
|
||||
|
||||
## Reuse (already on `origin/main`)
|
||||
|
||||
- `omnigent/cursor_native_bridge.py`: `read_tmux_info`, `_capture_pane`,
|
||||
`_run_tmux(..., "send-keys", ...)`, `_session_alive`, `_settle_pane` — the tmux detection +
|
||||
answer primitives.
|
||||
- `omnigent/cursor_native_forwarder.py`: `supervise_cursor_forwarder` — the backoff / lifecycle
|
||||
supervisor pattern to mirror, already wired into `runner/app.py` `_auto_create_cursor_terminal`.
|
||||
- `omnigent/server/routes/sessions.py`: `_publish_and_wait_for_harness_elicitation` (publishes
|
||||
`response.elicitation_request` and parks for the web verdict) and the
|
||||
`external_elicitation_resolved` event handling (un-park).
|
||||
- `web/src/lib/blockStream.ts` (`elicitation_request`) and
|
||||
`web/src/components/blocks/BlockRenderer.tsx` (`ApprovalCard`) — render the card, post the
|
||||
verdict. **No frontend change.**
|
||||
|
||||
## Build (new, relative to `origin/main`)
|
||||
|
||||
1. **Approval scraper supervisor** — a new runner task (new module
|
||||
`omnigent/cursor_native_permissions.py`) modeled on `supervise_cursor_forwarder`: poll
|
||||
`_capture_pane` (~0.3s cadence) → detect approval block → parse → dedup → POST to the new
|
||||
route (parks) → on verdict `send-keys` → reconcile TUI-side answers via
|
||||
`external_elicitation_resolved`. Inherits the forwarder's backoff + lifecycle.
|
||||
|
||||
2. **Approval-prompt parser** (in the same module) — generic block detector anchored on the
|
||||
observed shape:
|
||||
|
||||
```
|
||||
$ echo omnigent_probe > out.txt in .
|
||||
Run this command?
|
||||
Shell allowlist is empty
|
||||
→ Run (once) (y)
|
||||
Run Everything (shift+tab)
|
||||
Skip (esc or n)
|
||||
```
|
||||
|
||||
Detect by a title line ending in `?` plus option lines; **parse the advertised keys from the
|
||||
`(…)` hints** (accept = `y`, decline = `Escape`/`n`) so a key rename does not silently break
|
||||
us; extract the operation text for the card. Per-op enrichment (shell command+cwd, file-edit
|
||||
path, MCP tool, web URL) with a generic fallback. Plus small UI-string helpers
|
||||
(message / preview / stable elicitation-id).
|
||||
|
||||
3. **Server route** — new `POST /sessions/{id}/hooks/cursor-permission-request` in
|
||||
`omnigent/server/routes/sessions.py`: build `ElicitationRequestParams` from the scraped
|
||||
operation, call the existing `_publish_and_wait_for_harness_elicitation`, return
|
||||
accept/decline to the parked runner POST.
|
||||
|
||||
4. **Runner wiring** (`omnigent/runner/app.py` `_auto_create_cursor_terminal`): start the
|
||||
scraper supervisor **alongside** the existing `supervise_cursor_forwarder` (gather both).
|
||||
Keep cursor's native prompts (do **not** add `--force`); keep `--approve-mcps`.
|
||||
|
||||
5. **Executor coordination** (`omnigent/cursor_native_bridge.py`): teach `_settle_pane` to treat
|
||||
an active approval block as "busy" so a web steering message cannot blind-paste over a pending
|
||||
prompt.
|
||||
|
||||
## Identity / dedup (the tricky part)
|
||||
|
||||
Cursor shows one approval at a time → maintain a single `active_prompt`
|
||||
(content-hash + minted `elicitation_id`):
|
||||
|
||||
- New block with a different key ⇒ surface (mint id, POST/park).
|
||||
- Same key ⇒ already parked; do nothing.
|
||||
- Block vanished ⇒ resolved — ours (we sent the key) or the TUI's (→ POST
|
||||
`external_elicitation_resolved`).
|
||||
|
||||
Edge case: identical consecutive commands (same content key) rely on observing the
|
||||
vanish-between-prompts transition. Document it; the hardening path is the **hook-assisted
|
||||
hybrid** — borrow `preToolUse`'s `tool_use_id` + exact `tool_input` for a stable identity and
|
||||
exact card content, leaving the scraper to only answer "is a prompt on screen → send key."
|
||||
|
||||
## Testing
|
||||
|
||||
- **Unit:** parser over captured-pane fixtures (shell / write / mcp) including key extraction;
|
||||
dedup state machine driven by a fake `capture` function.
|
||||
- **Integration:** supervisor against fake capture / send-keys — asserts POST payload, the
|
||||
keystroke on verdict, and un-park on a TUI-side resolve.
|
||||
- **E2E (gated on cursor auth):** drive a real TUI in tmux — boot → trigger prompt → assert card
|
||||
POST → approve → assert keystroke + execution. Doubles as the **cursor-upgrade regression
|
||||
guard**.
|
||||
|
||||
## Scope boundaries
|
||||
|
||||
Out of scope: "Run Everything" passthrough (v1 = approve-once / decline), byte-exact mirroring
|
||||
of cursor's internal classifier (impossible), suppressing the embedded raw prompt. Fragility to
|
||||
cursor's prompt strings is **mitigated** (parse advertised keys + regression test), not
|
||||
eliminated.
|
||||
|
||||
## Estimated effort
|
||||
|
||||
Small. The tmux primitives (`_capture_pane`, `send-keys` via `_run_tmux`), the forwarder
|
||||
supervisor pattern, the generic elicitation parking (`_publish_and_wait_for_harness_elicitation`
|
||||
/ `external_elicitation_resolved`), and the frontend card all already exist on `main`. The new
|
||||
code is a scraper supervisor + prompt parser, a thin server route, small UI-string helpers, and
|
||||
the runner wiring — so a first working version lands quickly. The only meaningful additions
|
||||
beyond that are parser robustness across op-types and the E2E regression guard (plus light
|
||||
"re-verify on cursor upgrade" upkeep).
|
||||
@@ -0,0 +1,715 @@
|
||||
# Running omnigent on Databricks
|
||||
|
||||
A production deployment guide for running omnigent agents on Databricks
|
||||
infrastructure. Covers the four canonical integration points:
|
||||
|
||||
1. **Databricks Apps** as the managed runtime for the omnigent server
|
||||
2. **Mosaic AI Foundation Model APIs** as the LLM provider
|
||||
3. **Mosaic AI Gateway** as the governance and audit layer over LLM calls
|
||||
4. **MLflow Tracing in Unity Catalog** as the long-term trace store
|
||||
|
||||
omnigent's fine standalone with any OTLP backend and any LLM
|
||||
provider. This guide's for the production deployment story where
|
||||
governance, audit, cost tracking, and managed scale matter.
|
||||
|
||||
> **Databricks customer? Start with the managed offering.**
|
||||
> [Omnigent on Databricks](https://docs.databricks.com/aws/en/omnigent/)
|
||||
> (Beta) is a fully managed service: Databricks operates the omnigent
|
||||
> server for you, already wired to workspace identity, Foundation
|
||||
> Models, AI Gateway, and MLflow Tracing. You enable the **Omnigent**
|
||||
> preview in your workspace settings and follow the quickstart there.
|
||||
> No deploy tooling, no Lakebase bootstrap, no bundle to maintain. That
|
||||
> is the recommended path for most Databricks users.
|
||||
>
|
||||
> This guide covers the **self-managed** path: deploying and operating
|
||||
> the omnigent server yourself on Databricks Apps. Reach for it when the
|
||||
> managed service is not available in your region, or when you need
|
||||
> something it does not expose today (custom YAML policies, bring-your-own
|
||||
> provider API keys, custom egress controls).
|
||||
|
||||
## Who this is for
|
||||
|
||||
This guide assumes you're new to both omnigent and Databricks. Each
|
||||
section starts with a short context paragraph, then the concrete
|
||||
commands. If you already use both, skim the quick start at the top of
|
||||
each section.
|
||||
|
||||
## What you get
|
||||
|
||||
When omnigent runs on Databricks with the four integration points
|
||||
wired:
|
||||
|
||||
- **Single trace per agent turn.** Every span the agent emits lands
|
||||
in MLflow Tracing in Unity Catalog with the standard OpenTelemetry
|
||||
GenAI semantic-convention attributes (`gen_ai.operation.name`,
|
||||
`gen_ai.agent.name`, `gen_ai.provider.name`, `gen_ai.request.model`,
|
||||
`tool.name`). Searchable, filterable, retained per UC governance.
|
||||
- **Per-key LLM cost and audit.** Every LLM call (whether to
|
||||
Mosaic AI Foundation Models, OpenAI, Anthropic, or a custom
|
||||
endpoint) flows through Mosaic AI Gateway. Per-key cost tracking,
|
||||
rate limits, PII guardrails, audit logs, all enforced at the
|
||||
gateway. Switching providers or rotating keys is a Gateway config
|
||||
change, not an agent change.
|
||||
- **Managed runtime with workspace identity.** The omnigent server
|
||||
runs on Databricks Apps with the workspace SSO as the user
|
||||
identity. No separate auth to set up.
|
||||
- **Lakehouse-native state.** Conversation state, agent bundles, and
|
||||
executor snapshots persist in Lakebase Postgres and Unity Catalog
|
||||
Volumes. Lifecycle managed by the workspace.
|
||||
|
||||
> **A note on auth tier.** The audit + cost-tracking + guardrails
|
||||
> story above assumes API-key tier with the provider. Consumer
|
||||
> subscriptions like Anthropic Claude Max, OpenAI ChatGPT Plus, and
|
||||
> Cursor Pro use a per-user OAuth flow that the Gateway can't proxy.
|
||||
> See [Auth tier compatibility](#auth-tier-compatibility-api-key-vs-subscription--oauth)
|
||||
> in the Gateway section for the details and the org-deployment
|
||||
> guidance.
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
The integration is layered. The omnigent server runs on Databricks
|
||||
Apps. It exports OpenTelemetry traces (via the work landed in [PR
|
||||
#1050](https://github.com/omnigent-ai/omnigent/pull/1050)) to MLflow
|
||||
Tracing's OTLP receiver. Agent runs make LLM calls through Mosaic AI
|
||||
Gateway, which proxies to either Mosaic AI Foundation Models or an
|
||||
external provider (OpenAI, Anthropic) configured as an External Model.
|
||||
|
||||

|
||||
|
||||
The boundary stays sharp. omnigent itself remains a standalone Apache
|
||||
2.0 Python package. Each integration point is an env var or a config
|
||||
file, not a fork.
|
||||
|
||||
---
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. **A Databricks workspace** with the following enabled:
|
||||
- Databricks Apps
|
||||
- Unity Catalog
|
||||
- Mosaic AI Model Serving
|
||||
- MLflow Tracing in Unity Catalog (Public Preview as of 2026 H1)
|
||||
2. **The [Databricks CLI](https://docs.databricks.com/aws/en/dev-tools/cli/install)**
|
||||
installed and authenticated against your workspace. Either a CLI
|
||||
profile (`DATABRICKS_CONFIG_PROFILE=<profile>`) or env-based auth
|
||||
(`DATABRICKS_HOST` + `DATABRICKS_CLIENT_ID` + `DATABRICKS_CLIENT_SECRET`).
|
||||
3. **Python 3.11+** locally with `uv` installed (the omnigent project
|
||||
standard).
|
||||
4. **Workspace permissions** to:
|
||||
- Create or use a Unity Catalog catalog and schema for trace
|
||||
storage
|
||||
- Create a Databricks App (or use an existing one)
|
||||
- Query at least one Mosaic AI Foundation Model serving endpoint
|
||||
|
||||
Verify your CLI auth before continuing:
|
||||
|
||||
```bash
|
||||
databricks current-user me -p <your-profile>
|
||||
```
|
||||
|
||||
This should print your user object as JSON. If it errors with
|
||||
"Invalid access token", run `databricks auth login --profile <your-profile>`
|
||||
and try again.
|
||||
|
||||
---
|
||||
|
||||
## Quick start (5 minutes)
|
||||
|
||||
If you want to see the integration working before reading the full
|
||||
guide, this is the fastest path. It runs omnigent locally (not on
|
||||
Apps) but wires up Foundation Models + Gateway + tracing to the
|
||||
workspace.
|
||||
|
||||
```bash
|
||||
pip install 'omnigent[tracing]' openai
|
||||
|
||||
export DATABRICKS_HOST=https://<your-workspace>.cloud.databricks.com
|
||||
export DATABRICKS_TOKEN=<personal-access-token>
|
||||
|
||||
# Point omnigent at Mosaic AI Foundation Models as the LLM provider
|
||||
export OPENAI_BASE_URL=$DATABRICKS_HOST/serving-endpoints
|
||||
export OPENAI_API_KEY=$DATABRICKS_TOKEN
|
||||
|
||||
# Point omnigent's OTel exporter at the MLflow OTLP receiver in UC
|
||||
export MLFLOW_TRACKING_URI=databricks
|
||||
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=$DATABRICKS_HOST
|
||||
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $DATABRICKS_TOKEN"
|
||||
|
||||
# Sanity check the model endpoint works
|
||||
python -c "
|
||||
from openai import OpenAI
|
||||
import os
|
||||
c = OpenAI(base_url=os.environ['OPENAI_BASE_URL'], api_key=os.environ['OPENAI_API_KEY'])
|
||||
r = c.chat.completions.create(
|
||||
model='databricks-claude-sonnet-4',
|
||||
messages=[{'role':'user','content':'Reply with exactly: HELLO'}],
|
||||
max_tokens=5,
|
||||
)
|
||||
print(r.choices[0].message.content)
|
||||
print(f'input={r.usage.prompt_tokens} output={r.usage.completion_tokens}')
|
||||
"
|
||||
|
||||
# Start the local omnigent server with tracing
|
||||
omni server
|
||||
```
|
||||
|
||||
Verified output (run against e2-dogfood workspace):
|
||||
|
||||
```
|
||||
HELLO
|
||||
input=15 output=2
|
||||
```
|
||||
|
||||
Open the MLflow Traces UI in your workspace and you should see one
|
||||
trace per agent turn with the GenAI semconv attributes set.
|
||||
|
||||
The rest of this guide walks each piece in depth.
|
||||
|
||||
---
|
||||
|
||||
## 1. Deploy on Databricks Apps
|
||||
|
||||
### Context
|
||||
|
||||
Databricks Apps is a managed runtime for HTTP applications inside the
|
||||
Databricks workspace. omnigent's server is an HTTP app, so it fits
|
||||
natively. Apps gives you workspace SSO (the agent's user identity is
|
||||
the workspace identity), Lakebase Postgres for state, Unity Catalog
|
||||
Volumes for files, and access to all workspace data through the same
|
||||
identity.
|
||||
|
||||
### Quick deploy
|
||||
|
||||
The `deploy/databricks/` directory in the omnigent repository contains
|
||||
a complete Databricks Asset Bundle for deploying the omnigent server
|
||||
to Apps backed by Lakebase. Use it as-is for a first deploy:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/omnigent-ai/omnigent
|
||||
cd omnigent
|
||||
uv sync --extra databricks
|
||||
|
||||
# Set targets.prod.workspace.host in deploy/databricks/databricks.yml, then run
|
||||
# the deploy orchestrator — it builds the wheels and runs `databricks bundle
|
||||
# deploy` + `bundle run` for you:
|
||||
# uv run python deploy/databricks/deploy.py --app-name omnigent --profile <profile> ...
|
||||
# See deploy/databricks/README.md for the full command and required flags.
|
||||
```
|
||||
|
||||
The full deploy walkthrough (one-time Lakebase bootstrap, UC volume
|
||||
creation, service principal permissions) lives in
|
||||
[`deploy/databricks/README.md`](../deploy/databricks/README.md). Treat
|
||||
that as canonical for the deploy details. The rest of this page covers
|
||||
the integration knobs you set on top of that deploy.
|
||||
|
||||
### What you get on Databricks vs DIY
|
||||
|
||||
Self-hosting the omnigent server (e.g., on a VM or in a container)
|
||||
works fine. The Apps path adds:
|
||||
|
||||
| Capability | Self-hosted | Databricks Apps |
|
||||
|---|---|---|
|
||||
| Identity | You configure | Workspace SSO automatic |
|
||||
| State store | You manage Postgres | Lakebase, managed |
|
||||
| File / artifact store | You manage S3/GCS | UC Volumes, governed |
|
||||
| Scaling | You manage | App compute, billed per usage |
|
||||
| Audit logs | You wire up | UC audit, automatic |
|
||||
| Secret management | You manage | Workspace secrets |
|
||||
|
||||
---
|
||||
|
||||
## 2. Mosaic AI Foundation Models as LLM provider
|
||||
|
||||
### Context
|
||||
|
||||
Mosaic AI Foundation Model APIs expose curated LLMs (Anthropic Claude,
|
||||
Meta Llama, OpenAI GPT-OSS, Mistral, and others) behind a single,
|
||||
pay-per-token endpoint. The endpoints speak the OpenAI Chat
|
||||
Completions API, so any client that targets OpenAI also targets
|
||||
Databricks Foundation Models with two env vars.
|
||||
|
||||
omnigent's harnesses (`claude_sdk`, `openai_agents_sdk`, `pi`, and
|
||||
others) talk to LLMs via OpenAI-compatible HTTP. Pointing those
|
||||
harnesses at Foundation Models is the same two env vars.
|
||||
|
||||
### Setup
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL=$DATABRICKS_HOST/serving-endpoints
|
||||
export OPENAI_API_KEY=$DATABRICKS_TOKEN
|
||||
```
|
||||
|
||||
Then any omnigent agent spec that points at a `databricks-` model
|
||||
(for example `databricks-claude-sonnet-4`, `databricks-meta-llama-3-1-70b-instruct`,
|
||||
or `databricks-gpt-oss-20b`) resolves to a Foundation Model call.
|
||||
|
||||
### Verified call
|
||||
|
||||
```bash
|
||||
python -c "
|
||||
from openai import OpenAI
|
||||
import os
|
||||
c = OpenAI(base_url=os.environ['OPENAI_BASE_URL'], api_key=os.environ['OPENAI_API_KEY'])
|
||||
r = c.chat.completions.create(
|
||||
model='databricks-claude-sonnet-4',
|
||||
messages=[{'role':'user','content':'Reply with exactly two words: GATEWAY OK'}],
|
||||
max_tokens=10,
|
||||
)
|
||||
print(f'Response: {r.choices[0].message.content!r}')
|
||||
print(f'Usage: input={r.usage.prompt_tokens} output={r.usage.completion_tokens} total={r.usage.total_tokens}')
|
||||
print(f'Model: {r.model}')
|
||||
"
|
||||
```
|
||||
|
||||
Output (verified against e2-dogfood):
|
||||
|
||||
```
|
||||
Response: 'GATEWAY OK'
|
||||
Usage: input=16 output=6 total=22
|
||||
Model: global.anthropic.claude-sonnet-4-20250514-v1:0
|
||||
```
|
||||
|
||||
Note that `model` in the response is the backing model identifier
|
||||
(here, Anthropic Claude Sonnet 4 served through Bedrock). Your
|
||||
endpoint name (`databricks-claude-sonnet-4`) is the stable handle
|
||||
your agent spec uses.
|
||||
|
||||
### What you get on Databricks vs BYO API key
|
||||
|
||||
| Capability | BYO provider key | Foundation Models |
|
||||
|---|---|---|
|
||||
| Billing | Provider bills you directly | Bills as Databricks compute |
|
||||
| Per-key cost tracking | Provider dashboard | Workspace cost dashboard |
|
||||
| Audit logs | Provider-specific | UC audit, automatic |
|
||||
| Network isolation | Provider edge | Workspace network |
|
||||
| Model availability | Whatever you provision | Curated list, swap-in via endpoint config |
|
||||
|
||||
---
|
||||
|
||||
## 3. Mosaic AI Gateway as the LLM governance layer
|
||||
|
||||

|
||||
|
||||
### Context
|
||||
|
||||
This is the integration that matters most for production. Mosaic AI
|
||||
Gateway (configured via the **External Models** feature on Model
|
||||
Serving) lets you front any LLM provider (OpenAI, Anthropic,
|
||||
Foundation Models, custom endpoints) with a Databricks-managed
|
||||
endpoint that adds:
|
||||
|
||||
- Per-key cost tracking and rate limits
|
||||
- PII detection and other guardrails
|
||||
- Audit logs for every request and response
|
||||
- A stable endpoint URL even when you change providers behind it
|
||||
- Optional fallback to a backup provider on failure
|
||||
|
||||
omnigent doesn't need to know it's calling a Gateway. The Gateway
|
||||
endpoint speaks the OpenAI API, so the same `OPENAI_BASE_URL` knob
|
||||
that points omnigent at Foundation Models points it at the Gateway.
|
||||
|
||||
### Create an External Model endpoint
|
||||
|
||||
This is a one-time setup per provider key. Using the Databricks CLI:
|
||||
|
||||
```bash
|
||||
databricks serving-endpoints create --json '{
|
||||
"name": "production-openai-gateway",
|
||||
"config": {
|
||||
"served_entities": [{
|
||||
"name": "openai-production",
|
||||
"external_model": {
|
||||
"name": "gpt-4o-mini",
|
||||
"provider": "openai",
|
||||
"task": "llm/v1/chat",
|
||||
"openai_config": {
|
||||
"openai_api_key": "{{secrets/<scope>/openai-prod-key}}"
|
||||
}
|
||||
}
|
||||
}],
|
||||
"traffic_config": {
|
||||
"routes": [{"served_entity_name": "openai-production", "traffic_percentage": 100}]
|
||||
}
|
||||
},
|
||||
"ai_gateway": {
|
||||
"usage_tracking_config": {"enabled": true},
|
||||
"rate_limits": [{"calls": 1000, "key": "user", "renewal_period": "minute"}],
|
||||
"guardrails": {"input": {"pii": {"behavior": "BLOCK"}}, "output": {"pii": {"behavior": "BLOCK"}}}
|
||||
}
|
||||
}' -p <your-profile>
|
||||
```
|
||||
|
||||
The `openai_api_key` value uses a [Databricks workspace
|
||||
secret](https://docs.databricks.com/aws/en/security/secrets/) so the
|
||||
raw key never lives in the endpoint config.
|
||||
|
||||
After creation, the endpoint URL is:
|
||||
|
||||
```
|
||||
https://<your-workspace>.cloud.databricks.com/serving-endpoints/production-openai-gateway/invocations
|
||||
```
|
||||
|
||||
The OpenAI-style chat completions URL (what omnigent uses) is:
|
||||
|
||||
```
|
||||
https://<your-workspace>.cloud.databricks.com/serving-endpoints
|
||||
```
|
||||
|
||||
with the endpoint name passed as the `model` parameter on the request.
|
||||
|
||||
### Point omnigent at the Gateway endpoint
|
||||
|
||||
For each harness that calls LLMs (claude-sdk, openai-agents-sdk, pi,
|
||||
etc.), set:
|
||||
|
||||
```bash
|
||||
export OPENAI_BASE_URL=$DATABRICKS_HOST/serving-endpoints
|
||||
export OPENAI_API_KEY=$DATABRICKS_TOKEN
|
||||
```
|
||||
|
||||
Then in the omnigent agent spec, use the Gateway endpoint name as the
|
||||
model:
|
||||
|
||||
```yaml
|
||||
# agent.yaml
|
||||
name: my-agent
|
||||
executor:
|
||||
type: openai-agents-sdk
|
||||
model: production-openai-gateway # the Gateway endpoint, not "gpt-4o-mini"
|
||||
```
|
||||
|
||||
When the agent runs, every LLM call hits the Gateway. Cost tracking,
|
||||
guardrails, audit, and rate limits enforce automatically. Rotating the
|
||||
underlying OpenAI key is a Gateway config update with zero agent
|
||||
restart.
|
||||
|
||||
### Verified end-to-end
|
||||
|
||||
Created a real External Model endpoint on e2-dogfood that proxies
|
||||
through `databricks-model-serving` to `databricks-claude-sonnet-4`,
|
||||
then called it via the same OpenAI SDK pattern omnigent uses. The
|
||||
Gateway config included `usage_tracking_config.enabled=true` and a
|
||||
`60 calls/minute/user` rate limit.
|
||||
|
||||
```python
|
||||
from openai import OpenAI
|
||||
client = OpenAI(base_url=f'{DATABRICKS_HOST}/serving-endpoints', api_key=DATABRICKS_TOKEN)
|
||||
resp = client.chat.completions.create(
|
||||
model='omnigent-docs-test-gateway', # the Gateway endpoint, not the backing model
|
||||
messages=[{'role':'user','content':'Reply with exactly two words: PROXY OK'}],
|
||||
max_tokens=10,
|
||||
)
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
```
|
||||
Response: 'PROXY OK'
|
||||
Usage: input=16 output=6 total=22
|
||||
Model: global.anthropic.claude-sonnet-4-20250514-v1:0
|
||||
```
|
||||
|
||||
The `model` in the response is the backing model the Gateway forwarded
|
||||
to (Anthropic Claude Sonnet 4 via Bedrock). The Gateway endpoint name
|
||||
(`omnigent-docs-test-gateway`) is what omnigent calls. Swapping the
|
||||
backing model is a Gateway config update, zero change in the agent.
|
||||
|
||||
### Auth tier compatibility (API key vs subscription / OAuth)
|
||||
|
||||
Most coding-agent SDKs in this space support two auth flows. The
|
||||
distinction matters for what Gateway can actually intercept.
|
||||
|
||||
**API key tier.** Per-token billing, a header on every request, a key
|
||||
that lives in a workspace secret. The Gateway pattern proxies cleanly:
|
||||
the proxied call uses the standard provider endpoint, the workspace
|
||||
secret holds the key, every prompt and response flows through the
|
||||
workspace.
|
||||
|
||||
**Subscription / OAuth tier.** Flat-rate consumer subscriptions
|
||||
(Anthropic Claude Max, OpenAI ChatGPT Plus, Cursor Pro). The SDK
|
||||
authenticates via a browser OAuth flow against the consumer
|
||||
infrastructure. The resulting token is per-user, per-device, and
|
||||
short-lived. Gateway can't proxy these calls because the workspace
|
||||
secret can't hold a per-user OAuth token, and Gateway's outbound auth
|
||||
expects an API key, not a refreshable OAuth bearer. Even if the proxy
|
||||
were technically feasible, consumer subscriptions are positioned as
|
||||
individual products; orgs evaluating them for team use should verify
|
||||
the terms of service allow that pattern before relying on it.
|
||||
|
||||
| Auth tier | Works with Gateway proxy? | Workspace audit? | Central cost tracking? |
|
||||
|---|---|---|---|
|
||||
| API key (Anthropic API, OpenAI API, etc.) | Yes | Yes — every prompt / response in UC | Yes — Gateway `usage_tracking_config.enabled` |
|
||||
| Claude Max / ChatGPT Plus / Cursor Pro (OAuth subscription) | No | omnigent session metadata only; LLM calls invisible | No — billed to the individual's consumer account |
|
||||
|
||||
**Practical guidance for an org deployment.** Most enterprise
|
||||
Anthropic / OpenAI deals are API-tier with a volume agreement and an
|
||||
enterprise SLA. The Gateway story assumes that tier. If individual
|
||||
developers want to keep their consumer subscriptions for personal
|
||||
usage, that's fine, but those sessions sit outside the workspace
|
||||
audit and cost tracking.
|
||||
|
||||
If centralized governance is a hard requirement, the omnigent host
|
||||
can enforce API-key-only by setting the provider's API-key env var
|
||||
(`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, etc.) before invoking the SDK
|
||||
subprocess and refusing to launch the SDK in OAuth mode. With
|
||||
`ANTHROPIC_API_KEY` set, the Claude Agent SDK uses the API endpoint
|
||||
and the workspace key, not the OAuth flow. Same pattern for the
|
||||
OpenAI Agents SDK.
|
||||
|
||||
If mixed usage is acceptable, document the boundary explicitly:
|
||||
production / customer-facing work goes through the workspace
|
||||
(API tier + Gateway), individual experimentation can use consumer
|
||||
subscriptions but is out of band for compliance.
|
||||
|
||||
### What you get on Databricks vs raw provider calls
|
||||
|
||||
| Capability | Raw provider call from omnigent | Through AI Gateway |
|
||||
|---|---|---|
|
||||
| Cost tracking per agent / user | Build it yourself | Automatic, queryable in UC |
|
||||
| Rate limits per key | Provider-level, coarse | Per-key, configurable per minute / hour / day |
|
||||
| PII guardrails | Build a separate filter | Built-in, BLOCK or LOG behavior |
|
||||
| Audit logs | Provider-specific | UC audit, automatic, full request and response |
|
||||
| Provider swap | Code change | Gateway config update |
|
||||
| Fallback on provider failure | Build it yourself | Gateway route config |
|
||||
| Audit and cost across many keys | Manual reconciliation | One workspace dashboard |
|
||||
|
||||
---
|
||||
|
||||
## 4. MLflow Tracing in Unity Catalog
|
||||
|
||||

|
||||
|
||||
### Context
|
||||
|
||||
omnigent emits OpenTelemetry spans for every agent turn, LLM call, and
|
||||
tool invocation. The spans follow the OpenTelemetry GenAI semantic
|
||||
conventions (`gen_ai.operation.name`, `gen_ai.agent.name`,
|
||||
`gen_ai.provider.name`, `gen_ai.request.model`, `tool.name`) shipped
|
||||
in [PR #1050](https://github.com/omnigent-ai/omnigent/pull/1050). Any
|
||||
OTLP-compatible backend (Jaeger, Tempo, Datadog) can receive them.
|
||||
|
||||
MLflow Tracing exposes an OTLP/HTTP receiver at the MLflow tracking
|
||||
server. On Databricks, that receiver writes traces into a Unity
|
||||
Catalog table, governed per workspace UC policies. Operators get a
|
||||
search UI, retention policies, lineage, and the standard UC RBAC for
|
||||
free.
|
||||
|
||||
### Setup
|
||||
|
||||
Three env vars on the omnigent server:
|
||||
|
||||
```bash
|
||||
export OTEL_EXPORTER_OTLP_PROTOCOL=http/protobuf
|
||||
export OTEL_EXPORTER_OTLP_ENDPOINT=$DATABRICKS_HOST
|
||||
export OTEL_EXPORTER_OTLP_HEADERS="Authorization=Bearer $DATABRICKS_TOKEN"
|
||||
```
|
||||
|
||||
omnigent's `telemetry.init()` auto-detects the OTLP endpoint and wires
|
||||
up the MLflow OTel exporter. No code changes in the agent.
|
||||
|
||||
### What the trace looks like
|
||||
|
||||
For a single agent turn that calls one tool and one LLM:
|
||||
|
||||
```
|
||||
[agent:debby] (root span)
|
||||
gen_ai.operation.name = invoke_agent
|
||||
gen_ai.agent.name = debby
|
||||
gen_ai.provider.name = databricks
|
||||
gen_ai.request.model = databricks-claude-sonnet-4
|
||||
session.id = conv_e4f5a6b7c8d9e0f1
|
||||
task.id = resp_d8e9f0a1b2c3d4e5f6a7b8c9d0e1f2a3
|
||||
|
||||
[llm_call] (child span)
|
||||
gen_ai.operation.name = chat
|
||||
gen_ai.provider.name = databricks
|
||||
gen_ai.request.model = databricks-claude-sonnet-4
|
||||
gen_ai.usage.input_tokens = 1523
|
||||
gen_ai.usage.output_tokens = 847
|
||||
|
||||
[tool:calculator] (child span)
|
||||
gen_ai.operation.name = execute_tool
|
||||
tool.name = calculator
|
||||
tool.call_id = call_abc123
|
||||
```
|
||||
|
||||
The trace ID is the hex suffix of the response ID, so you can search
|
||||
for a specific request in MLflow by stripping the `resp_` prefix.
|
||||
|
||||
### Verified end-to-end
|
||||
|
||||
Emitted a synthetic trace from a local Python script (using the same
|
||||
`mlflow.start_span` API omnigent's `TracingContext` wraps) against the
|
||||
e2-dogfood Databricks workspace's MLflow OTLP receiver. Verified the
|
||||
trace landed in UC and the spans carry the expected attributes:
|
||||
|
||||
```
|
||||
Tracking URI: databricks
|
||||
Experiment: id=3163592711242134 path=/Users/.../omnigent-databricks-docs-verification
|
||||
Trace ID: tr-f13c03f61e44a0442c8865ab2c79e5a4
|
||||
Total traces found: 1
|
||||
trace_id: tr-f13c03f61e44a0442c8865ab2c79e5a4
|
||||
spans: 3
|
||||
'agent:debby' attrs: ['gen_ai.agent.name', 'gen_ai.operation.name',
|
||||
'gen_ai.provider.name', 'gen_ai.request.model']
|
||||
'llm_call' attrs: ['gen_ai.operation.name', 'gen_ai.provider.name',
|
||||
'gen_ai.request.model']
|
||||
'tool:calculator' attrs: ['gen_ai.operation.name', 'tool.name']
|
||||
```
|
||||
|
||||
The trace is queryable via `mlflow.search_traces()` and shows up in
|
||||
the workspace MLflow Traces UI at
|
||||
`/ml/experiments/<experiment_id>/traces/<trace_id>` per UC RBAC.
|
||||
|
||||
Workspace MLflow Traces UI (e2-dogfood) showing the verification trace
|
||||
in the experiment table:
|
||||
|
||||

|
||||
|
||||
Trace detail view with the `llm_call` and `tool:calculator` child spans
|
||||
expanded:
|
||||
|
||||

|
||||
|
||||
### Content capture and privacy
|
||||
|
||||
omnigent does not capture message bodies into traces by default. Set:
|
||||
|
||||
```bash
|
||||
export OMNIGENT_OTEL_CAPTURE_CONTENT=true
|
||||
```
|
||||
|
||||
to include user messages and tool arguments in `mlflow.spanInputs` /
|
||||
`mlflow.spanOutputs`. Leave unset for production unless you have
|
||||
explicit consent and PII handling in place.
|
||||
|
||||
### What you get on Databricks vs DIY OTel collector
|
||||
|
||||
| Capability | DIY OTLP backend | MLflow Tracing in UC |
|
||||
|---|---|---|
|
||||
| Persistent storage | You provision | Managed, UC-governed |
|
||||
| Search UI | You install Jaeger / Tempo / Grafana | MLflow Traces UI in workspace |
|
||||
| Retention policy | You configure | Per UC table policy |
|
||||
| RBAC | Backend-specific | UC, same as your tables |
|
||||
| Cross-trace correlation | Backend-specific | Built into MLflow eval |
|
||||
| Cost attribution | Build separately | Aligns with workspace cost reporting |
|
||||
|
||||
---
|
||||
|
||||
## Reference: env var summary
|
||||
|
||||
| Variable | Purpose | Where you set it |
|
||||
|---|---|---|
|
||||
| `DATABRICKS_HOST` | Workspace URL | Apps env or local shell |
|
||||
| `DATABRICKS_TOKEN` | Personal access token or service principal token | Apps env (Workspace Secret) or local shell |
|
||||
| `OPENAI_BASE_URL` | LLM provider endpoint, points at Foundation Models or AI Gateway | Apps env or harness spawn-env |
|
||||
| `OPENAI_API_KEY` | Auth for the above endpoint | Apps env or harness spawn-env |
|
||||
| `MLFLOW_TRACKING_URI` | Set to `databricks` for workspace-hosted MLflow | Apps env |
|
||||
| `OTEL_EXPORTER_OTLP_PROTOCOL` | Set to `http/protobuf` for the MLflow OTLP receiver | Apps env |
|
||||
| `OTEL_EXPORTER_OTLP_ENDPOINT` | Workspace URL (same as `DATABRICKS_HOST`) | Apps env |
|
||||
| `OTEL_EXPORTER_OTLP_HEADERS` | `Authorization=Bearer $DATABRICKS_TOKEN` | Apps env |
|
||||
| `OMNIGENT_OTEL_CAPTURE_CONTENT` | `true` to include message bodies in traces | Apps env (default off) |
|
||||
|
||||
---
|
||||
|
||||
## Roadmap
|
||||
|
||||
The four sections above cover the V1 integration. The omnigent +
|
||||
Databricks story extends further. Planned follow-ups, each as a
|
||||
separate PR:
|
||||
|
||||
- **Unity Catalog functions as agent tools.** A `databricks-tools`
|
||||
optional extra in omnigent that exposes UC functions as
|
||||
first-class agent tools with full UC governance and audit.
|
||||
- **Mosaic AI Vector Search as agent tool.** A vector search tool
|
||||
for RAG agents that uses UC-governed vector indexes.
|
||||
- **Mosaic AI Agent Evaluation integration.** Sample production
|
||||
traces from omnigent for the managed Mosaic AI Agent Evaluation
|
||||
pipeline.
|
||||
- **Inference Tables.** Auto-logged Mosaic AI Model Serving requests
|
||||
as a second observability path alongside OTel traces.
|
||||
- **Lakehouse Monitoring for agent drift.** Long-term drift
|
||||
detection on agent trace tables.
|
||||
|
||||
Track these on the [omnigent issues
|
||||
list](https://github.com/omnigent-ai/omnigent/issues) under the
|
||||
`databricks` label.
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### "Credential was not sent" on Foundation Models call
|
||||
|
||||
The OpenAI SDK needs an explicit `api_key` argument, not just the
|
||||
`OPENAI_API_KEY` env var, when used with a custom `base_url`. Pass it
|
||||
explicitly:
|
||||
|
||||
```python
|
||||
client = OpenAI(base_url=os.environ['OPENAI_BASE_URL'], api_key=os.environ['DATABRICKS_TOKEN'])
|
||||
```
|
||||
|
||||
### Traces not appearing in MLflow UI
|
||||
|
||||
1. Confirm `OTEL_EXPORTER_OTLP_ENDPOINT` matches your workspace URL
|
||||
exactly (no trailing slash).
|
||||
2. Confirm `OTEL_EXPORTER_OTLP_HEADERS` includes the Bearer token.
|
||||
3. Check `mlflow tracking get-uri` returns `databricks`.
|
||||
4. Run a small synthetic trace and watch for OTLP export errors in
|
||||
the omnigent server logs.
|
||||
|
||||
### Apps deployment fails with "permission denied for table agents"
|
||||
|
||||
This typically means a shared Lakebase project. Per
|
||||
[`deploy/databricks/README.md`](../deploy/databricks/README.md), use a
|
||||
fresh Lakebase project per omnigent app rather than sharing one.
|
||||
|
||||
### Gateway endpoint returns 403 "Invalid access token"
|
||||
|
||||
The External Model's underlying provider key (stored in workspace
|
||||
secrets) has expired or rotated. Update the secret value, then update
|
||||
the endpoint config via `databricks serving-endpoints update`.
|
||||
|
||||
---
|
||||
|
||||
## Provenance
|
||||
|
||||
This guide was authored by [Debu Sinha](https://github.com/debu-sinha)
|
||||
(Lead Applied AI/ML Engineer, Databricks Solutions Architecture).
|
||||
The MLflow Tracing integration section depends on the OTel
|
||||
observability series shipped in PRs [#1050](https://github.com/omnigent-ai/omnigent/pull/1050),
|
||||
[#1068](https://github.com/omnigent-ai/omnigent/pull/1068),
|
||||
[#1070](https://github.com/omnigent-ai/omnigent/pull/1070),
|
||||
[#1071](https://github.com/omnigent-ai/omnigent/pull/1071),
|
||||
[#1072](https://github.com/omnigent-ai/omnigent/pull/1072), and
|
||||
[#1083](https://github.com/omnigent-ai/omnigent/pull/1083).
|
||||
|
||||
Verified end-to-end against the e2-dogfood Databricks workspace
|
||||
(2026-06-24):
|
||||
|
||||
- Foundation Model call output (`databricks-claude-sonnet-4`,
|
||||
18 tokens in / 5 tokens out via CLI and 16 / 6 via OpenAI SDK)
|
||||
- OpenAI SDK pattern against `/serving-endpoints` with PAT auth
|
||||
- External Model (Gateway) endpoint created, called, and torn down:
|
||||
`omnigent-docs-test-gateway` proxied to `databricks-claude-sonnet-4`
|
||||
via `provider=databricks-model-serving`, with
|
||||
`ai_gateway.usage_tracking_config.enabled=true` and a
|
||||
`60 calls/minute/user` rate limit. CLI call returned `PROXY OK`,
|
||||
20 tokens. OpenAI SDK call returned `PROXY OK`, 22 tokens, model
|
||||
resolved to `global.anthropic.claude-sonnet-4-20250514-v1:0`.
|
||||
Endpoint and the supporting workspace secret were deleted after
|
||||
verification.
|
||||
- MLflow OTLP receiver pattern via a real synthetic-trace round-trip:
|
||||
experiment id `3163592711242134`, trace id
|
||||
`tr-f13c03f61e44a0442c8865ab2c79e5a4`, 3 spans with the expected
|
||||
`gen_ai.*` and `tool.*` attributes, fetched back via
|
||||
`mlflow.search_traces()`
|
||||
|
||||
The Apps deployment section links to `deploy/databricks/README.md`
|
||||
which is the canonical, already-merged recipe.
|
||||
|
||||
Maintenance and updates: open an issue with the `databricks` label, or
|
||||
ping @debu-sinha on the omnigent Slack channel.
|
||||
@@ -0,0 +1,421 @@
|
||||
# Harness test bench: a standardized capability conformance suite
|
||||
|
||||
A pluggable bench that, given a harness, empirically reports its verdict on
|
||||
every capability dimension in the harness support matrix — "is model switching
|
||||
available", "is steering possible", "does policy DENY actually block a call" —
|
||||
instead of a human hand-maintaining a spreadsheet and hoping it still reflects
|
||||
reality.
|
||||
|
||||
> **Status:** shipped and in use. The bench on `main` has three transport
|
||||
> drivers, six P0 probes, three report-only P1 probes, automatic live/offline
|
||||
> selection, and a capability-derived matrix that has already caught and
|
||||
> corrected real declaration drift. See
|
||||
> [Current state](#current-state-shipped) for what is live vs. still open. The
|
||||
> sections before it describe the design and the decisions behind it.
|
||||
|
||||
## Motivation
|
||||
|
||||
We maintain a capability matrix by hand (the native + SDK support
|
||||
spreadsheet). It drifts the moment a harness changes and nobody re-tests every
|
||||
cell. Worse, there are already **three disagreeing sources of truth** for any
|
||||
given capability:
|
||||
|
||||
1. **The spreadsheet** — what a human believed at some point.
|
||||
2. **The `Executor` capability flags** — `supports_streaming()`,
|
||||
`supports_live_message_queue()`, `supports_tool_boundary_interrupt()`,
|
||||
`supports_stepwise_internal_turns()`, `handles_tools_internally()` in
|
||||
`omnigent/inner/executor.py:541`.
|
||||
3. **`omnigent/model_override.py`** — already encodes per-harness facts
|
||||
declaratively (`_SDK_MODEL_OVERRIDE_HARNESSES`, the `_*_FAMILY_HARNESSES`
|
||||
sets, single- vs multi-model rules).
|
||||
|
||||
The bench turns the matrix into an **executable conformance suite** that earns
|
||||
each cell by running a live turn and inspecting the event stream, and then
|
||||
**reconciles observed behavior against the declared flags** — so a flag that
|
||||
says `✓` but behaves `✗` becomes a test failure (a `DRIFT` verdict), not a
|
||||
production surprise.
|
||||
|
||||
## Goals
|
||||
|
||||
- One command produces the support matrix for a harness, with a verdict per
|
||||
dimension.
|
||||
- Adding a new *official* harness needs at most a one-line registry entry plus a
|
||||
self-declared profile — never per-probe code.
|
||||
- A *community* / out-of-repo harness that ships a bench profile can be probed
|
||||
with `--harness <name>` and no bench edits.
|
||||
- Detect drift between what a harness *declares* and what it *does*.
|
||||
|
||||
## Non-goals
|
||||
|
||||
- Not a replacement for the existing per-harness e2e tests (those assert
|
||||
specific behaviors deeply; the bench asserts breadth across dimensions).
|
||||
- Not a performance/latency benchmark. "Bench" here means conformance, not
|
||||
throughput.
|
||||
- The bench does not invent model ids, credentials, or transports. Facts it
|
||||
cannot infer must be self-declared by the harness (see `BenchProfile`).
|
||||
|
||||
## Key constraint: registration is a hardcoded dict today
|
||||
|
||||
Harnesses register via a literal `_HARNESS_MODULES: dict[str, str]` mapping
|
||||
harness name to module path in `omnigent/runtime/harnesses/__init__.py:34`.
|
||||
There is **no entry-point / plugin discovery** mechanism. An out-of-repo harness
|
||||
cannot even register without editing that file — the same shared-file conflict
|
||||
pain tracked in #899, whose proposed fix was per-harness self-registration.
|
||||
|
||||
This constraint is what shapes the coupling decision below. It is *not* a limit
|
||||
on what the bench can probe: the probes are harness-agnostic. It is only a limit
|
||||
on how a harness gets *discovered*.
|
||||
|
||||
> **Update since this was written:** entry-point plugin discovery now exists —
|
||||
> `harness_capabilities()` merges contributions from the
|
||||
> `omnigent.community.harness` entry-point group, and the bench derives
|
||||
> everything from it. So the bench side of option B is realized: a plugin's
|
||||
> harness flows in with no bench edit. The remaining hardcoded seam is *not*
|
||||
> here — it is the server's native-agent seeding (see "Plugin seamlessness").
|
||||
|
||||
## Decision: option B (registry-indexed now, profile-driven from day one)
|
||||
|
||||
Two coupling options were considered:
|
||||
|
||||
- **(A)** Build the bench on dynamic discovery (entry points / plugin registry)
|
||||
now. True plug-and-play, but partly blocked on self-registration (#899) before
|
||||
out-of-repo harnesses can register at all.
|
||||
- **(B)** Index official harnesses from the current hardcoded registry now
|
||||
(one registry line + a profile per new harness), and swap enumeration to
|
||||
dynamic discovery when self-registration lands.
|
||||
|
||||
**We chose B**, with the critical rule that **all per-harness facts live on a
|
||||
self-declared `BenchProfile` from day one** — never in bench or probe code. The
|
||||
hardcoded list is just a convenience index of the official harnesses; it is not
|
||||
a gate on what the bench can probe.
|
||||
|
||||
This satisfies both use cases:
|
||||
|
||||
- **Official harnesses** — already in `_HARNESS_MODULES`; the bench iterates the
|
||||
list and `--harness <name>` selects one.
|
||||
- **Community harnesses** — `--harness <name>` resolution falls back (when the
|
||||
name is not in the registry) to any harness that exposes a `BenchProfile`
|
||||
(e.g. `--harness mypkg.myharness`, or a name resolved via an installed
|
||||
plugin). A ~10-line resolution shim, not the full discovery system.
|
||||
|
||||
The day self-registration lands, only the *enumeration* changes from "read the
|
||||
list" to "discover"; probes, profiles, and reports are untouched.
|
||||
|
||||
## What is free vs. what a harness must provide
|
||||
|
||||
**Free (no per-harness code):**
|
||||
|
||||
- Every **behavioral probe** — it creates a session, runs a turn, and inspects
|
||||
the generic event stream (`TextChunk`, `ReasoningChunk`, `ToolCallRequest`,
|
||||
`TurnComplete`, elicitation events). It never names a harness. Any harness the
|
||||
bench can launch is probed on every dimension; a dimension with no probe yet
|
||||
reports `UNKNOWN`.
|
||||
- **Declared-flag reconciliation** — reads the `Executor` capability methods
|
||||
that every harness inherits from the base class.
|
||||
|
||||
**Must be self-declared on `BenchProfile` (the bench cannot infer these):**
|
||||
|
||||
- A **test model** (or model family) — a probe cannot invent a valid model id.
|
||||
- The **CLI binary** to skip-gate on (for subprocess / native harnesses).
|
||||
- The **transport class** (see transport drivers below).
|
||||
- The **static columns** the matrix records but cannot verify: Owner, Auth
|
||||
method, Implementation, "inherits preexisting configs", priority tier.
|
||||
|
||||
**Derived by convention (not hand-authored):** `env_prefix`
|
||||
(`HARNESS_<NAME>_`), `marker` (`<NAME>_BENCH_OK`).
|
||||
|
||||
## Architecture
|
||||
|
||||
The implementation has three layers plus reporting:
|
||||
|
||||
```
|
||||
tests/harness_bench/
|
||||
profile.py # BenchProfile and profile-name resolution
|
||||
manifest.py # official profiles derived from capabilities + e2e metadata
|
||||
verdict.py # verdict vocabulary, priority, and drift reconciliation
|
||||
transport.py # semantic Driver protocol and transport resolution
|
||||
driver.py # sdk-inproc driver + shared TurnResult/usage helpers
|
||||
full_server.py # shared server/runner lifecycle and registration
|
||||
full_server_driver.py # full-server driver and session polling
|
||||
native_tui_driver.py # native vendor CLI + host-daemon/tmux driver
|
||||
session_items.py # shared session-item envelope parsing
|
||||
runtime_env.py # config/credential resolution matching `omni run`
|
||||
probes/ # one module per capability dimension
|
||||
events.py # structured progress events and plain sink
|
||||
richreport.py # optional live Rich matrix
|
||||
bench.py # orchestration, concurrency, and shared-server wiring
|
||||
report.py # terminal, Markdown, and JSON rendering
|
||||
```
|
||||
|
||||
Reusable configuration and runtime primitives live in production modules such
|
||||
as `omnigent.config`, `find_free_port`, and the harness registry rather than
|
||||
being reimplemented under tests.
|
||||
|
||||
- **Layer 0 — Profile / manifest.** Static facts and declared verdicts are
|
||||
derived from `harness_capabilities()` plus the existing e2e harness metadata.
|
||||
- **Layer 1 — Offline conformance.** No network or credentials. It validates
|
||||
registration, profile shape, capability derivation, transport resolution,
|
||||
rendering, and orchestration behavior in normal CI.
|
||||
- **Layer 2 — Live probes.** Drivers execute behavioral probes through the
|
||||
wrap boundary or the real server/runner session API. Missing credentials,
|
||||
vendor binaries, or vendor login produce capability-neutral skips.
|
||||
- **Report.** The CLI renders the declared matrix offline or reconciles live
|
||||
observations into terminal, Markdown, and JSON reports. `DRIFT` produces a
|
||||
non-zero exit status.
|
||||
|
||||
### Build on `HarnessProbe`, don't reinvent it
|
||||
|
||||
`tests/e2e/_harness_probes.py` already gives per-harness rows
|
||||
(name, model, env_prefix, marker, cli_binary) and CLI-gating that every e2e test
|
||||
parametrizes over. `BenchProfile` should extend / subsume that row so adding a
|
||||
harness there flows into both the existing e2e suite and the bench.
|
||||
|
||||
## Verdict vocabulary
|
||||
|
||||
Maps directly to the spreadsheet glyphs, plus two operational states and the
|
||||
drift alarm.
|
||||
|
||||
| Verdict | Glyph | Meaning |
|
||||
|---|---|---|
|
||||
| `SUPPORTED` | ✓ | probe ran, behavior confirmed |
|
||||
| `UNSUPPORTED` | ✗ | probe ran, capability absent (and expected absent) |
|
||||
| `PARTIAL` | ~ | works with caveats (e.g. "TUI-only", "hook-DENY only") |
|
||||
| `NOT_APPLICABLE` | — | dimension does not apply (e.g. model override on agy self-select) |
|
||||
| `UNKNOWN` | ? | never probed / no probe written yet |
|
||||
| `SKIPPED` | | CLI / creds / transport unavailable in this environment |
|
||||
| `DRIFT` | !! | observed verdict disagrees with the declared flag / manifest |
|
||||
|
||||
Each dimension also carries a `P0` / `P1` priority (from the spreadsheet) so CI
|
||||
can gate on P0 and merely report P1.
|
||||
|
||||
## Dimension catalog
|
||||
|
||||
Two classes.
|
||||
|
||||
### Static / declared (recorded, not probed)
|
||||
|
||||
Validated for presence and shape only: `Owner`, `Transport`, `Implementation`,
|
||||
`Auth` method, `Inherits preexisting configs`.
|
||||
|
||||
### Behavioral (proven by a live turn)
|
||||
|
||||
| Dimension | How the probe proves it |
|
||||
|---|---|
|
||||
| Basic turn (P0 prerequisite) | complete a marker-echo turn and require assistant text |
|
||||
| Streaming (P0) | count output-text deltas; repeated single-delta output is `PARTIAL` |
|
||||
| Tool calling (P0) | provoke the transport's tool mechanism and require a surfaced call |
|
||||
| Policy DENY (P0) | apply a tool-call deny and require a blocked-call signal |
|
||||
| Policy ALLOW (P1) | attach an explicit allow and require a non-blocked tool output; native hooks expose no positive ALLOW event |
|
||||
| Policy ASK (P1) | apply ask and require an elicitation/approval request |
|
||||
| Model override (P0) | validate the requested harness/model pair and complete a turn |
|
||||
| Cost tracking (P1) | read priced cost or token usage from the turn/session |
|
||||
| Interrupt (P0) | interrupt a long turn and require cancellation or early termination |
|
||||
|
||||
Planned dimensions are steering, live queue, resume/fork, reasoning, images,
|
||||
and compaction.
|
||||
|
||||
Every behavioral probe also reads the corresponding declared flag and returns
|
||||
`DRIFT` when observed disagrees with declared.
|
||||
|
||||
### Illustrative probe shape
|
||||
|
||||
```python
|
||||
class StreamingProbe(CapabilityProbe):
|
||||
name = "streaming"
|
||||
priority = P0
|
||||
applies_to = BOTH
|
||||
|
||||
def declared(self, profile) -> Verdict:
|
||||
return SUPPORTED if executor_of(profile).supports_streaming() else UNSUPPORTED
|
||||
|
||||
async def run(self, driver, profile) -> ProbeResult:
|
||||
deltas = await driver.count_text_chunks("Write a 3-sentence story.")
|
||||
observed = SUPPORTED if deltas > 1 else PARTIAL # "complete-only"
|
||||
return ProbeResult(
|
||||
observed,
|
||||
note=f"{deltas} text chunks",
|
||||
drift=reconcile(observed, self.declared(profile)),
|
||||
)
|
||||
```
|
||||
|
||||
## Transport drivers: the real ceiling on "all dimensions"
|
||||
|
||||
Behavioral probes call semantic driver methods such as `run_basic_turn`,
|
||||
`run_tool_turn`, `run_policy_turn`, and `run_interrupt_turn`. Drivers own the
|
||||
transport-specific mechanism; probes interpret a common `TurnResult`.
|
||||
|
||||
Three drivers exist:
|
||||
|
||||
- `full-server` is the SDK-family default. It drives a real server and runner,
|
||||
uses a server-dispatched builtin for tool probes, and observes fixed
|
||||
ALLOW/ASK/DENY policies.
|
||||
- `native-tui` drives a resident vendor CLI in a runner-owned tmux pane through
|
||||
the server session API. It observes vendor tool calls and tool-call DENY via
|
||||
the native policy hook. ALLOW/ASK are not yet implemented.
|
||||
- `sdk-inproc` drives the harness wrap directly. It is selected by `--fast` and
|
||||
provides cheaper wrap-level coverage, but no server-side policy surface.
|
||||
|
||||
A `SKIPPED` verdict therefore means the behavior was not measurable in that
|
||||
transport or environment, not that the harness lacks the capability. A novel
|
||||
transport class still requires a driver, but harnesses reusing one of these
|
||||
families flow through the existing probes without per-harness probe code.
|
||||
|
||||
## Current state (shipped)
|
||||
|
||||
The bench on `main` includes:
|
||||
|
||||
- **Six P0 probes:** Basic turn, Streaming, Tool calling, Policy DENY, Model
|
||||
override, and Interrupt.
|
||||
- **Three P1 probes:** Policy ALLOW, Policy ASK, and Cost tracking. P1 verdicts
|
||||
are report-only and do not gate the same way as P0 declarations.
|
||||
- **Three transport drivers:** `full-server`, `native-tui`, and `sdk-inproc`,
|
||||
selected by harness family with `--transport` and `--fast` overrides.
|
||||
- **Automatic live selection:** without an explicit mode, the CLI runs live
|
||||
when credentials are resolvable and otherwise renders the declared matrix.
|
||||
`--live` and `--no-live` force either mode. Credentials are derived like
|
||||
`omni run`; `--profile` is only an override.
|
||||
- **Concurrent execution and shared infrastructure:** `--jobs` runs harnesses
|
||||
concurrently while preserving report order, and full-server harnesses share
|
||||
one server/runner pair within a run.
|
||||
- **Structured progress and reports:** plain or Rich live progress plus terminal,
|
||||
Markdown, JSON, and optional report-file output.
|
||||
- **Capability-derived registration:** official SDK and native profiles derive
|
||||
from `harness_capabilities()` and existing e2e metadata. Session-item parsing,
|
||||
config loading, free-port selection, and polling helpers are shared rather
|
||||
than duplicated.
|
||||
|
||||
### Not yet wired
|
||||
|
||||
- Registry-driven server seeding for community native UI agents.
|
||||
- Steering, live queue, resume/fork, reasoning, images, and compaction probes.
|
||||
- Automatic provisioning of vendor login/provider configuration for native
|
||||
harnesses; unavailable environments skip cleanly.
|
||||
|
||||
## CI integration
|
||||
|
||||
- **Every PR:** Layer 1 offline conformance (fast, no network, no creds).
|
||||
- **Nightly / on-demand:** Layer 2 live probes (real API cost + flake surface),
|
||||
gated on CLI + creds, P0 blocking, P1 report-only. Follows the existing
|
||||
nightly/flake-stress pattern rather than blocking every PR on live turns.
|
||||
|
||||
## Running the bench and reading the result
|
||||
|
||||
```
|
||||
# Declared matrix only, with no credentials.
|
||||
python -m tests.harness_bench --no-live
|
||||
|
||||
# Auto-live when configured or ambient credentials are available.
|
||||
python -m tests.harness_bench --harness codex
|
||||
|
||||
# Force a named profile and probe several harnesses concurrently.
|
||||
python -m tests.harness_bench --profile oss --jobs 4 --rich
|
||||
|
||||
# A community harness that ships its own BenchProfile.
|
||||
python -m tests.harness_bench --harness mypkg.harness:PROFILE --live
|
||||
```
|
||||
|
||||
Without `--live` or `--no-live`, resolvable credentials select live mode and
|
||||
missing credentials select the offline declared matrix. Native harnesses also
|
||||
need their vendor CLI installed and logged in; the bench cannot provision those
|
||||
accounts, so unavailable harnesses skip without aborting the run.
|
||||
|
||||
Offline conformance covers every registered harness in CI. Live runs are
|
||||
spot-checks of observed behavior and can vary with model behavior and timing;
|
||||
re-run an isolated timeout or skip before treating it as a regression. The
|
||||
signals that matter most are `DRIFT` and repeatable unexpected
|
||||
`UNSUPPORTED`/`PARTIAL` verdicts on a runnable harness.
|
||||
|
||||
## Streaming is a binary declared capability
|
||||
|
||||
A recurring subtlety worth stating: the `streaming` capability is **binary** —
|
||||
a harness either forwards token-level deltas (`SUPPORTED`) or it does not
|
||||
(`UNSUPPORTED`). `PARTIAL` is a *probe observation only*: the streaming probe
|
||||
returns it for the ambiguous coalesced-single-delta case against a `SUPPORTED`
|
||||
declaration. It is **never a declared value**. Declaring a non-streaming
|
||||
harness as `PARTIAL` drifts against reality, because the probe reports zero
|
||||
deltas as `UNSUPPORTED`, not `PARTIAL`.
|
||||
|
||||
**Declare `streaming=False` only from a live observation of 0 deltas** — a
|
||||
static "the forwarder posts no delta" grep is *not* sufficient. That grep once
|
||||
flipped seven natives to `False` in one batch; a live run then showed
|
||||
pi-native streams (7 deltas) despite having no delta-posting forwarder, so the
|
||||
flip was reverted. Only three natives are declared non-streaming today, each
|
||||
live-verified at 0 deltas: **kiro-native, cursor-native, qwen-native**. The
|
||||
rest default to `streaming=True` (the honest default: if one turns out not to
|
||||
stream, the bench flags a real drift on the next run, rather than a false
|
||||
`False` that silently drifts the moment the harness *does* stream).
|
||||
|
||||
## Which transport exercises which dimension
|
||||
|
||||
| Dimension | `sdk-inproc` (`--fast`) | `full-server` (SDK default) | `native-tui` |
|
||||
|---|---|---|---|
|
||||
| Basic turn, Streaming, Model override, Interrupt | Wrap-level observation | End-to-end server/runner observation | End-to-end server/runner/vendor observation |
|
||||
| Tool calling | Request-level wrap tool | Server-dispatched builtin | Vendor tool mirrored into session items |
|
||||
| Policy DENY | Not observable | Fixed policy blocks the builtin | Session CEL policy triggers the native policy hook |
|
||||
| Policy ALLOW / ASK | Not observable | Fixed policy; ASK observes and resolves an elicitation | Temporary session CEL policy; ASK observes and resolves an elicitation |
|
||||
| Cost tracking | Completed-response usage when forwarded | Session snapshot usage/cost | Session snapshot when the vendor forwards usage |
|
||||
|
||||
`full-server` remains the SDK default because it covers the deployed server
|
||||
path and all three policy actions. `--fast` trades that policy coverage for
|
||||
lower startup cost. `native-tui` now has real Tool calling and all three policy
|
||||
action probes through the native hook path.
|
||||
|
||||
## Plugin seamlessness: where it is and isn't
|
||||
|
||||
The original goal (option B) was that a *community* harness ships a
|
||||
`BenchProfile` and runs with `--harness <name>` and no bench edits. For the
|
||||
**bench itself, that holds**: profile resolution, capability derivation, and
|
||||
`native_vendor()` all read `harness_capabilities()`, which discovers community
|
||||
plugins via entry points. A plugged-in harness needs zero bench code to be
|
||||
recognized.
|
||||
|
||||
The seam is **one level down, in the omnigent server**. A native harness is
|
||||
only drivable once the server has seeded a built-in `<harness>-native-ui`
|
||||
agent, and that seeding is a **hardcoded list** in
|
||||
`server/app.py:_ensure_default_agents` — one `_ensure_default_<harness>_agent()`
|
||||
call per harness. goose-native and hermes-native were in the capability
|
||||
registry but omitted from that list, so the bench (correctly) reported them
|
||||
`not auto-registered on the server` until the seeders were added.
|
||||
|
||||
So: **the bench is plugin-seamless; the server's native-agent seeding is not,
|
||||
and the bench inherits that seam.** A community native plugin today resolves in
|
||||
the bench, then fails at registration because nothing seeds its UI agent. The
|
||||
clean fix is to make `_ensure_default_agents` iterate `native_agents()` from
|
||||
the registry (which already includes plugins) instead of a hardcoded call list
|
||||
— then native harnesses and plugins register automatically. This is the highest
|
||||
-leverage remaining item: it is the difference between "the bench is plugin-
|
||||
ready" and "a plugged-in native harness works end to end".
|
||||
|
||||
## The self-enforcing table in practice (drift case studies)
|
||||
|
||||
`reconcile()` turns a false capability declaration into a `DRIFT`. This is not
|
||||
theoretical — the bench caught several real declaration errors this way, each
|
||||
resolved by correcting the *source* (the capability model), not the bench:
|
||||
|
||||
- **kiro-native / streaming.** Declared `SUPPORTED`, observed 0 deltas
|
||||
(`!!✓>✗`). kiro mirrors each complete assistant message rather than streaming
|
||||
tokens. Corrected to `streaming=False`.
|
||||
- **pi-native / streaming (a fixed over-correction).** A static grep had flipped
|
||||
pi to `False`; a live run showed it streams 7 deltas (`!!✗>✓`) despite having
|
||||
no delta-posting forwarder. Reverted to `True`. This is why the rule is
|
||||
"declare `False` only from a live 0-delta observation" — the grep lied.
|
||||
- **cursor-native / streaming + provisioning.** cursor could not provision at
|
||||
all until the `lazy_chat` fix (its `external_session_id` is created by the
|
||||
first message, not at launch, so gating on it pre-turn deadlocked). Once
|
||||
runnable, it observed 0 deltas → `streaming=False`.
|
||||
- **qwen-native / streaming.** Observed 0 deltas → `streaming=False`.
|
||||
|
||||
The pattern each time: the bench detects the mismatch, a live probe pins which
|
||||
side is wrong, and the capability model is corrected — not the bench massaged to
|
||||
agree with it.
|
||||
|
||||
## Open items
|
||||
|
||||
- **Registry-driven native-agent seeding** — replace the hardcoded server
|
||||
seeding list with registry iteration so community native harnesses work end
|
||||
to end after plugin installation.
|
||||
- **Per-harness native provisioning** — some vendors require login or provider
|
||||
configuration that the bench deliberately cannot create. Improve diagnostics
|
||||
where possible while retaining clean skips.
|
||||
- **Additional dimensions** — steering, live queue, resume/fork, reasoning,
|
||||
images, and compaction.
|
||||
|
After Width: | Height: | Size: 191 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 107 KiB |
|
After Width: | Height: | Size: 140 KiB |
|
After Width: | Height: | Size: 232 KiB |
|
After Width: | Height: | Size: 977 KiB |
|
After Width: | Height: | Size: 1.5 MiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,44 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M208.314 574.639L207.775 574.256C200.279 568.835 188.565 557.989 181.44 551.295C146.313 518.29 100.931 480.158 90.8455 430.852C84.1266 398.005 107.268 372.184 138.92 367.367C172.339 362.281 200.387 376.193 231.657 384.892C266.688 394.637 313.524 404.556 348.456 390.123C386.67 374.334 398.114 315.034 408.23 278.633C412.496 263.73 416.887 248.862 421.401 234.032C434.557 189.666 457.748 108.663 511.512 98.8481C537.704 95.2681 554.804 110.543 568.668 130.231C583.499 151.286 587.863 170.243 596.376 193.36C614.434 242.395 620.239 312.72 647.301 356.702C652.208 364.709 658.611 371.695 666.16 377.278C701.071 402.818 762.618 386.831 799.582 372.604C810.401 368.413 821.107 363.934 831.682 359.172C857.388 347.543 889.292 330.628 916.148 349.956C925.853 356.942 932.715 371.654 933.473 383.419C938.309 458.672 880.367 516.694 828.371 561.885L804.227 582.642C791.561 593.479 781.987 601.127 772.211 615.046C749.499 647.394 760.027 680.915 771.659 714.956C784.409 752.252 800.162 789.474 802.52 829.328C803.928 854.006 801.851 883.098 786.177 903.439C777.085 915.244 763.713 921.657 749.022 923.069C691.683 928.018 635.234 880.581 591.446 848.996C575.758 837.682 557.176 823.356 539.688 816.667C529.716 812.762 518.907 811.499 508.308 813.01C481.254 816.756 456.192 841.906 434.594 859.038C391.171 893.491 331.131 939.08 272.085 920.838C249.715 913.93 237.89 886.456 236.686 865.189C235.154 841.695 238.73 815.601 242.662 792.35C242.177 791.976 240.904 791.359 240.328 791.167C228.207 787.093 218.215 796.752 209.381 802.944C195.642 812.575 169.172 833.066 151.552 825.577C124.923 814.268 143.15 769.769 146.622 749.165C147.382 744.671 148.893 739.806 148.244 735.321C147.709 731.612 146.163 728.127 143.778 725.237C140.235 720.868 135.317 717.865 130.829 714.236C118.261 704.077 100.06 692.281 92.8478 677.487C90.3487 672.361 90.0943 665.35 92.4409 660.181C102.426 640.088 135.569 639.035 154.679 636.566C172.695 634.237 187.362 635.644 193.133 614.812C194.091 611.356 195.963 606.973 197.169 603.489C200.545 593.727 204.056 584.05 208.314 574.639Z" fill="#F53B9D"/>
|
||||
<path d="M208.315 574.639C209.839 569.041 218.811 557.539 224.681 554.841C240.202 547.698 250.629 557.885 257.772 570.622C268.032 588.923 270.436 609.77 281.298 627.347C283.281 630.556 288.578 633.371 292.108 634.419C301.252 636.926 310.507 636.963 319.757 637.772C333.108 638.937 351.253 641.252 362.6 648.334C366.207 650.583 369.422 654.54 371.932 657.721C373.598 668.708 373.739 674.189 366.806 683.937C361.796 690.985 355.395 696.57 349.367 702.65C345.552 705.386 343.834 706.49 340.717 709.989C314.826 730.915 318.282 734.699 327.379 766.13C330.104 775.55 330.483 784.198 332.572 793.014L333.201 798.608C332.523 802.467 331.724 809.82 330.121 812.921C320.793 829.169 305.684 826.419 290.775 820.619C283.367 818.257 265.363 806.321 258.424 801.765C252.802 798.08 249.431 794.736 242.662 792.35C242.177 791.976 240.905 791.359 240.329 791.167C228.207 787.093 218.215 796.751 209.382 802.944C195.643 812.574 169.172 833.065 151.552 825.577C124.924 814.268 143.151 769.768 146.623 749.165C147.383 744.67 148.894 739.806 148.245 735.321C147.709 731.612 146.164 728.127 143.778 725.237C140.236 720.868 135.317 717.865 130.829 714.236C118.262 704.077 100.061 692.281 92.8483 677.487C90.3493 672.361 90.0949 665.349 92.4414 660.181C102.426 640.088 135.57 639.035 154.68 636.566C172.695 634.236 187.362 635.644 193.133 614.812C194.091 611.355 195.963 606.973 197.169 603.488C200.545 593.727 204.056 584.05 208.315 574.639Z" fill="#4DC5A0"/>
|
||||
<path d="M168.773 776.494C190.268 775.269 183.762 803.431 167.716 807.841C164.872 807.925 162.22 808.145 159.681 806.887C149.284 801.742 153.674 787.402 160.63 781.204C163.814 778.365 165.011 777.776 168.773 776.494Z" fill="#26A079"/>
|
||||
<path d="M296.029 776.514C299.113 776.242 300.47 776.121 303.369 777.355C308.901 779.764 313.268 784.245 315.535 789.834C318.553 797.453 317.625 804.394 309.36 807.341C307.811 807.514 306.25 807.566 304.693 807.495C292.738 806.995 288.186 793.281 288.77 783.09C291.066 779.68 292.604 778.557 296.029 776.514Z" fill="#26A079"/>
|
||||
<path d="M230.848 572.221C233.186 572.039 235.563 572.128 237.913 572.16C247.387 577.918 247.575 592.815 237.042 597.628C221.037 601.257 219.916 575.851 230.848 572.221Z" fill="#26A079"/>
|
||||
<path d="M114.038 660.12C118.182 659.905 121.248 659.554 125.046 661.659C131.584 665.279 132.092 671.261 128.681 677.276C127.274 678.235 125.489 679.227 124.004 680.101C109.514 682.693 101.139 666.594 114.038 660.12Z" fill="#26A079"/>
|
||||
<path d="M344.327 659.611C347.702 659.395 350.255 659.69 353.484 660.635C360.343 668.712 359.409 676.664 348.605 680.125C333.222 680.733 330.102 665.756 344.327 659.611Z" fill="#26A079"/>
|
||||
<path d="M219.422 721.238C227.414 720.985 223.054 728.908 229.339 732.566C231.675 733.796 236.149 733.618 238.497 732.781C244.56 730.625 241.31 722.688 248.143 720.747C252.164 721.645 251.634 726.303 250.894 729.343C250.407 730.681 249.764 732.243 248.955 733.403C246.147 737.5 241.735 740.209 236.814 740.868C231.91 741.584 226.931 740.199 223.1 737.056C218.536 733.333 214.795 726.341 219.422 721.238Z" fill="black"/>
|
||||
<path d="M230.194 603.465C234.786 603.166 240.244 603.484 242.65 607.89C246.432 614.812 243.836 620.462 237.68 623.919C235.394 624.106 233.078 623.966 230.78 623.895C223.985 620.009 224.017 617.113 224.226 609.92C226.111 606.641 227.036 605.514 230.194 603.465Z" fill="#26A079"/>
|
||||
<path d="M279.941 756.874C290.642 755.737 292.417 762.07 291.909 771.088C289.611 773.553 288.538 774.586 285.501 776.032C281.054 776.214 276.916 775.325 274.465 771.186C273.116 768.903 272.802 766.153 273.604 763.628C274.749 759.933 276.723 758.52 279.941 756.874Z" fill="#26A079"/>
|
||||
<path d="M184.917 756.836C196.422 756.027 197.404 761.696 196.62 771.378C194.357 773.454 192.84 774.371 190.267 776.041C176.666 776.354 173.76 763.066 184.917 756.836Z" fill="#26A079"/>
|
||||
<path d="M141.648 670.85C153.661 670.733 155.879 683.156 145.441 688.581C143.519 688.829 141.425 688.843 139.628 688.029C137.382 686.996 135.674 685.064 134.924 682.707C134.045 680.059 134.348 677.16 135.754 674.751C137.291 672.117 138.879 671.528 141.648 670.85Z" fill="#26A079"/>
|
||||
<path d="M321.766 670.859C322.156 670.836 322.547 670.822 322.938 670.812C331.825 670.611 332.326 676.355 332.099 683.431C329.982 686.383 329.069 687.192 325.763 688.651C324.112 688.665 322.889 688.642 321.271 688.216C318.935 687.604 316.972 686.023 315.88 683.866C312.953 678.188 316.596 673.277 321.766 670.859Z" fill="#26A079"/>
|
||||
<path d="M330.121 812.921C351.649 827.762 346.04 854.263 333.098 872.743C322 888.589 298.34 902.284 280.221 889.847C261.55 877.027 269.439 848.094 280.395 832.944C283.6 828.51 287.566 824.796 290.775 820.62C305.684 826.419 320.793 829.17 330.121 812.921Z" fill="#E60C7F"/>
|
||||
<path d="M332.571 793.015C332.378 788.576 332.341 785.896 334.063 781.64C337.844 772.299 345.921 766.747 356.036 766.766C368.787 766.79 378.185 777.182 377.436 789.806C376.754 803.992 365.734 813.188 351.728 811.733C343.99 810.929 340.003 807.374 335.403 801.574C334.631 800.615 333.896 799.624 333.201 798.609L332.571 793.015Z" fill="#E60C7F"/>
|
||||
<path d="M349.366 702.65C349.073 709.245 346.033 709.03 340.716 709.989C343.833 706.49 345.552 705.387 349.366 702.65Z" fill="#E60C7F"/>
|
||||
<path d="M721.44 807.537C744.705 804.085 765.083 831.55 767.88 852.116C770.34 870.235 763.601 888.463 743.133 890.689C730.766 890.815 726.636 889.052 716.407 882.218C691.931 865.872 682.418 811.812 721.44 807.537Z" fill="#E60C7F"/>
|
||||
<path d="M513.098 132.984C552.826 129.498 560.305 190.521 537.414 212.767C532.358 217.685 529.173 218.896 522.419 220.389C504.529 223.385 492.424 210.736 487.42 194.923C482.662 180.347 484.028 164.462 491.208 150.913C496.423 141.111 502.578 136.148 513.098 132.984Z" fill="#E60C7F"/>
|
||||
<path d="M883.488 373.034C932.453 371.208 913.571 443.307 865.859 450.972C854.718 451.19 842.791 448.575 838.104 436.854C827.436 410.169 857.272 374.97 883.488 373.034Z" fill="#E60C7F"/>
|
||||
<path d="M143.248 394.964C193.554 391.929 219.573 456.112 171.828 463.485C122.154 465.224 94.9093 402.326 143.248 394.964Z" fill="#E60C7F"/>
|
||||
<path d="M513.701 233.861C529.983 231.238 545.286 242.387 547.779 258.692C550.277 274.997 539.005 290.211 522.681 292.577C506.54 294.917 491.531 283.799 489.062 267.675C486.597 251.551 497.593 236.455 513.701 233.861Z" fill="#E60C7F"/>
|
||||
<path d="M513.561 306.543C527.415 303.878 540.824 312.896 543.574 326.732C546.329 340.568 537.4 354.033 523.579 356.875C509.636 359.744 496.021 350.713 493.243 336.751C490.464 322.789 499.58 309.233 513.561 306.543Z" fill="#E60C7F"/>
|
||||
<path d="M479.039 595.093C481.923 594.822 484.459 594.476 486.863 596.375C490.315 599.097 490.507 603.849 491.512 607.792C493.87 617.048 500.282 623.212 509.66 624.751C521.358 626.669 533.458 622.922 538.43 611.286C540.764 606.543 539.552 599.139 544.538 595.982C548.448 593.503 554.028 594.723 556.138 598.98C558.631 604.003 556.858 610.57 555.324 615.607C548.107 635.051 528.041 645.752 507.957 642.328C498.177 640.78 486.377 634.223 480.891 625.691C475.437 617.212 468.172 602.17 479.039 595.093Z" fill="black"/>
|
||||
<path d="M807.225 436.948C819.938 435.444 831.472 444.519 832.997 457.23C834.521 469.941 825.462 481.486 812.754 483.031C800.017 484.579 788.441 475.497 786.912 462.758C785.382 450.018 794.484 438.456 807.225 436.948Z" fill="#E60C7F"/>
|
||||
<path d="M648.611 720.924C661.165 720.241 671.88 729.886 672.517 742.44C673.153 754.998 663.461 765.676 650.908 766.261C638.42 766.845 627.807 757.22 627.175 744.736C626.544 732.248 636.128 721.602 648.611 720.924Z" fill="#E60C7F"/>
|
||||
<path d="M681.16 767.186C693.339 764.46 705.43 772.088 708.218 784.254C711.005 796.414 703.442 808.547 691.291 811.4C679.055 814.267 666.815 806.639 664.008 794.384C661.197 782.135 668.891 769.932 681.16 767.186Z" fill="#E60C7F"/>
|
||||
<path d="M383.948 721.71C396.037 718.623 408.338 725.934 411.4 738.029C414.462 750.129 407.121 762.412 395.018 765.447C382.954 768.473 370.717 761.158 367.665 749.1C364.612 737.042 371.897 724.793 383.948 721.71Z" fill="#E60C7F"/>
|
||||
<path d="M747.081 466.845C759.059 464.286 770.846 471.905 773.428 483.876C776.014 495.847 768.418 507.65 756.454 510.26C744.452 512.877 732.605 505.255 730.014 493.248C727.423 481.241 735.07 469.411 747.081 466.845Z" fill="#E60C7F"/>
|
||||
<path d="M221.035 442.073C233.082 441.05 243.71 449.907 244.877 461.941C246.043 473.974 237.314 484.708 225.295 486.019C217.392 486.88 209.635 483.426 204.988 476.974C200.341 470.524 199.522 462.072 202.844 454.848C206.164 447.625 213.113 442.745 221.035 442.073Z" fill="#E60C7F"/>
|
||||
<path d="M273.457 466.429C285.414 465.832 295.572 475.085 296.089 487.046C296.607 499.008 287.287 509.104 275.322 509.542C263.469 509.977 253.487 500.763 252.974 488.912C252.462 477.062 261.61 467.019 273.457 466.429Z" fill="#E60C7F"/>
|
||||
<path d="M614.288 449.332C653.717 446.734 687.743 476.697 690.166 516.149C692.589 555.601 662.486 589.506 623.035 591.753C583.83 593.992 550.193 564.106 547.784 524.899C545.377 485.692 575.104 451.913 614.288 449.332Z" fill="#FEFEFE"/>
|
||||
<path d="M619.877 464.629C650.751 465.05 675.441 490.422 675.029 521.305C674.618 552.189 649.261 576.894 618.388 576.492C587.5 576.09 562.789 550.712 563.2 519.815C563.612 488.918 588.99 464.208 619.877 464.629Z" fill="black"/>
|
||||
<path d="M595.036 483.748C604.506 482.54 613.184 489.181 614.494 498.64C615.805 508.099 609.26 516.852 599.819 518.265C593.62 519.193 587.4 516.718 583.533 511.784C579.665 506.85 578.746 500.217 581.126 494.417C583.506 488.617 588.818 484.542 595.036 483.748Z" fill="#FEFEFE"/>
|
||||
<path d="M408.921 449.332C448.35 446.734 482.376 476.697 484.799 516.149C487.222 555.601 457.119 589.506 417.669 591.753C378.464 593.992 344.826 564.106 342.418 524.899C340.01 485.692 369.737 451.913 408.921 449.332Z" fill="#FEFEFE"/>
|
||||
<path d="M414.511 464.629C445.384 465.05 470.074 490.422 469.663 521.305C469.251 552.189 443.895 576.894 413.021 576.492C382.133 576.09 357.422 550.712 357.834 519.815C358.245 488.918 383.623 464.208 414.511 464.629Z" fill="black"/>
|
||||
<path d="M389.669 483.748C399.139 482.54 407.817 489.181 409.128 498.64C410.439 508.099 403.894 516.852 394.452 518.265C388.253 519.193 382.034 516.718 378.166 511.784C374.299 506.85 373.379 500.217 375.759 494.417C378.139 488.617 383.451 484.542 389.669 483.748Z" fill="#FEFEFE"/>
|
||||
<path d="M192.439 656.54C209.889 655.39 224.948 668.65 226.021 686.111C227.093 703.571 213.77 718.576 196.311 719.571C178.96 720.562 164.073 707.335 163.007 689.983C161.942 672.632 175.098 657.682 192.439 656.54Z" fill="#FEFEFE"/>
|
||||
<path d="M194.913 663.309C208.576 663.496 219.503 674.725 219.321 688.393C219.139 702.06 207.917 712.994 194.253 712.816C180.584 712.638 169.647 701.407 169.829 687.733C170.012 674.059 181.243 663.123 194.913 663.309Z" fill="black"/>
|
||||
<path d="M183.919 671.771C188.11 671.236 191.951 674.176 192.531 678.362C193.111 682.548 190.214 686.422 186.036 687.047C183.292 687.458 180.54 686.363 178.828 684.179C177.116 681.995 176.71 679.06 177.763 676.493C178.816 673.926 181.167 672.123 183.919 671.771Z" fill="#FEFEFE"/>
|
||||
<path d="M271.5 656.54C288.95 655.39 304.009 668.65 305.082 686.111C306.154 703.571 292.831 718.576 275.372 719.571C258.021 720.562 243.134 707.335 242.068 689.983C241.003 672.632 254.159 657.682 271.5 656.54Z" fill="#FEFEFE"/>
|
||||
<path d="M273.974 663.309C287.638 663.496 298.565 674.725 298.383 688.393C298.201 702.06 286.979 712.994 273.315 712.816C259.645 712.638 248.709 701.407 248.891 687.733C249.073 674.059 260.305 663.123 273.974 663.309Z" fill="black"/>
|
||||
<path d="M262.98 671.771C267.172 671.236 271.012 674.176 271.592 678.362C272.172 682.548 269.276 686.422 265.097 687.047C262.354 687.458 259.601 686.363 257.89 684.179C256.178 681.995 255.771 679.06 256.824 676.493C257.878 673.926 260.229 672.123 262.98 671.771Z" fill="#FEFEFE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 5.2 MiB |
@@ -0,0 +1,80 @@
|
||||
# Kiro-native Elicitation
|
||||
|
||||
**Status:** implemented for one-time tool approvals observed on Kiro CLI 2.8.1.
|
||||
**Code:** `omnigent/kiro_native_permissions.py`, `omnigent/kiro_native_bridge.py`, runner wiring in `omnigent/runner/app.py`.
|
||||
|
||||
## Behavior
|
||||
|
||||
`omnigent kiro` still runs Kiro's own terminal UI. When Kiro shows a tool approval prompt in the embedded Terminal, Omnigent also mirrors supported one-time approvals into Chat as an approval card. The Terminal prompt remains authoritative and answerable; the Chat card is additive.
|
||||
|
||||
Supported today:
|
||||
|
||||
- Kiro ACP `session/request_permission` records from the same `kiro-cli chat --tui` session.
|
||||
- Prompt options containing `allow_once` and `reject_once`.
|
||||
- Web `accept` mapped to Kiro's default one-time allow option.
|
||||
- Web `decline` / `cancel` mapped to Kiro's one-time reject option.
|
||||
|
||||
Not surfaced today:
|
||||
|
||||
- Persistent trust options such as `allow_always`.
|
||||
- Prompt types without stable ACP request ids or without `allow_once` / `reject_once` options.
|
||||
- Prompts already visible before the mirror starts, unless Kiro re-emits them after the recorder is attached.
|
||||
|
||||
## Signal Source
|
||||
|
||||
Kiro's persisted CLI session JSONL under `~/.kiro/sessions/cli` mirrors transcript records, but during the characterization probe it did not contain pending permission records. It contained conversation/tool-result records such as `Prompt`, `AssistantMessage`, and `ToolResults`.
|
||||
|
||||
The usable permission signal is Kiro's TUI ACP recorder. The runner sets `KIRO_ACP_RECORD_PATH` to a per-session file under the Kiro bridge directory, then `omnigent/kiro_native_permissions.py` tails that JSONL file. The observed record wrapper is:
|
||||
|
||||
```json
|
||||
{"dir":"out","msg":"{...json-rpc message...}","ts":"..."}
|
||||
```
|
||||
|
||||
A pending permission is a JSON-RPC message with:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "stable-request-id",
|
||||
"method": "session/request_permission",
|
||||
"params": {
|
||||
"toolCall": {"toolCallId": "stable-tool-call-id", "title": "Running: pwd"},
|
||||
"options": [
|
||||
{"optionId": "allow_once", "kind": "allow_once"},
|
||||
{"optionId": "allow_always", "kind": "allow_always"},
|
||||
{"optionId": "reject_once", "kind": "reject_once"}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
A terminal-side resolution is a JSON-RPC response with the same `id` and a selected `result.outcome.optionId`, for example `allow_once` or `reject_once`.
|
||||
|
||||
## Verdict Delivery
|
||||
|
||||
Kiro's public docs describe `KIRO_ACP_RECORD_PATH` as a traffic recorder, not as a writable control channel. This implementation therefore does not write ACP responses. It delivers web verdicts to the active visible TUI prompt through tmux keystrokes:
|
||||
|
||||
- `accept`: `Enter`, because `Yes, single permission` is the default focused option.
|
||||
- `decline` / `cancel`: `Down`, `Down`, `Enter`, sent one key at a time with render gaps.
|
||||
|
||||
The render gaps are required. A live probe showed that sending `Down Down Enter` as one burst could still select the default approval because the TUI had not processed the intermediate selection movement.
|
||||
|
||||
Immediately before pressing `Enter`, the bridge re-verifies that Kiro's approval prompt is visible, focused on the intended row, and associated with the parsed request title — the one-time allow row for `accept` (re-checked after the pre-`Enter` settle delay), or the one-time reject row for `decline` / `cancel` after moving down one row at a time. If those checks fail, the bridge raises instead of typing, so no verdict is delivered and the Terminal remains usable.
|
||||
|
||||
## Race Handling
|
||||
|
||||
The mirror starts at the current end of the recorder file. Historical recorder entries are not replayed into Chat because the Terminal is already the fallback and replaying old prompts risks stale approval cards.
|
||||
|
||||
For new records:
|
||||
|
||||
- A request followed by its response in the same poll batch is skipped, because the prompt already resolved before a web card could safely park.
|
||||
- A response for a still-parked request posts `external_elicitation_resolved`, clears the web card when the Terminal wins, and cancels the parked web-delivery task. Cancelling reliably aborts a verdict still waiting on the web user. If a web verdict is already mid-delivery through tmux, the keystroke worker cannot be interrupted, so the per-keypress focus and title re-validation (above) is what stops it: a verdict whose prompt has changed or vanished fails closed rather than landing on a later prompt.
|
||||
- A web verdict delivered through tmux is treated as a delivery attempt; Kiro's matching ACP result remains the internal confirmation that the prompt resolved.
|
||||
- Once a prompt is parked, the mirror handles one approval at a time; any further Kiro prompt that arrives while it is pending stays Terminal-only (the authoritative fallback) rather than queuing a second card.
|
||||
- The single slot is released as soon as the parked delivery task finishes, not only when a recorder response arrives. A verdict that was delivered, that failed its focus/title checks, or that timed out therefore cannot leave the slot occupied for the rest of the session and silently block every later prompt. A late recorder response for an already-released request finds no parked entry and is ignored.
|
||||
|
||||
## Security Notes
|
||||
|
||||
- The runner sets `KIRO_ACP_RECORD_PATH` itself inside the allowlisted child environment. It does not inherit an arbitrary recorder path from the parent shell.
|
||||
- Kiro-derived prompt text is treated as untrusted UI input and truncated before it is sent as a card preview.
|
||||
- The web UI never exposes persistent trust for Kiro. Users who want persistent trust must use Kiro's own trust flags or TUI controls deliberately.
|
||||
- Kiro remains authenticated by Kiro's own CLI login and does not use Omnigent Databricks, OpenAI, or Anthropic provider credentials.
|
||||
@@ -0,0 +1,130 @@
|
||||
# Upgrade experience: `omni upgrade` + release-available notices
|
||||
|
||||
How Omnigent keeps a user's CLI — and the local processes it spawns — current
|
||||
across PyPI releases. Three pieces ship together:
|
||||
|
||||
1. A **version-aware server signature** so a running local server is cycled
|
||||
onto new code automatically after any upgrade.
|
||||
2. An **`omni upgrade`** command that upgrades the CLI and gracefully cycles
|
||||
the local server / daemon / runners.
|
||||
3. A **"release available" notice** that fires only when a newer release is
|
||||
actually on PyPI, and points at `omni upgrade`.
|
||||
|
||||
## Background
|
||||
|
||||
- The CLI ships as `omnigent` / `omni` → `omnigent.cli:main()`; the installed
|
||||
version comes from `importlib.metadata.version("omnigent")`.
|
||||
- Releases publish three lockstep packages (`omnigent`, `omnigent-client`,
|
||||
`omnigent-ui-sdk`) to PyPI via `.github/workflows/release-omnigent.yml`.
|
||||
**No GitHub Releases are cut**, so the source of truth for "latest version"
|
||||
is the PyPI JSON API: `https://pypi.org/pypi/omnigent/json` → `info.version`.
|
||||
- The local server is a detached process on `:6767`, tracked by
|
||||
`~/.omnigent/local_server.pid` plus a config-signature sidecar
|
||||
(`local_server.sig`). `ensure_local_omnigent_server()` reuses a healthy
|
||||
server **iff its recorded signature matches**; otherwise it stops it and
|
||||
respawns. All durable state lives in sqlite (`~/.omnigent/chat.db`), not in
|
||||
process memory — which is what makes cycling a server safe.
|
||||
- PR #172 removed an earlier startup check that nagged on *install age* (it
|
||||
fired even when you were already on the latest version) and did a synchronous
|
||||
`git fetch` on the hot path. The module (`omnigent/update_check.py`) stayed in
|
||||
the tree, dormant. This work rewires it correctly.
|
||||
|
||||
## Key constraint
|
||||
|
||||
You cannot hot-patch a running Python process. "Upgrade running
|
||||
servers/daemons/runners gracefully" really means **cycle them**:
|
||||
|
||||
- **Server / daemon** — safe to stop and respawn; they rehydrate from sqlite.
|
||||
- **Runners** — an in-flight runner *is* a running agent loop and cannot adopt
|
||||
new code mid-run. The honest options are **drain** (let it finish; new
|
||||
sessions get new code) or **stop** (lose in-flight work).
|
||||
|
||||
## 1. Version-aware server signature
|
||||
|
||||
`server_config_signature()` (`omnigent/host/local_server.py`) now folds the
|
||||
installed package version into the signature alongside the resolved auth source:
|
||||
|
||||
```python
|
||||
payload = json.dumps({"auth": resolve_auth_source(), "version": version}, sort_keys=True)
|
||||
```
|
||||
|
||||
Effect: after *any* upgrade — `omni upgrade` **or** a manual `uv tool upgrade` —
|
||||
the next CLI command sees the running server's recorded signature no longer
|
||||
match and respawns it on the new code through the **existing** config-drift
|
||||
respawn path. This is the keystone: "running servers get upgraded" works even
|
||||
when the user never runs the dedicated command. (Running from a source tree with
|
||||
no registered distribution contributes an empty version and is unaffected.)
|
||||
|
||||
## 2. `omni upgrade`
|
||||
|
||||
`omnigent/cli.py`, command `upgrade`. Flow:
|
||||
|
||||
1. Bail on a source checkout / editable install (`_find_repo_root()` /
|
||||
`is_editable`) → tell the user to `git pull`.
|
||||
2. Read the install shape (`_read_installed_wheel_info`) and compare the
|
||||
installed version with the latest on the configured index
|
||||
(`fetch_latest_version`, PEP 440 compare).
|
||||
- Already current → report and exit 0.
|
||||
- `--check` → print the available delta and exit non-zero (scriptable).
|
||||
3. **Drain and wait** (default): poll the local server's *connected* sessions
|
||||
until idle so an upgrade never yanks a running agent turn. `--force` stops
|
||||
immediately (SIGTERM→SIGKILL). `Ctrl-C` aborts the wait.
|
||||
4. Stop the server + daemon (`_stop_local_server_and_daemon`) — *before*
|
||||
swapping code, so the live process never serves half-upgraded modules.
|
||||
5. Run the installer-appropriate command (`_build_upgrade_suggestion` +
|
||||
`_run_upgrade_command`): `uv tool upgrade omnigent`, `pip install -U
|
||||
omnigent`, `pipx upgrade omnigent`, `--reinstall <vcs_url>`, etc.
|
||||
6. **Lazy respawn**: do not restart the server. The next `omni` command spawns
|
||||
a fresh new-code server via the signature change above.
|
||||
|
||||
Most of steps 2/5 reuse helpers that already existed in `update_check.py`.
|
||||
|
||||
## 3. "Release available" notice (the PR #172 redo)
|
||||
|
||||
`omnigent/update_check.py`, installed-wheel path; wired into `main()` behind
|
||||
`_should_skip_update_check(argv)` and a `sys.stderr.isatty()` gate.
|
||||
|
||||
- **Only when newer**: compares installed vs. the cached latest version;
|
||||
notifies only when `latest > current`.
|
||||
- **Configured-index aware**: `fetch_latest_version` queries the resolved
|
||||
index's Simple Repository API (PEP 691 JSON, PEP 503 HTML fallback), not
|
||||
PyPI's Warehouse-only JSON API. `_resolve_index_url()` checks
|
||||
`OMNIGENT_INDEX_URL` / `UV_INDEX_URL` / `PIP_INDEX_URL`, then the uv/pip
|
||||
**config files** (`uv.toml`'s `index-url` or default `[[index]]`; `pip.conf`'s
|
||||
`[global] index-url`), default `pypi.org/simple`. So it works on corporate
|
||||
mirrors / air-gapped networks even when the mirror lives in a config file
|
||||
(the common case), and matches what `omni upgrade` pulls.
|
||||
- **Fire once per release**: the cache tracks `last_notified_version`; the
|
||||
notice shows once per new version, never on every invocation.
|
||||
- **Never on the hot path**: the foreground only reads the cache (instant). The
|
||||
network lookup runs in a **detached** background process
|
||||
(`refresh_update_cache`, spawned via `python -c` so it can't recurse into the
|
||||
CLI) that refreshes the cache for next time.
|
||||
- **Quiet + opt-out**: TTY-only, skipped for `--help` / `version` / internal TUI
|
||||
commands / `upgrade` itself, and silenced by `OMNIGENT_NO_UPDATE_CHECK`.
|
||||
- Dev clones keep the existing git "commits behind origin/main" notice
|
||||
(pointing at `git pull`), unchanged.
|
||||
|
||||
The notice points at `omni upgrade`; it no longer runs an interactive upgrade
|
||||
itself (that responsibility moved to the command).
|
||||
|
||||
## Decisions
|
||||
|
||||
- **Source of truth**: the configured package index's Simple Repository API
|
||||
(default `pypi.org/simple`; honors `OMNIGENT_INDEX_URL` / `UV_INDEX_URL` /
|
||||
`PIP_INDEX_URL` and the uv/pip config files). Picks the latest
|
||||
non-pre-release. No GitHub Releases are cut.
|
||||
- **Drain posture**: drain-and-wait by default, `--force` to stop now.
|
||||
- **Restart**: lazy respawn (no auto-restart) — simplest, fewest surprises.
|
||||
- **Notice cadence**: once per new release.
|
||||
|
||||
## Not in scope (possible follow-ups)
|
||||
|
||||
- True rolling drain (new server on a new port, old one finishes) — unnecessary
|
||||
for a local single-user server.
|
||||
- Pre-release / channel opt-in (the probe filters pre-releases today).
|
||||
- Project-local `uv.toml` / `pyproject [tool.uv]` index URLs (only the user/
|
||||
system uv config and pip.conf are read — matching how `uv tool install`
|
||||
resolves a global tool; use `OMNIGENT_INDEX_URL` for anything else).
|
||||
- A `/api/version` drift warning when attaching to a server you didn't spawn.
|
||||
- A `config` toggle mirroring `OMNIGENT_NO_UPDATE_CHECK`.
|
||||