Compare commits
81 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4fc575e518 | |||
| b551f669d6 | |||
| ddc5f6ee60 | |||
| 473941e322 | |||
| 1f575ba8de | |||
| 8f63f3271b | |||
| 9df23f7aeb | |||
| c6d1f7d5e0 | |||
| 05eaa253d1 | |||
| 699809de6e | |||
| 4f05fbd0ac | |||
| 537620909c | |||
| ed9369474f | |||
| 1fceeeb754 | |||
| 71a3437168 | |||
| 86726e0ab5 | |||
| 03c7907966 | |||
| adcf83ccb6 | |||
| 70ee54bdba | |||
| 036e0b9d29 | |||
| fb579783ce | |||
| fabdc7d25b | |||
| ddfa872809 | |||
| 21f71ddfdd | |||
| 1fc4b283b8 | |||
| 83b7ff409f | |||
| a447db22cb | |||
| ce00d35f0d | |||
| 8aaf72c91a | |||
| 65021dc1e8 | |||
| 6d277c4fc0 | |||
| 5de71a1877 | |||
| fcf5a902af | |||
| ba3692130d | |||
| 57df36a1ee | |||
| fc0e2e99c6 | |||
| c2439c6fd7 | |||
| 901aa8d12a | |||
| 2aba5079d4 | |||
| dc10a22147 | |||
| 08b20956f2 | |||
| 39c986cb06 | |||
| c8c4f81826 | |||
| 6c2daae4a9 | |||
| 73abf26b8e | |||
| b9d53f0a96 | |||
| 8f194d6f9d | |||
| 204e99d5c6 | |||
| d41f491e37 | |||
| 4e13ff5b82 | |||
| bee2b7518e | |||
| 849af0674f | |||
| 244ded1ed9 | |||
| e811f35f23 | |||
| c6b627cfc6 | |||
| c4dd03c47c | |||
| c118266e59 | |||
| d0c4317757 | |||
| 35689fc2a6 | |||
| f79e196428 | |||
| f75c07ba56 | |||
| bc3dc80200 | |||
| 75b6e71b18 | |||
| 84d1753c84 | |||
| ce6dba9c88 | |||
| 10e5cf6059 | |||
| 8f73e26a74 | |||
| a31e3f67ac | |||
| a736d3aeed | |||
| 32f58f1e16 | |||
| 321a766e59 | |||
| 6377f3fbd9 | |||
| f55001af0f | |||
| d32440a1f4 | |||
| 31b9c81664 | |||
| 0f1afc3502 | |||
| 6bb128c804 | |||
| 9e5b8741d2 | |||
| 0d073b3ab4 | |||
| d720d27624 | |||
| b0becdc002 |
@@ -146,7 +146,7 @@ streaming, harness.
|
||||
|
||||
```bash
|
||||
# Unit tests (use --frozen; the cwsandbox extra is unsatisfiable on public PyPI here)
|
||||
uv run --frozen --extra dev python -m pytest \
|
||||
uv run --frozen --group test python -m pytest \
|
||||
tests/inner/test_antigravity_executor.py \
|
||||
tests/inner/test_antigravity_harness.py \
|
||||
tests/runtime/test_antigravity_spawn_env.py \
|
||||
|
||||
@@ -157,7 +157,7 @@ already has complementary CUJ coverage — use both:
|
||||
- **Deeper end-to-end journeys → `tests/e2e/test_journey_*.py`** (first session
|
||||
to code, resume/disconnect, fork/explore, file upload, collaboration, …).
|
||||
Run a slice with the project's gated runner, e.g.
|
||||
`uv run --frozen --extra dev python -m pytest tests/e2e/test_journey_first_session_to_code.py -q`.
|
||||
`uv run --frozen --group test python -m pytest tests/e2e/test_journey_first_session_to_code.py -q`.
|
||||
- **Reusable PTY helpers** live in `tests/e2e/omnigent/_pexpect_harness.py`
|
||||
(`spawn_omnigent_run`, `wait_for_ready`, `submit_prompt`, `await_turn_complete`,
|
||||
`clean_exit`) and the snapshot comparator in `tests/e2e/omnigent/_snapshot.py`
|
||||
|
||||
@@ -20,8 +20,8 @@ the unit tests.
|
||||
|
||||
1. **You're on the branch you want to test.** The copilot harness is an
|
||||
optional extra — install it (without disturbing other extras) with
|
||||
`uv sync --frozen --extra dev --extra copilot`. NB: a bare
|
||||
`uv run --frozen --extra dev` re-syncs the venv and **prunes** the copilot
|
||||
`uv sync --frozen --group test --extra copilot`. NB: a bare
|
||||
`uv run --frozen --group test` re-syncs the venv and **prunes** the copilot
|
||||
SDK; for live testing call `.venv/bin/omni` / `.venv/bin/python` directly and
|
||||
avoid `uv run` mid-session.
|
||||
2. **The SDK is installed:**
|
||||
@@ -169,7 +169,7 @@ final answer lands server-side — read it over the AP API
|
||||
- **Spawn env:** `_build_copilot_spawn_env` in `omnigent/runtime/workflow.py`
|
||||
|
||||
```bash
|
||||
uv run --frozen --extra dev python -m pytest \
|
||||
uv run --frozen --group test python -m pytest \
|
||||
tests/inner/test_copilot_executor.py \
|
||||
tests/inner/test_copilot_harness.py \
|
||||
tests/runtime/test_copilot_spawn_env.py \
|
||||
|
||||
@@ -128,12 +128,12 @@ that works, the full stack is good: key, egress, bridge, harness.
|
||||
|
||||
```bash
|
||||
# Unit tests (use --frozen; the cwsandbox extra is unsatisfiable on public PyPI here)
|
||||
uv run --frozen --extra dev python -m pytest \
|
||||
uv run --frozen --group test python -m pytest \
|
||||
tests/inner/test_cursor_executor.py \
|
||||
tests/runtime/test_cursor_spawn_env.py \
|
||||
tests/onboarding/test_cursor_auth.py -q
|
||||
# Gated end-to-end harness test
|
||||
uv run --frozen --extra dev python -m pytest tests/e2e/omnigent/test_per_harness_cursor.py -q
|
||||
uv run --frozen --group test python -m pytest tests/e2e/omnigent/test_per_harness_cursor.py -q
|
||||
```
|
||||
|
||||
## Bug-bash (fan out)
|
||||
|
||||
@@ -203,7 +203,7 @@ side effects.
|
||||
|
||||
```bash
|
||||
# Existing pytest e2e for polly (mock-LLM) — complementary to this skill:
|
||||
uv run --frozen --extra dev python -m pytest \
|
||||
uv run --frozen --group test python -m pytest \
|
||||
tests/e2e/test_polly_e2e.py \
|
||||
tests/e2e/test_polly_cost_advisor_e2e.py \
|
||||
tests/e2e/test_polly_subagent_model_e2e.py -q
|
||||
|
||||
@@ -19,7 +19,7 @@ micro-benchmarks (not concurrency), that is a different tool: `dev/benchmarks/`.
|
||||
## 1. Ensure deps (repo checkout)
|
||||
|
||||
```bash
|
||||
pip install -e '.[loadtest,dev,agents-sdk]' # or: uv sync --extra loadtest --extra dev --extra agents-sdk
|
||||
uv sync --extra loadtest --extra agents-sdk
|
||||
```
|
||||
|
||||
Run with that same interpreter (e.g. `.venv/bin/python`), from the repo root.
|
||||
|
||||
+2
-2
@@ -2,8 +2,8 @@
|
||||
web/electron/icons/AppIcon.icon/** binary -merge
|
||||
|
||||
# Protobuf bindings regenerated by scripts/gen_routing_pb2.py from the .proto
|
||||
# schema. Mark them generated so review/code-quality tooling skips them (ruff
|
||||
# and mypy already exclude them in pyproject.toml); the protoc output isn't
|
||||
# schema. Mark them generated so review/code-quality tooling skips them (Ruff
|
||||
# excludes them and Pyrefly ignores generated-code errors); the protoc output isn't
|
||||
# hand-editable, so its unused-import/global artifacts are expected.
|
||||
omnigent/api/**/*_pb2.py linguist-generated=true
|
||||
omnigent/api/**/*_pb2.pyi linguist-generated=true
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
name: "Run e2e compat smoke tests"
|
||||
description: >
|
||||
Run @pytest.mark.compat_smoke tests from tests/e2e/ in one configuration:
|
||||
either the server or the runner subprocess is pinned to an older released
|
||||
build while the other side stays on the checked-out code. Exactly one of
|
||||
server_version / runner_version must be set.
|
||||
|
||||
Reuses the same install steps as .github/actions/e2e-run so the two
|
||||
never drift on Python/uv/binary-dep setup. Unlike e2e-run this action
|
||||
is not sharded and runs only the compat_smoke marker, keeping wall-clock
|
||||
time under ~15 minutes.
|
||||
|
||||
inputs:
|
||||
server_version:
|
||||
description: >
|
||||
Release tag for the OLD server build (e.g. v0.9.0).
|
||||
Set this for Config 1 (new runner, old server). Leave empty for Config 2.
|
||||
required: false
|
||||
default: ""
|
||||
runner_version:
|
||||
description: >
|
||||
Release tag for the OLD runner build (e.g. v0.9.0).
|
||||
Set this for Config 2 (new server, old runner). Leave empty for Config 1.
|
||||
required: false
|
||||
default: ""
|
||||
artifact_suffix:
|
||||
description: >
|
||||
Appended to uploaded-artifact names to keep them unique across jobs
|
||||
(e.g. "-config1"). Default empty — fine for single-run cases.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Configure environment
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "OMNIGENT_SKIP_WEB_UI=true"
|
||||
echo "ANTHROPIC_API_KEY="
|
||||
echo "OPENAI_API_KEY="
|
||||
echo "CODEX="
|
||||
echo "CLAUDE_CODE="
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Cache virtualenv
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and test dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install binary dependencies
|
||||
working-directory: .github/ci-deps
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get install -y tmux bubblewrap
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
npm install --ignore-scripts
|
||||
node node_modules/@anthropic-ai/claude-code/install.cjs
|
||||
echo "${GITHUB_WORKSPACE}/.github/ci-deps/node_modules/.bin" >> "$GITHUB_PATH"
|
||||
|
||||
- name: "Build pinned old server (Config 2: new runner, old server)"
|
||||
if: ${{ inputs.server_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_VERSION_INPUT: ${{ inputs.server_version }}
|
||||
run: |
|
||||
tag="$SERVER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid server_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/server-src"
|
||||
venv="$RUNNER_TEMP/server-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_SERVER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_SERVER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: "Build pinned old runner/host (Config 1: new server, old runner)"
|
||||
if: ${{ inputs.runner_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
RUNNER_VERSION_INPUT: ${{ inputs.runner_version }}
|
||||
run: |
|
||||
tag="$RUNNER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid runner_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/runner-src"
|
||||
venv="$RUNNER_TEMP/runner-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_RUNNER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_RUNNER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run compat smoke tests
|
||||
shell: bash
|
||||
env:
|
||||
E2E_TMP_BASE: /tmp/omnigent-compat-smoke-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p "$E2E_TMP_BASE"
|
||||
uv run pytest tests/e2e/ \
|
||||
-m compat_smoke \
|
||||
-v --tb=long --showlocals --log-level=INFO \
|
||||
--timeout=120 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$E2E_TMP_BASE" \
|
||||
--junitxml="$E2E_TMP_BASE/junit.xml"
|
||||
|
||||
- name: Upload logs on failure
|
||||
if: ${{ failure() || cancelled() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: compat-smoke-logs-${{ github.run_id }}${{ inputs.artifact_suffix }}
|
||||
path: |
|
||||
/tmp/omnigent-compat-smoke-${{ github.run_id }}/**/server.log
|
||||
/tmp/omnigent-compat-smoke-${{ github.run_id }}/**/runner.log
|
||||
/tmp/omnigent-compat-smoke-${{ github.run_id }}/**/.omnigent/logs/**/*.log
|
||||
/tmp/omnigent-compat-smoke-${{ github.run_id }}/junit.xml
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
include-hidden-files: true
|
||||
@@ -0,0 +1,227 @@
|
||||
name: "Run UI compat tests (smoke or full suite)"
|
||||
description: >
|
||||
Run tests/e2e_ui/ in one of two cross-version configurations:
|
||||
|
||||
Config A — new SPA + new runner, old server (server_version set):
|
||||
The server subprocess is pinned to the released tag while the SPA is
|
||||
built from HEAD. Tests the common deploy ordering where the server
|
||||
lags behind the frontend.
|
||||
|
||||
Config B — old SPA, new server + new runner (ui_version set):
|
||||
The SPA is built from the released tag's web/ source and the HEAD
|
||||
server is pointed at it via OMNIGENT_WEB_UI_DIST. Tests cached-SPA
|
||||
scenarios where a user's browser has an older bundle after a server
|
||||
upgrade.
|
||||
|
||||
Exactly one of server_version / ui_version must be set.
|
||||
|
||||
Two run modes:
|
||||
- full_suite=false (default): run only @pytest.mark.compat_smoke tests.
|
||||
- full_suite=true: run the complete tests/e2e_ui/ suite, sharded.
|
||||
|
||||
inputs:
|
||||
server_version:
|
||||
description: >
|
||||
Release tag for the OLD server (e.g. v0.9.0). SPA and runner stay
|
||||
on HEAD. Mutually exclusive with ui_version.
|
||||
required: false
|
||||
default: ""
|
||||
ui_version:
|
||||
description: >
|
||||
Release tag whose web/ source is used to build the OLD SPA (e.g.
|
||||
v0.9.0). Server and runner stay on HEAD; OMNIGENT_WEB_UI_DIST is
|
||||
set to the old built bundle. Mutually exclusive with server_version.
|
||||
required: false
|
||||
default: ""
|
||||
full_suite:
|
||||
description: >
|
||||
"true" = run the full tests/e2e_ui/ suite with sharding (overnight matrix).
|
||||
"false" (default) = run only @pytest.mark.compat_smoke tests (PR gate).
|
||||
required: false
|
||||
default: "false"
|
||||
shard_id:
|
||||
description: "0-based shard index (only used when full_suite=true)."
|
||||
required: false
|
||||
default: "0"
|
||||
num_shards:
|
||||
description: "Total shard count (only used when full_suite=true)."
|
||||
required: false
|
||||
default: "1"
|
||||
artifact_suffix:
|
||||
description: >
|
||||
Appended to uploaded-artifact names (e.g. "-ui-config"). Default empty.
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- name: Configure environment
|
||||
shell: bash
|
||||
run: |
|
||||
{
|
||||
echo "OMNIGENT_SKIP_WEB_UI=true"
|
||||
echo "ANTHROPIC_API_KEY="
|
||||
echo "OPENAI_API_KEY="
|
||||
echo "CODEX="
|
||||
echo "CLAUDE_CODE="
|
||||
} >> "$GITHUB_ENV"
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up pnpm + Node
|
||||
uses: ./.github/actions/setup-pnpm
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v4
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Cache virtualenv
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and test dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install bubblewrap and tmux
|
||||
shell: bash
|
||||
run: |
|
||||
sudo apt-get install -y bubblewrap tmux
|
||||
sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0
|
||||
|
||||
- name: Cache Playwright browsers
|
||||
uses: actions/cache@5a3ec84eff668545956fd18022155c47e93e2684 # v4
|
||||
with:
|
||||
path: ~/.cache/ms-playwright
|
||||
key: ${{ runner.os }}-playwright-${{ hashFiles('uv.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-playwright-
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
shell: bash
|
||||
run: uv run playwright install --with-deps chromium
|
||||
|
||||
- name: Build HEAD SPA
|
||||
# Always build the HEAD SPA so the built_spa fixture's tombstone assertion
|
||||
# passes. In Config B OMNIGENT_WEB_UI_DIST is then set to the old bundle,
|
||||
# so the server serves that instead — but the HEAD build must exist for the
|
||||
# fixture's structural check.
|
||||
shell: bash
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |
|
||||
pnpm install --frozen-lockfile --filter web
|
||||
pnpm --filter web run build
|
||||
|
||||
- name: "Build pinned old server (Config A)"
|
||||
if: ${{ inputs.server_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
SERVER_VERSION_INPUT: ${{ inputs.server_version }}
|
||||
run: |
|
||||
tag="$SERVER_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid server_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/ui-server-src"
|
||||
venv="$RUNNER_TEMP/ui-server-env"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
uv venv --python 3.12 "$venv"
|
||||
uv pip install --python "$venv/bin/python" \
|
||||
-e "$src" -e "$src/sdks/python-client" -e "$src/sdks/ui"
|
||||
"$venv/bin/omnigent" --version
|
||||
echo "OMNIGENT_COMPAT_SERVER_PYTHON=$venv/bin/python" >> "$GITHUB_ENV"
|
||||
echo "OMNIGENT_COMPAT_SERVER_VERSION=${tag#v}" >> "$GITHUB_ENV"
|
||||
|
||||
- name: "Build old SPA from release tag (Config B: old SPA / new server)"
|
||||
# Check out the old tag's web/ source into a temp dir, build it there,
|
||||
# then set OMNIGENT_WEB_UI_DIST so the HEAD server serves that bundle.
|
||||
if: ${{ inputs.ui_version != '' }}
|
||||
shell: bash
|
||||
env:
|
||||
UI_VERSION_INPUT: ${{ inputs.ui_version }}
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |
|
||||
tag="$UI_VERSION_INPUT"
|
||||
if ! [[ "$tag" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]; then
|
||||
echo "Invalid ui_version: '$tag'" >&2; exit 1
|
||||
fi
|
||||
src="$RUNNER_TEMP/ui-spa-src"
|
||||
git worktree add --detach "$src" "$tag"
|
||||
# Build the old SPA in its own directory. pnpm install uses the
|
||||
# old lock file; the build output lands in web/dist/ inside src.
|
||||
cd "$src"
|
||||
pnpm install --frozen-lockfile --filter web
|
||||
pnpm --filter web run build
|
||||
# vite.config.ts writes to ../omnigent/server/static/web-ui relative
|
||||
# to web/ — resolve to the absolute path inside the old checkout.
|
||||
built=$(cd web && node -e "const p=require('./vite.config.ts')" 2>/dev/null \
|
||||
|| echo "$src/omnigent/server/static/web-ui")
|
||||
# Fall back to checking both known locations.
|
||||
if [ -d "$src/omnigent/server/static/web-ui" ]; then
|
||||
built="$src/omnigent/server/static/web-ui"
|
||||
elif [ -d "$src/web/dist" ]; then
|
||||
built="$src/web/dist"
|
||||
else
|
||||
echo "Could not locate built SPA under $src" >&2; exit 1
|
||||
fi
|
||||
# Set OMNIGENT_WEB_UI_DIST so the HEAD server serves this old bundle.
|
||||
# The HEAD SPA is still built above (built_spa fixture needs it for the
|
||||
# tombstone assertion); the server uses OMNIGENT_WEB_UI_DIST to override
|
||||
# which bundle it actually mounts.
|
||||
echo "OMNIGENT_WEB_UI_DIST=$built" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Run UI compat tests
|
||||
shell: bash
|
||||
env:
|
||||
FULL_SUITE: ${{ inputs.full_suite }}
|
||||
SHARD_ID: ${{ inputs.shard_id }}
|
||||
NUM_SHARDS: ${{ inputs.num_shards }}
|
||||
E2E_TMP_BASE: /tmp/omnigent-compat-ui-${{ github.run_id }}
|
||||
run: |
|
||||
mkdir -p "$E2E_TMP_BASE"
|
||||
if [[ "$FULL_SUITE" == "true" ]]; then
|
||||
uv run pytest tests/e2e_ui/ \
|
||||
-m "not visual and not nightly" \
|
||||
--ui-skip-build \
|
||||
--splits="$NUM_SHARDS" \
|
||||
--group="$((SHARD_ID + 1))" \
|
||||
--tracing=retain-on-failure \
|
||||
--screenshot=only-on-failure \
|
||||
-v --tb=long --showlocals --log-level=INFO \
|
||||
--timeout=180 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$E2E_TMP_BASE" \
|
||||
--junitxml="$E2E_TMP_BASE/junit.xml" \
|
||||
|| { rc=$?; [ "$rc" -eq 5 ] && echo "::notice::No tests collected in this shard; treating as a pass." || exit "$rc"; }
|
||||
else
|
||||
uv run pytest tests/e2e_ui/ \
|
||||
-m compat_smoke \
|
||||
--ui-skip-build \
|
||||
-v --tb=long --showlocals --log-level=INFO \
|
||||
--timeout=120 \
|
||||
--timeout-method=thread \
|
||||
--basetemp="$E2E_TMP_BASE" \
|
||||
--junitxml="$E2E_TMP_BASE/junit.xml"
|
||||
fi
|
||||
|
||||
- name: Upload logs on failure
|
||||
if: ${{ failure() || cancelled() }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
with:
|
||||
name: compat-ui-smoke-logs-${{ github.run_id }}${{ inputs.artifact_suffix }}
|
||||
path: |
|
||||
/tmp/omnigent-compat-ui-${{ github.run_id }}/**/server.log
|
||||
/tmp/omnigent-compat-ui-${{ github.run_id }}/**/runner.log
|
||||
/tmp/omnigent-compat-ui-${{ github.run_id }}/**/.omnigent/logs/**/*.log
|
||||
/tmp/omnigent-compat-ui-${{ github.run_id }}/junit.xml
|
||||
retention-days: 7
|
||||
if-no-files-found: warn
|
||||
include-hidden-files: true
|
||||
@@ -81,9 +81,9 @@ runs:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and dev dependencies
|
||||
- name: Install project and test dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install binary dependencies
|
||||
# npm install against .github/ci-deps/package.json with --ignore-scripts
|
||||
|
||||
@@ -71,9 +71,9 @@ runs:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project and dev dependencies
|
||||
- name: Install project and test dependencies
|
||||
shell: bash
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install binary dependencies
|
||||
# Mirrors e2e.yml. --ignore-scripts blocks npm postinstall hooks; we run
|
||||
|
||||
@@ -58,7 +58,8 @@ runs:
|
||||
- name: Install dependencies
|
||||
shell: bash
|
||||
working-directory: ${{ inputs.workdir }}
|
||||
run: uv sync --extra all --extra dev
|
||||
# Current callers are tools-less prose/JSON agents; repository checks never run.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
shell: bash
|
||||
|
||||
Executable
+83
@@ -0,0 +1,83 @@
|
||||
#!/usr/bin/env bash
|
||||
# Emit the UI backwards-compat matrix on $GITHUB_OUTPUT as `ui_matrix`.
|
||||
#
|
||||
# The UI matrix is server-only: for each final (non-prerelease) release tag
|
||||
# at or above the backcompat floor, emit one cell per shard where the server
|
||||
# is that release and the SPA + runner are both main. The runner axis is
|
||||
# omitted because the SPA is always served by the server binary in production,
|
||||
# so "new SPA vs old runner" is not a meaningful compat scenario for the UI.
|
||||
#
|
||||
# Env in:
|
||||
# VERSIONS optional comma-separated override (e.g. "main,v0.9.0").
|
||||
# When set, only release tokens (non-"main") become cells.
|
||||
# NUM_SHARDS e2e_ui shard count per cell (default 3, mirrors e2e-ui.yml).
|
||||
# Out (GITHUB_OUTPUT):
|
||||
# ui_matrix={"include":[{"server":..,"shard_id":..,"num_shards":..}, ...]}
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
_valid_version() {
|
||||
[ "$1" = "main" ] || [[ "$1" =~ ^v?[0-9]+\.[0-9]+(\.[0-9]+)?([a-z0-9.]*)?$ ]]
|
||||
}
|
||||
|
||||
MIN_VERSION="${BACKCOMPAT_MIN_VERSION:-0.9.0}"
|
||||
MIN_VERSION="${MIN_VERSION#v}"
|
||||
|
||||
_below_floor() {
|
||||
[ "$1" = "main" ] && return 1
|
||||
local v="${1#v}"
|
||||
[ "$v" = "$MIN_VERSION" ] && return 1
|
||||
[ "$(printf '%s\n%s\n' "$v" "$MIN_VERSION" | sort -V | head -1)" = "$v" ]
|
||||
}
|
||||
|
||||
raw=()
|
||||
if [ -n "${VERSIONS:-}" ]; then
|
||||
IFS=',' read -ra raw <<<"$VERSIONS"
|
||||
else
|
||||
raw=()
|
||||
while IFS= read -r tag; do raw+=("$tag"); done < <(git tag --sort=-v:refname | grep -viE '(^|[^a-z])(rc|dev|pre)[0-9]')
|
||||
fi
|
||||
|
||||
# Collect only release tokens (skip "main" — "main vs main" is the normal gate).
|
||||
V=()
|
||||
for v in "${raw[@]}"; do
|
||||
v="${v#"${v%%[![:space:]]*}"}"
|
||||
v="${v%"${v##*[![:space:]]}"}"
|
||||
[ -z "$v" ] && continue
|
||||
[ "$v" = "main" ] && continue
|
||||
if ! _valid_version "$v"; then
|
||||
echo "skipping invalid version token: '$v'" >&2; continue
|
||||
fi
|
||||
if _below_floor "$v"; then
|
||||
echo "skipping '$v': below UI backcompat floor $MIN_VERSION" >&2; continue
|
||||
fi
|
||||
V+=("$v")
|
||||
done
|
||||
|
||||
num_shards="${NUM_SHARDS:-3}"
|
||||
|
||||
# Each release tag produces 2 × num_shards jobs: one Config A cell
|
||||
# (server=tag) and one Config B cell (ui=tag). Cap at 256 total.
|
||||
max_ui=256
|
||||
while [ "${#V[@]}" -gt 0 ] && [ "$(( ${#V[@]} * 2 * num_shards ))" -gt "$max_ui" ]; do
|
||||
dropped="${V[${#V[@]} - 1]}"
|
||||
unset 'V[${#V[@]}-1]'
|
||||
V=("${V[@]}")
|
||||
echo "ui-matrix cap: dropped oldest version '$dropped' to keep UI jobs <= $max_ui" >&2
|
||||
done
|
||||
|
||||
items=()
|
||||
for v in "${V[@]}"; do
|
||||
# Config A: new SPA (HEAD), old server
|
||||
for ((i = 0; i < num_shards; i++)); do
|
||||
items+=("{\"server\":\"$v\",\"ui\":\"\",\"config\":\"A\",\"shard_id\":$i,\"num_shards\":$num_shards}")
|
||||
done
|
||||
# Config B: old SPA (tag), new server (HEAD)
|
||||
for ((i = 0; i < num_shards; i++)); do
|
||||
items+=("{\"server\":\"\",\"ui\":\"$v\",\"config\":\"B\",\"shard_id\":$i,\"num_shards\":$num_shards}")
|
||||
done
|
||||
done
|
||||
|
||||
json=$(IFS=,; echo "${items[*]:-}")
|
||||
echo "ui_matrix={\"include\":[$json]}" >>"${GITHUB_OUTPUT:-/dev/stdout}"
|
||||
echo "versions: ${V[*]:-(none)}; UI jobs: ${#items[@]} (${#V[@]} tags × 2 configs × $num_shards shards)" >&2
|
||||
@@ -0,0 +1,118 @@
|
||||
name: Benchmark (host sessions)
|
||||
|
||||
# Profiles the host-bound session lifecycle — create → host.launch_runner →
|
||||
# runner boot → first token (`session_cold_start`), plus restart and the
|
||||
# common session actions around it — and renders the journey × metric matrix
|
||||
# into the job summary. Informational: no thresholds, so a noisy shared
|
||||
# runner can't block a PR; regression *gating* stays with benchmark-pr.yml
|
||||
# (store paths) and release.yml (release cuts). The seeded, backend-matrix
|
||||
# trend numbers stay with the nightly benchmark.yml; this workflow's value is
|
||||
# a fresh matrix on the PRs that actually move these numbers.
|
||||
#
|
||||
# Uses dev/benchmarks/omnigent (real server + real `omni host` daemon +
|
||||
# runner against a zero-latency mock LLM — no agent CLI or credentials).
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "omnigent/host/**"
|
||||
- "omnigent/runner/**"
|
||||
- "omnigent/server/**"
|
||||
- "dev/benchmarks/**"
|
||||
- ".github/workflows/benchmark-host.yml"
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
journeys:
|
||||
description: "Comma-separated journeys (blank = the host-session set)"
|
||||
required: false
|
||||
default: ""
|
||||
iterations:
|
||||
description: "Requests per run (runner journeys stay capped at 5)"
|
||||
required: false
|
||||
default: "100"
|
||||
runs:
|
||||
description: "Timed runs per journey"
|
||||
required: false
|
||||
default: "3"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
# No web SPA build during `uv sync` (setup.py _build_web_ui): nothing here
|
||||
# serves the bundle, and the build otherwise times out on public npm.
|
||||
OMNIGENT_SKIP_WEB_UI: "true"
|
||||
UV_INDEX_URL: https://pypi.org/simple
|
||||
PIP_INDEX_URL: https://pypi.org/simple
|
||||
# The host-session set: the host-bound lifecycle journeys first, then the
|
||||
# common HTTP session actions a user drives around them. Matches the
|
||||
# journey names in dev/benchmarks/omnigent/journeys.py (ALL_JOURNEYS).
|
||||
DEFAULT_JOURNEYS: >-
|
||||
session_cold_start,session_cold_restart,warm_turn,time_to_first_token,interrupt,create_session,fork_session,list_sessions,get_session,load_conversation_history
|
||||
JOURNEYS: ${{ (github.event_name == 'workflow_dispatch' && inputs.journeys) || '' }}
|
||||
ITERATIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.iterations || '100' }}
|
||||
RUNS: ${{ github.event_name == 'workflow_dispatch' && inputs.runs || '3' }}
|
||||
|
||||
concurrency:
|
||||
# PR pushes cancel the previous run; manual dispatches never cancel each
|
||||
# other (unique run_id).
|
||||
group: benchmark-host-${{ github.event.pull_request.number || github.run_id }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
benchmark-host:
|
||||
name: Host session benchmark (sqlite)
|
||||
if: github.repository == 'omnigent-ai/omnigent' && !github.event.pull_request.draft
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version-file: ".python-version"
|
||||
|
||||
- name: Set up uv
|
||||
uses: astral-sh/setup-uv@38f3f104447c67c051c4a08e39b64a148898af3a # v3
|
||||
with:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
# Same runtime install as the other benchmark workflows so numbers stay
|
||||
# comparable across them.
|
||||
run: uv sync --extra databricks
|
||||
|
||||
- name: Run host-session benchmark
|
||||
# Throwaway empty SQLite DB (run.py default): these journeys measure
|
||||
# process spin-up and turn latency, not query scale — corpus-scale
|
||||
# numbers live in the nightly benchmark.yml.
|
||||
run: |
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
--journeys "${JOURNEYS:-$DEFAULT_JOURNEYS}" \
|
||||
--iterations "$ITERATIONS" \
|
||||
--runs "$RUNS" \
|
||||
--output benchmark-results-host.json
|
||||
|
||||
- name: Render results matrix to job summary
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f benchmark-results-host.json ]]; then
|
||||
uv run --no-sync dev/benchmarks/omnigent/report_markdown.py \
|
||||
--title "Host session benchmark" \
|
||||
benchmark-results-host.json >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
with:
|
||||
name: benchmark-results-host-${{ github.run_id }}
|
||||
path: benchmark-results-host.json
|
||||
retention-days: 30
|
||||
if-no-files-found: warn
|
||||
@@ -55,7 +55,10 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev --extra databricks
|
||||
# pexpect drives omnigent polly via PTY for the cli_startup journey.
|
||||
run: |
|
||||
uv sync --extra databricks
|
||||
uv pip install pexpect
|
||||
|
||||
# Use the same corpus size as the nightly so baseline numbers are
|
||||
# directly comparable. Cache the seeded DB on the schema head + seed
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
# `databricks` extra carries psycopg[binary] for the Postgres backend.
|
||||
run: uv sync --extra dev --extra databricks
|
||||
run: uv sync --extra databricks
|
||||
|
||||
- name: Install MySQL driver
|
||||
# mysqlclient (mysql+mysqldb://) needs the system client library and is
|
||||
@@ -180,6 +180,10 @@ jobs:
|
||||
--database-uri "${{ steps.db.outputs.uri }}" \
|
||||
--sessions "$SESSIONS" --items-per-session "$ITEMS"
|
||||
|
||||
# pexpect drives omnigent polly via PTY for the cli_startup journey.
|
||||
- name: Install CLI startup dependencies
|
||||
run: uv pip install pexpect
|
||||
|
||||
- name: Run benchmark
|
||||
run: |
|
||||
uv run --no-sync dev/benchmarks/omnigent/run.py \
|
||||
@@ -189,6 +193,17 @@ jobs:
|
||||
--network-delay-ms "$NETWORK_DELAY_MS" \
|
||||
--output "benchmark-results-${{ matrix.backend }}.json"
|
||||
|
||||
- name: Render results matrix to job summary
|
||||
# The JSON artifact feeds the trend dashboard; this makes the same
|
||||
# numbers readable on the run page without downloading it.
|
||||
if: always()
|
||||
run: |
|
||||
if [[ -f "benchmark-results-${{ matrix.backend }}.json" ]]; then
|
||||
uv run --no-sync dev/benchmarks/omnigent/report_markdown.py \
|
||||
--title "Benchmark results" \
|
||||
"benchmark-results-${{ matrix.backend }}.json" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
|
||||
- name: Upload benchmark results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
if: always()
|
||||
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
# matrix.extra (e.g. "databricks") adds an extra for lanes that need it;
|
||||
# empty for the default lanes.
|
||||
run: uv sync --locked --extra all --extra dev ${{ matrix.extra && format('--extra {0}', matrix.extra) || '' }}
|
||||
run: uv sync --locked --extra all --group test ${{ matrix.extra && format('--extra {0}', matrix.extra) || '' }}
|
||||
|
||||
- name: Run pytest
|
||||
shell: bash
|
||||
@@ -266,7 +266,7 @@ jobs:
|
||||
with:
|
||||
enable-cache: true
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra all --extra dev --extra databricks
|
||||
run: uv sync --locked --extra all --group test --extra databricks
|
||||
- name: Run store + DB tests against PostgreSQL
|
||||
env:
|
||||
OMNIGENT_TEST_DB_URI: postgresql+psycopg://postgres:omnigent@localhost:5432/omnigent_root
|
||||
@@ -314,7 +314,7 @@ jobs:
|
||||
- name: Install system MySQL client library
|
||||
run: sudo apt-get update -qq && sudo apt-get install -y -q libmysqlclient-dev
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra all --extra dev --extra databricks && uv pip install mysqlclient
|
||||
run: uv sync --locked --extra all --group test --extra databricks && uv pip install mysqlclient
|
||||
- name: Run store + DB tests against MySQL
|
||||
env:
|
||||
OMNIGENT_TEST_DB_URI: mysql+mysqldb://root:omnigent@127.0.0.1:3306/omnigent_root
|
||||
@@ -389,7 +389,7 @@ jobs:
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Build parity sidecar
|
||||
if: steps.sidecar-cache.outputs.cache-hit != 'true'
|
||||
|
||||
@@ -247,7 +247,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
run: uv sync --extra all --extra dev
|
||||
# Agents classify or edit external-site prose; repository checks never run.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.plan.outputs.proceed == 'true' && steps.creds.outputs.available == 'true'
|
||||
|
||||
@@ -181,8 +181,8 @@ jobs:
|
||||
with:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
- name: Install project + test dependencies
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install bubblewrap + tmux
|
||||
# bubblewrap: the UI tests open terminals under os_env, whose
|
||||
|
||||
@@ -1,119 +0,0 @@
|
||||
name: Electron Build
|
||||
|
||||
# Manually-triggered build of the Electron desktop shell (web/electron) for
|
||||
# Linux and Windows. Each platform packages on its own native runner —
|
||||
# electron-builder does not reliably cross-compile installers — and uploads the
|
||||
# installers PLUS the electron-updater feed manifests (latest-linux.yml /
|
||||
# latest.yml) as downloadable workflow artifacts. Unsigned: no signing creds are
|
||||
# wired here, so `CSC_IDENTITY_AUTO_DISCOVERY=false` forces an unsigned build
|
||||
# rather than failing when a cert is absent. No publishing to a provider / no
|
||||
# release upload (`--publish never`): the artifacts are captured here for manual
|
||||
# placement onto the omnigent.ai update feed (omnigent-site repo + artifact host).
|
||||
#
|
||||
# Run it from the Actions tab (Run workflow). macOS is intentionally omitted —
|
||||
# its signed/notarized build lives elsewhere.
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
ref:
|
||||
description: "Branch, tag, or SHA to build."
|
||||
required: false
|
||||
default: ""
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
# One build per ref: back-to-back manual dispatches on the same ref queue
|
||||
# instead of running concurrently (keyed on ref only — including run_id would
|
||||
# make every run its own group, defeating the serialization).
|
||||
group: electron-build-${{ github.ref }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build (${{ matrix.platform }})
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
# Keep building the other platform even if one fails, so a Windows-only
|
||||
# break still yields the Linux installers (and vice versa).
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- os: ubuntu-latest
|
||||
platform: linux
|
||||
build-script: build:linux
|
||||
- os: windows-latest
|
||||
platform: win
|
||||
build-script: build:win
|
||||
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.inputs.ref || github.ref }}
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: ./.github/actions/setup-pnpm
|
||||
|
||||
- name: Install dependencies
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
run: |-
|
||||
pnpm install --frozen-lockfile --filter @omnigent/electron
|
||||
pnpm install --frozen-lockfile --filter web
|
||||
|
||||
- name: Build ${{ matrix.platform }} app
|
||||
working-directory: web/electron
|
||||
env:
|
||||
NPM_CONFIG_REGISTRY: https://registry.npmjs.org/
|
||||
# No signing credentials in CI: force an unsigned build instead of
|
||||
# letting electron-builder fail hunting for a certificate.
|
||||
CSC_IDENTITY_AUTO_DISCOVERY: "false"
|
||||
# electron-builder downloads Electron/tooling from GitHub; the token
|
||||
# lifts the anonymous rate limit that otherwise flakes downloads.
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
run: pnpm run ${{ matrix.build-script }} -- --publish never
|
||||
|
||||
# One artifact per platform bundling the COMPLETE electron-updater feed —
|
||||
# the installer(s), the .blockmap electron-updater needs for differential
|
||||
# downloads (referenced by path inside latest*.yml; the .deb has no
|
||||
# blockmap since debs aren't differentially updated), and the feed
|
||||
# manifest (latest-linux.yml / latest.yml). upload-artifact zips all
|
||||
# matched files into a single download, so each platform yields one zip
|
||||
# whose contents can be dropped straight onto a feed root (local HTTP
|
||||
# server for testing, or public/_desktop/updates/ on the artifact host).
|
||||
# Ship only the distributables + feed files, not electron-builder's
|
||||
# unpacked intermediates (dist/*-unpacked).
|
||||
#
|
||||
# electron-builder writes the latest*.yml manifests to dist/ even under
|
||||
# --publish never (a publish config exists in build.*.publish, so
|
||||
# update-info generation runs; --publish only skips the provider upload).
|
||||
# The manifest lists each artifact with sha512 + size + relative url.
|
||||
|
||||
- name: Upload Linux feed
|
||||
if: matrix.platform == 'linux'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: omnigent-desktop-linux
|
||||
path: |
|
||||
web/electron/dist/*.AppImage
|
||||
web/electron/dist/*.AppImage.blockmap
|
||||
web/electron/dist/*.deb
|
||||
web/electron/dist/latest-linux.yml
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
|
||||
- name: Upload Windows feed
|
||||
if: matrix.platform == 'win'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: omnigent-desktop-win
|
||||
path: |
|
||||
web/electron/dist/*.exe
|
||||
web/electron/dist/*.exe.blockmap
|
||||
web/electron/dist/latest.yml
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
@@ -233,10 +233,10 @@ jobs:
|
||||
# PAT passthrough for the codex / claude-sdk auth commands.
|
||||
echo "DATABRICKS_BEARER=$LLM_API_KEY" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install project and dev dependencies
|
||||
- name: Install project and test dependencies
|
||||
# Matches e2e.yml; ``--extra all`` pulls the harness SDKs so the
|
||||
# executor adapters import at collection time.
|
||||
run: uv sync --extra all --extra dev
|
||||
run: uv sync --extra all --group test
|
||||
|
||||
- name: Set up pnpm
|
||||
uses: ./.github/actions/setup-pnpm
|
||||
|
||||
@@ -229,8 +229,8 @@ jobs:
|
||||
path: .venv
|
||||
key: venv-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --locked --extra all --extra dev
|
||||
- name: Install project + test dependencies
|
||||
run: uv sync --locked --extra all --group test
|
||||
|
||||
- name: Install bubblewrap + tmux
|
||||
# bubblewrap: the UI tests open terminals under os_env, whose
|
||||
|
||||
@@ -157,7 +157,7 @@ jobs:
|
||||
- name: Install dependencies
|
||||
# Matches ci.yml; ``--extra all`` pulls the harness SDKs so
|
||||
# executor adapters import at collection time.
|
||||
run: uv sync --extra all --extra dev
|
||||
run: uv sync --extra all --group test
|
||||
|
||||
- name: Run pytest target
|
||||
# Inputs validated by prep. Word-splitting on $TEST_TARGET /
|
||||
|
||||
@@ -233,7 +233,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
run: uv sync --extra all --extra dev
|
||||
# The tools-less triage agent emits JSON; no repository checks run.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
name: Kustomize validate
|
||||
|
||||
# Renders every deploy/kubernetes overlay with `kustomize build` so manifest
|
||||
# drift (duplicate bases, missing patches, invalid YAML) is caught in CI
|
||||
# rather than at deploy time. Only runs when overlay or base files change.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- 'deploy/kubernetes/**'
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- 'release/v[0-9]*'
|
||||
paths:
|
||||
- 'deploy/kubernetes/**'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: kustomize-${{ github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
validate:
|
||||
name: Kustomize build (overlays)
|
||||
if: ${{ !github.event.pull_request.draft }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
steps:
|
||||
- name: Check out repo
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install kustomize
|
||||
run: |
|
||||
curl -s "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" | bash
|
||||
sudo mv kustomize /usr/local/bin/
|
||||
|
||||
- name: Render all overlays
|
||||
run: |
|
||||
failed=0
|
||||
for overlay in deploy/kubernetes/overlays/*/; do
|
||||
name="$(basename "$overlay")"
|
||||
echo "::group::$name"
|
||||
if kustomize build "$overlay"; then
|
||||
echo "::endgroup::"
|
||||
else
|
||||
echo "::endgroup::"
|
||||
echo "::error::kustomize build failed for overlay '$name'"
|
||||
failed=1
|
||||
fi
|
||||
done
|
||||
exit "$failed"
|
||||
@@ -75,7 +75,15 @@ jobs:
|
||||
# `--locked` is the hard gate: fails if uv.lock is out of sync with
|
||||
# pyproject.toml (a bare `uv run pre-commit` would re-lock first and mask
|
||||
# a stale lockfile). Fix locally with `uv lock`.
|
||||
run: uv sync --locked --extra dev
|
||||
# Pyrefly checks optional integrations against their real packages.
|
||||
# Compose capability extras with lint tooling instead of duplicating
|
||||
# runtime dependencies in the repository-only lint group.
|
||||
run: |
|
||||
uv sync --locked --group lint \
|
||||
--extra hindsight \
|
||||
--extra nimble \
|
||||
--extra s3 \
|
||||
--extra tracing
|
||||
|
||||
# Sets up Node 20 + pnpm, with pnpm dependency caching keyed on the
|
||||
# workspace lockfile. pnpm is pinned in .github/actions/setup-pnpm.
|
||||
|
||||
@@ -164,7 +164,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
run: uv sync --extra all --extra dev
|
||||
# Reviews a prefetched diff against trusted main; it does not run PR checks.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.trigger.outputs.skip != 'true' && steps.creds.outputs.available == 'true'
|
||||
|
||||
@@ -283,7 +283,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
run: uv sync
|
||||
|
||||
- name: Find previous stable release tag
|
||||
id: prev
|
||||
@@ -308,7 +308,7 @@ jobs:
|
||||
if: steps.prev.outputs.found == 'true'
|
||||
run: |
|
||||
git checkout "${{ steps.prev.outputs.tag }}"
|
||||
uv sync --extra dev
|
||||
uv sync
|
||||
uv run --no-sync dev/benchmarks/omnigent/seed.py \
|
||||
--database-uri "sqlite:///bench.db" \
|
||||
--sessions 5000 --items-per-session 200
|
||||
@@ -362,7 +362,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
run: uv sync
|
||||
|
||||
- name: Run baseline benchmark
|
||||
run: |
|
||||
@@ -413,7 +413,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
run: uv sync
|
||||
|
||||
# The seeded bench.db is at the previous release's schema head. The
|
||||
# candidate (newer code) auto-migrates on server boot, but the z7
|
||||
@@ -473,7 +473,7 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --extra dev
|
||||
run: uv sync
|
||||
|
||||
- name: Download results
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -188,7 +188,8 @@ jobs:
|
||||
|
||||
- name: Install dependencies
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
run: uv sync --extra all --extra dev
|
||||
# The tools-less triage agent emits JSON; no repository checks run.
|
||||
run: uv sync --extra all
|
||||
|
||||
- name: Install Claude Code CLI
|
||||
if: steps.creds.outputs.available == 'true'
|
||||
|
||||
@@ -30,6 +30,23 @@ on:
|
||||
schedule:
|
||||
# Every 12 hours (00:00 and 12:00 UTC).
|
||||
- cron: "0 */12 * * *"
|
||||
# Fast smoke on PRs that touch the server↔runner contract surface.
|
||||
# Runs both Config 1 (new server, old runner) and Config 2 (new runner,
|
||||
# old server) against the latest stable release so protocol regressions
|
||||
# surface before merge, not in the overnight full matrix.
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- "omnigent/runner/**"
|
||||
- "omnigent/server/**"
|
||||
- "omnigent/host/**"
|
||||
- "web/src/**"
|
||||
- "tests/e2e/**"
|
||||
- "tests/e2e_ui/**"
|
||||
- "tests/_helpers/compat.py"
|
||||
- ".github/actions/compat-smoke-run/**"
|
||||
- ".github/actions/compat-smoke-ui-run/**"
|
||||
- ".github/workflows/server-compat.yml"
|
||||
|
||||
concurrency:
|
||||
group: backcompat-${{ github.workflow }}-${{ github.sha }}
|
||||
@@ -39,11 +56,118 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
# Compute the full pairwise (server, runner) matrices. Integration is the
|
||||
# single openai-agents leg (claude-sdk/codex reject the mock LLM's
|
||||
# "mock-model"); e2e is sharded per cell. See backcompat-pairwise-matrix.sh.
|
||||
# ── Fast smoke: both compat configs against the latest stable release ──────
|
||||
# Runs on every PR that touches the server↔runner contract surface (paths
|
||||
# filter above), plus every schedule/dispatch run. Resolves the latest
|
||||
# final (non-prerelease) tag once, then fans out to Config 1 and Config 2.
|
||||
# The full pairwise matrix (backcompat-e2e / backcompat-integration) still
|
||||
# runs on schedule/dispatch and covers the broader version history.
|
||||
resolve-latest:
|
||||
name: Resolve latest stable tag
|
||||
# PRs: always. Schedule/dispatch: always.
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
latest_tag: ${{ steps.tag.outputs.latest_tag }}
|
||||
steps:
|
||||
- name: Checkout (tags only)
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
sparse-checkout: .github/scripts/ci
|
||||
persist-credentials: false
|
||||
- name: Resolve latest final tag
|
||||
id: tag
|
||||
shell: bash
|
||||
run: |
|
||||
# Latest final (non-prerelease) tag by version sort.
|
||||
tag=$(git tag --sort=-v:refname \
|
||||
| grep -viE '(^|[^a-z])(rc|dev|pre)[0-9]' \
|
||||
| head -1)
|
||||
if [ -z "$tag" ]; then
|
||||
echo "No stable release tag found" >&2; exit 1
|
||||
fi
|
||||
echo "latest_tag=$tag" >> "$GITHUB_OUTPUT"
|
||||
echo "Resolved latest stable tag: $tag" >&2
|
||||
|
||||
compat-smoke-config1:
|
||||
name: "Compat smoke – Config 1 (new server / old runner ${{ needs.resolve-latest.outputs.latest_tag }})"
|
||||
needs: resolve-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run compat smoke (Config 1)
|
||||
uses: ./.github/actions/compat-smoke-run
|
||||
with:
|
||||
runner_version: ${{ needs.resolve-latest.outputs.latest_tag }}
|
||||
artifact_suffix: "-config1"
|
||||
|
||||
compat-smoke-config2:
|
||||
name: "Compat smoke – Config 2 (new runner / old server ${{ needs.resolve-latest.outputs.latest_tag }})"
|
||||
needs: resolve-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run compat smoke (Config 2)
|
||||
uses: ./.github/actions/compat-smoke-run
|
||||
with:
|
||||
server_version: ${{ needs.resolve-latest.outputs.latest_tag }}
|
||||
artifact_suffix: "-config2"
|
||||
|
||||
compat-smoke-ui-config-a:
|
||||
name: "Compat smoke – UI Config A (new SPA / old server ${{ needs.resolve-latest.outputs.latest_tag }})"
|
||||
needs: resolve-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run UI compat smoke (Config A)
|
||||
uses: ./.github/actions/compat-smoke-ui-run
|
||||
with:
|
||||
server_version: ${{ needs.resolve-latest.outputs.latest_tag }}
|
||||
artifact_suffix: "-ui-config-a"
|
||||
|
||||
compat-smoke-ui-config-b:
|
||||
name: "Compat smoke – UI Config B (old SPA ${{ needs.resolve-latest.outputs.latest_tag }} / new server)"
|
||||
needs: resolve-latest
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.number && format('refs/pull/{0}/merge', github.event.pull_request.number) || github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run UI compat smoke (Config B)
|
||||
uses: ./.github/actions/compat-smoke-ui-run
|
||||
with:
|
||||
ui_version: ${{ needs.resolve-latest.outputs.latest_tag }}
|
||||
artifact_suffix: "-ui-config-b"
|
||||
|
||||
# ── Full pairwise matrix (schedule + dispatch only) ────────────────────────
|
||||
# Compute the full pairwise (server, runner) and UI matrices.
|
||||
# Integration is the single openai-agents leg (claude-sdk/codex reject the
|
||||
# mock LLM's "mock-model"); e2e is sharded per cell.
|
||||
# UI matrix is server-only (runner is always main; the SPA is always HEAD).
|
||||
setup:
|
||||
name: setup
|
||||
# The full pairwise matrix is expensive — skip it on PR triggers (the
|
||||
# fast smoke jobs above cover the PR case).
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
@@ -132,3 +256,50 @@ jobs:
|
||||
harness: ${{ matrix.harness }}
|
||||
model: ${{ matrix.model }}
|
||||
workers: ${{ matrix.workers }}
|
||||
|
||||
# tests/e2e_ui for every old server tag × shard (server axis only).
|
||||
setup-ui:
|
||||
name: setup-ui
|
||||
if: github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 5
|
||||
outputs:
|
||||
ui_matrix: ${{ steps.matrix.outputs.ui_matrix }}
|
||||
steps:
|
||||
- name: Check out CI scripts + tags
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
sparse-checkout: .github/scripts/ci
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: Compute UI matrix
|
||||
id: matrix
|
||||
env:
|
||||
VERSIONS: ${{ github.event.inputs.versions }}
|
||||
NUM_SHARDS: "3"
|
||||
run: bash .github/scripts/ci/backcompat-ui-matrix.sh
|
||||
|
||||
backcompat-e2e-ui:
|
||||
name: "Backcompat e2e-ui (Config ${{ matrix.config }}: ${{ matrix.config == 'A' && matrix.server || matrix.ui }}, shard ${{ matrix.shard_id }}/${{ matrix.num_shards }})"
|
||||
needs: setup-ui
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 6
|
||||
matrix: ${{ fromJSON(needs.setup-ui.outputs.ui_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.ref }}
|
||||
fetch-depth: 0
|
||||
- name: Run e2e-ui suite for this cell
|
||||
uses: ./.github/actions/compat-smoke-ui-run
|
||||
with:
|
||||
server_version: ${{ matrix.server }}
|
||||
ui_version: ${{ matrix.ui }}
|
||||
full_suite: "true"
|
||||
shard_id: ${{ matrix.shard_id }}
|
||||
num_shards: ${{ matrix.num_shards }}
|
||||
artifact_suffix: "-config${{ matrix.config }}-${{ matrix.config == 'A' && matrix.server || matrix.ui }}-shard${{ matrix.shard_id }}"
|
||||
|
||||
@@ -106,8 +106,8 @@ jobs:
|
||||
# the container's system Python, not the host interpreter).
|
||||
key: venv-uisnapshot-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --extra all --extra dev
|
||||
- name: Install project + test dependencies
|
||||
run: uv sync --extra all --group test
|
||||
|
||||
# No "playwright install": the pinned image ships matching Chromium + deps
|
||||
# under $PLAYWRIGHT_BROWSERS_PATH (/ms-playwright).
|
||||
|
||||
@@ -157,8 +157,8 @@ jobs:
|
||||
# must not share a key or a cross-restore would mismatch.
|
||||
key: venv-uisnapshot-${{ runner.os }}-${{ hashFiles('.python-version') }}-${{ hashFiles('uv.lock') }}
|
||||
|
||||
- name: Install project + dev extras
|
||||
run: uv sync --extra all --extra dev
|
||||
- name: Install project + test dependencies
|
||||
run: uv sync --extra all --group test
|
||||
|
||||
# No "playwright install": the pinned image already ships matching Chromium
|
||||
# + system deps under $PLAYWRIGHT_BROWSERS_PATH (/ms-playwright), so the
|
||||
|
||||
@@ -50,7 +50,8 @@ jobs:
|
||||
enable-cache: true
|
||||
|
||||
- name: Install dependencies
|
||||
run: uv sync --locked --extra dev
|
||||
# tests/inner includes tracing tests that import the OpenTelemetry SDK.
|
||||
run: uv sync --locked --group test --extra tracing
|
||||
|
||||
- name: Import + CLI smoke
|
||||
run: |
|
||||
|
||||
@@ -142,7 +142,7 @@ repos:
|
||||
|
||||
# Fail if routing.proto changed without regenerating the committed
|
||||
# bindings (or vice versa). Verify-only, not a fixer: regen needs
|
||||
# grpcio-tools, so CI's `uv sync --extra dev` enforces it (like ktlint).
|
||||
# grpcio-tools, so CI's `uv sync --group lint` enforces it (like ktlint).
|
||||
- id: routing-pb2-fresh
|
||||
name: routing protobuf bindings are up to date
|
||||
language: system
|
||||
|
||||
+177
@@ -5,6 +5,183 @@ generated at release time from each PR's `## Changelog` section, tagged by the
|
||||
PR's `Type of change` (e.g. `[UI]`); the concise, curated highlights live on the
|
||||
website under `/releases`.
|
||||
|
||||
## [v0.10.0] — 2026-08-19
|
||||
|
||||
- [Bug fix / Test/CI] Host daemons now honor standard proxy environment variables without forwarding (#1029)
|
||||
- [UI / Feature] Route host- and session-scoped server requests to the replica holding the host's tunnel, and signal `wrong_replica` (HTTP 400 / WS 4400) so clients re-address on a miss. (#2037)
|
||||
- [Bug fix] Keep `serve-mcp` responsive to pings and additional requests during slow tool calls. (#2813)
|
||||
- [UI / Feature / Docs / Test/CI] White-label the web UI from server config with custom names, headings, safe logo assets, favicon, and optional Omnigent attribution. (#2857)
|
||||
- [Bug fix] A transient server hiccup no longer pins a session to the wrong working directory for the rest of the conversation. (#3017)
|
||||
- [Bug fix] Claude SDK agents now discover large MCP tool definitions on demand instead of loading every schema up front. (#3134)
|
||||
- [Bug fix / Docs] Sub-agents no longer inherit their parent's bundle directory (skills, local tools); resolvable children use their own, while unresolvable children never fall back to the parent's (#3567)
|
||||
- [UI / Bug fix] Native-harness sessions no longer leave a stale duplicate of your message (or of the assistant's reply) pinned to the bottom of the web transcript. (#3595)
|
||||
- [Feature] GitHub policy blocks tag pushes (`--tags` / `--follow-tags` / `refs/tags/` refspecs) by default; opt out with `deny_tag_push: false`. (#3620)
|
||||
- [Bug fix] Server URLs and log paths in `omni host status` are now proper clickable links instead of text the terminal has to guess at (#3862)
|
||||
- [UI / Bug fix / Feature] agy sessions now mirror tool calls and sub-agents into the web UI, and no longer duplicate or truncate replies (#3890)
|
||||
- [Bug fix] `force_sandbox` (and any declared `os_env.sandbox`) now actually applies to a Claude Code native session's file/shell tools, not just the terminal process (#3910)
|
||||
- [Bug fix] Native sessions no longer fail with "terminal failed to start" when the host daemon was launched from a directory that has since been deleted (#3974)
|
||||
- [UI / Feature] The server can offer several sandbox providers at once (`sandbox.providers`), and the new-session picker lists one option per provider (#4006)
|
||||
- [Bug fix / Feature / Chore] Switching between conversations is instant — background conversations stay connected, so returning to one shows messages that arrived while you were away (#4113)
|
||||
- [Bug fix] `omnigent pi` now uses each model's real context window and output limit, instead of compacting early and truncating long replies on high-context models. (#4178)
|
||||
- [Feature] Route a host's tunnel, its runners, and its session traffic to one replica so multi-replica deployments keep host-scoped requests sticky. (#4185)
|
||||
- [Bug fix] A custom agent that declares its own provider auth now launches on codex-native instead of stalling on the Codex sign-in screen. (#4208)
|
||||
- [Bug fix] `omnigent server --host 0.0.0.0` with `OMNIGENT_LOCAL_SINGLE_USER=1` no longer 401s every request and 403s the host tunnel; the single-user marker is honored on network-exposed binds, with a warning that the server serves unauthenticated requests (#4224)
|
||||
- [UI / Bug fix] New chats in a project with a default base branch now fork a fresh branch off that default instead of reopening your last-used worktree (#4229)
|
||||
- [UI] New-chat landing header uses tighter Otto sizing and responsive headline scale (#4233)
|
||||
- [Bug fix] Changing effort or model mid-conversation on a Claude Code session no longer risks wedging the terminal or silently keeping the old setting. (#4250)
|
||||
- [Bug fix] `omnigent chat <remote-url>` can now start a new conversation with an agent registered on the remote server (#4260)
|
||||
- [UI / Bug fix] Collapsed "Worked for …" rows in a chat transcript now sit at an even spacing and draw their full-width divider (#4284)
|
||||
- [UI / Bug fix] Voice dictation now inserts text at your cursor instead of appending it to the end of the composer (#4290)
|
||||
- [UI / Feature] The file panel can now browse anywhere the session can reach, not just the folder it started in (#4306)
|
||||
- [UI / Bug fix] Navigating to another session while a new one is still being created no longer yanks you into the new session when it finishes (#4307)
|
||||
- [UI / Bug fix] New polly and debby sessions now show the same "Starting up…" spinner as claude-code, instead of a "Connecting…" row under the composer (#4312)
|
||||
- [Bug fix] Duplicate-detection comments no longer ask you to close your issue when the matching issue is already closed — an already-fixed match now asks whether you're on a build with the fix, and treats a still-reproducing report as a regression. (#4313)
|
||||
- [Bug fix] Kimi and Hermes sessions launch again — their version checks compared against the wrong version series and rejected every current CLI. (#4314)
|
||||
- [UI / Feature] Organizations can preconfigure Omnigent server URLs through Android managed configuration, and they show up ready to tap in the app's server list. (#4315)
|
||||
- [Feature] `omnigent host --background` starts the local server and registers this machine as a host without tying up a terminal. (#4317)
|
||||
- [UI / Breaking] Reverts the shared-session approval-authority and message-attribution features (#2150 stack); session approvals are again available to any shared editor. (#4318)
|
||||
- [UI] Administrators can preset the iOS app's server URLs with a managed app configuration, so managed users pick their organization's server instead of typing it (#4319)
|
||||
- [Bug fix] Fixed a bug where an archived session (or an archived sub-agent) could be gated as if it had spent nothing, letting a tool call proceed over its actual budget. (#4320)
|
||||
- [Feature] `omnigent start` starts the local server and registers this machine as a host — the on switch to go with `omnigent stop`. (#4321)
|
||||
- [Bug fix / Test/CI] Fix `omnidev` restart loop on Linux caused by non-mutating file access events (#4330)
|
||||
- [Feature / Docs] Issue triage now explains its impact assessment and priority in one bot comment instead of adding severity labels. (#4334)
|
||||
- [UI / Bug fix] Opening the left sidebar no longer squeezes the chat below its minimum width — the browser/workspace panel yields instead and restores its width when the sidebar collapses. (#4337)
|
||||
- [Bug fix] Pi sessions on a Databricks workspace no longer hang when the workspace model list is unavailable — non-Claude models are routed by family, and a model that truly can't be served now says so instead of never replying (#4339)
|
||||
- [Chore] Files in the viewer load faster — workspace-file reads are now gzipped, cutting a 1 MB text file from ~2.3 s to ~1.3 s (#4341)
|
||||
- [Bug fix / Chore] A claude-native session no longer shows "Working…" forever when Claude exits (#4344)
|
||||
- [UI] The sidebar "Needs response" badge now uses the brand accent color (pink by default), matching the unread indicator. (#4346)
|
||||
- [UI] Refreshed modal styling — larger rounded corners, softer drop shadow, and roomier padding (#4347)
|
||||
- [Bug fix] Pi sessions on a proxy that exposes both Anthropic and OpenAI surfaces now send each model to the surface its family speaks, instead of routing everything through Anthropic and hanging (#4348)
|
||||
- [Bug fix] Session snapshots no longer fail when a session contains a malformed legacy agent id. (#4350)
|
||||
- [Bug fix] A session whose message the runner refuses now reports the failure and its reason instead of appearing to finish successfully. (#4354)
|
||||
- [UI / Feature] Hover the collapsed sidebar toggle to peek the conversation list without pinning it open. (#4355)
|
||||
- [Bug fix] Generic-ACP / Goose / Qwen turns that fail now report the exception type instead of a blank "inner executor error: " with no detail. (#4362)
|
||||
- [UI / Bug fix] Messages sent from the chat UI no longer stop reaching the agent after a dropped network request (#4366)
|
||||
- [UI / Chore] The Files panel is now split into separate **Files** (folder tree) and **Changes** (changed files) tabs (#4367)
|
||||
- [UI / Feature] Chat messages now show their timestamp beside the Copy/Fork actions. (#4372)
|
||||
- [Bug fix] A conversation link copied from your browser now works wherever a server URL is expected, instead of failing later with an opaque "Method Not Allowed" crash (#4374)
|
||||
- [UI / Bug fix / Test/CI] Clicking a sidebar session that needs a response no longer runs its title under the "Needs response" tag, and the Inbox count badge now matches the sidebar's pink accent (#4375)
|
||||
- [UI / Bug fix] Maximized workspace panel no longer shows chat content through a transparent background in dark mode. (#4376)
|
||||
- [Bug fix] Agents now inherit your ssh-agent, so git-over-SSH and SSH-cert-authenticated tooling work in agent shells and terminals (#4377)
|
||||
- [Bug fix] The sidebar remembers your session filter across reloads instead of resetting to "All sessions" (#4381)
|
||||
- [Feature / Docs / Test/CI] Blaxel is now available as a sandbox provider for CLI and managed-host deployments. (#4383)
|
||||
- [UI] The Chat/Terminal switcher is now a segmented toggle — both views are visible at a glance and switching takes one click (#4385)
|
||||
- [Bug fix / Feature] `omnigent run --server local` runs against a local server, overriding any configured server default — and the no-AGENT `omnigent run --server ""` no longer fails with `Agent path not found: https:` (#4387)
|
||||
- [UI / Bug fix] Terminal-first sessions return to chat automatically when the runner stops or disconnects, instead of stranding on an empty "No terminals available" terminal view (#4388)
|
||||
- [Bug fix] The `build-omnigent` skill is available again in native `omnigent claude` and `omnigent codex` sessions (#4391)
|
||||
- [Bug fix] A custom ACP agent can declare the environment variables it authenticates with via `env_passthrough`, and a stalled ACP handshake now reports which call timed out instead of failing with an empty message. (#4392)
|
||||
- [Bug fix / Feature] The Copilot harness now authenticates with your existing `gh auth login` session, and a GitHub Enterprise host can be set via `omnigent setup` (#4396)
|
||||
- [Bug fix] MCP server configs can now use `${VAR}` placeholders in the `url` field, not just in `headers` — so a config can be committed to version control without hardcoding the endpoint. (#4398)
|
||||
- [Bug fix] `kimi-native` sub-agents honor `executor.config.yolo: true` (launching `kimi --yolo`) and `antigravity-native` sub-agents honor `permission_mode: bypassPermissions`, so server-spawned workers no longer stall on interactive approval prompts. (#4401)
|
||||
- [Bug fix] Resuming a claude-native session no longer drops a message sent right after the session starts. (#4403)
|
||||
- [Bug fix] A sub-agent session no longer logs a spurious "did not resolve in the parent spec" warning on every turn. (#4435)
|
||||
- [UI / Bug fix] Bulk-select sessions and move them to a project in one action via the new folder icon in the selection bar. (#4452)
|
||||
- [UI] Codex's bypass-approvals option now matches Claude's clean permission UX — no more red warning banners (#4467)
|
||||
- [Bug fix] Compaction snapshots no longer store raw image data, which cuts the size of newly written compacted conversation rows substantially. (#4470)
|
||||
- [UI / Bug fix] The new-session workspace picker now navigates to `~/…` paths and shows a clear error when a typed path doesn't exist (#4480)
|
||||
- [UI / Feature] Harness launch failures now show a clear title, cause, and suggested fix instead of a raw error code and truncated log tail. (#4485)
|
||||
- [UI / Feature] Sub-agents are now auto-assigned readable structured names (e.g. `researcher-1`) and a task-derived display label in the Agents panel (#4489)
|
||||
- [UI] Restored the down chevrons on the new-session composer's chips and made every dropdown trigger show a pointer cursor (#4493)
|
||||
- [UI / Bug fix] Modal dialogs and the workspace "Open new" menu no longer render behind the embedded browser pane (#4500)
|
||||
- [Bug fix] Named `sys_session_send` no longer returns a spurious 404 on the second and later sub-agent sends from a bundled agent (#4501)
|
||||
- [Bug fix] Session search no longer hangs on "Searching…" — content search is now backed by a trigram index and bounded by a timeout. (#4502)
|
||||
- [Bug fix / Test/CI] Fixes an HTTP client resource leak when OpenAI Agents SDK executors shut down. (#4508)
|
||||
- [Bug fix / Test/CI] Honor OmnigentClient timeout for ordinary Python SDK HTTP requests. (#4509)
|
||||
- [Bug fix] Sessions ride out brief runner-tunnel drops (laptop sleep-wake, ingress recycles) without flashing Failed or truncating the streaming reply. (#4516)
|
||||
- [Bug fix] `omnigent` no longer crashes on startup when your shell sets a SOCKS proxy (e.g. `ALL_PROXY=socks5://…`) and the `httpx[socks]` extra isn't installed (#4517)
|
||||
- [UI / Bug fix] Attaching an unsupported file in a new chat now tells you why up front and keeps your message instead of losing it (#4519)
|
||||
- [Bug fix] `omni` now works on machines with an HTTP proxy configured, and reports an unreachable server as a clear error instead of a crash. (#4520)
|
||||
- [Bug fix] Sessions that outlive their 60-minute runner bearer no longer permanently lose runner→server auth when the token re-mint is rejected — the runner now falls back to the machine's SDK/OIDC credential. (#4521)
|
||||
- [Bug fix] Harness logs now go to a file under `~/.omnigent/logs/harness/` (or the runner's log), and a failing ACP turn quotes the agent's own error output instead of dropping it. (#4523)
|
||||
- [Bug fix] An `openai-agents` agent with no pinned model no longer fails with a confusing "install databricks-sdk" error when only OpenAI credentials are missing (#4526)
|
||||
- [Bug fix] Claude native sessions now report per-turn token usage (`gen_ai.usage.input_tokens` / `output_tokens`) to MLflow and other OpenTelemetry backends. (#4530)
|
||||
- [UI / Bug fix / Feature] Move a running session to another machine from the host badge in the composer. (#4531)
|
||||
- [Bug fix] Upgrading omnigent in place no longer breaks harness launches on already-running runners (#4539)
|
||||
- [Chore] The session-search index migration builds its Postgres indexes concurrently, so upgrades no longer block writes while the indexes build. (#4541)
|
||||
- [Feature] The Android app now opens Databricks workspaces on their `/omnigent` app instead of the workspace landing page. (#4543)
|
||||
- [Bug fix] `omnigent host` pointed at a local server that has exited now stops after ~5 minutes with a clear error instead of reconnecting forever. (#4544)
|
||||
- [Bug fix] Runners no longer crash-loop at session start when signal-handler registration fails; they log one warning and keep working. (#4545)
|
||||
- [Bug fix] Session search now returns results instead of timing out on large workspaces. (#4546)
|
||||
- [UI / Bug fix] Modal buttons like Stop session and Clone now show a spinner while the action is running, instead of just greying out. (#4548)
|
||||
- [UI / Bug fix] The desktop server picker moved from the window title bar to the bottom of the sidebar, fixing an overlap with the chat header on narrow windows — and Windows and Linux desktop now have it too (#4551)
|
||||
- [UI] The embedded terminal connects in the background and stays connected across Chat/Terminal flips and recent-session switches, so opening it is near-instant instead of reconnecting every time. (#4552)
|
||||
- [Bug fix] The Android app no longer shows the Databricks workspace navigation bar around Omnigent when connecting to a workspace-hosted server. (#4555)
|
||||
- [UI / Feature] On the macOS desktop app, the sidebar header now shares the title-bar row with the window controls — the empty strip above the sidebar and the redundant wordmark row are gone, and the Collapse/Search/Settings buttons sit beside the traffic lights. (#4557)
|
||||
- [Bug fix] Codex sessions on a ChatGPT-account or API-key login launch again, instead of failing with "model is not supported when using Codex with a ChatGPT account" (#4558)
|
||||
- [UI] Connecting the iOS app to a Databricks workspace now opens Omnigent directly and hides the workspace navigation bar (#4559)
|
||||
- [Bug fix] kiro sessions no longer fail the first message with a connection error when the kiro TUI is slow to start (#4562)
|
||||
- [Bug fix] `omnigent host` now warns once and backs off when a server accepts connections but never responds, waits out (up to 120s) a slow-booting local server instead of stranding it — stopping it if it truly fails — recovers fast runner starts after a zygote crash, and no longer leaves empty log files behind. (#4563)
|
||||
- [Bug fix] Named `sys_session_send` no longer returns a spurious 404 on the second and later sub-agent sends from a bundled agent (#4564)
|
||||
- [UI / Bug fix] Deleting a session removes it from the sidebar immediately instead of waiting for the server to finish tearing it down (#4566)
|
||||
- [Bug fix] Session search no longer times out on large workspaces. (#4567)
|
||||
- [UI / Bug fix] Fixed iOS controls rendering under the status bar on Databricks workspace-hosted servers (#4568)
|
||||
- [UI / Feature] You can now leave a session someone shared with you — pick "Leave session" from its sidebar row menu to clear it from your sidebar without asking the owner (#4571)
|
||||
- [UI / Bug fix] The workspace Files panel now shows hidden files by default, and its eye icon shows whether they are visible rather than what clicking will do. (#4575)
|
||||
- [Bug fix] Switching a session's agent now updates the tools its native harness can call, instead of leaving the previous agent's tools in place (#4576)
|
||||
- [Bug fix] The native pane reaper no longer kills a terminal that is actively producing output when the harness status pipeline stalls; it now checks tmux's own activity clock before reaping. (#4577)
|
||||
- [Bug fix] A silently stalled claude-native transcript forwarder now self-recovers within five minutes and logs exactly where it stalled, instead of freezing mirroring and session status indefinitely. (#4578)
|
||||
- [Bug fix] A claude-native transcript forwarder that stops — cancelled, crashed, or returned — now always logs an attributed exit line instead of dying silently. (#4579)
|
||||
- [Bug fix] A stray hook event from another Claude session can no longer silently redirect a claude-native session's transcript mirroring; session identity now changes only via SessionStart announcements. (#4580)
|
||||
- [Bug fix] `omni claude` streaming, statusline, and typing during tool-running turns now respond at native speed: hook subprocesses skip the framework's eager import graph, and the blocking hook path runs as shell + a loopback `curl` relayed by the long-lived runner instead of spawning a Python interpreter per event. (#4582)
|
||||
- [UI / Bug fix / Feature] Fork a sub-agent to promote it into a top-level session of its own. (#4584)
|
||||
- [Bug fix] Upgrading omnigent in place no longer breaks runner launches on already-running hosts (#4587)
|
||||
- [UI / Bug fix] Native-harness sessions no longer briefly drop your in-flight message bubble when an interrupt marker is reconciled at the same time. (#4591)
|
||||
- [UI / Bug fix / Chore] Native assistant text now reconciles cleanly with committed transcript messages without duplicate streaming output. (#4593)
|
||||
- [Bug fix] The embedded browser pane no longer lingers over the welcome screen after switching or disconnecting from a server (#4595)
|
||||
- [Chore] Removed the "A new version of Omnigent is available" prompt and browser PWA install support; the desktop and mobile apps remain the installable clients. (#4617)
|
||||
- [Chore] OpenTelemetry exporters and automatic instrumentors are now installed through `omnigent[tracing]` instead of the default package. (#4621)
|
||||
- [Bug fix] Development builds no longer show an update reminder for the matching final release. (#4628)
|
||||
- [Bug fix] `omni host` no longer prints a zygote traceback — and keeps copy-on-write runner forking — when started from a directory that contains an `omnigent` checkout (#4631)
|
||||
- [UI / Bug fix] Clicking a file an agent mentions in its reply now opens it, including `path:line` citations and markdown links (#4644)
|
||||
- [UI / Bug fix] Fixed long unbroken text or inline code in chat messages overflowing or getting cut off at narrow window widths. (#4651)
|
||||
- [Bug fix] Fixed a duplicate assistant message that could appear after reconnecting to a Claude Code (native) session (#4656)
|
||||
- [Bug fix / Chore] Resuming or forking a Claude Code session containing screenshots no longer duplicates image payloads into metadata and inflates the request past the context limit. (#4659)
|
||||
- [UI / Bug fix] Archiving the current session now redirects to the home page instead of leaving you on the archived session (#4671)
|
||||
- [UI / Feature] Usage page shows session costs, daily spend timeline, and breakdowns by harness and model (#4673)
|
||||
- [Bug fix] Sessions whose workspace is an omnigent checkout no longer run a different omnigent than the one you installed (#4688)
|
||||
- [Bug fix] `omnigent run --harness acp:<agent>` now launches the ACP agent you asked for instead of the first one configured (#4689)
|
||||
- [UI / Bug fix] The desktop app now auto-selects this machine after "Run on this machine" (#4691)
|
||||
- [UI / Bug fix] The managed sandbox host option (and other capability-gated UI) now appears on its own after a slow `/v1/info` probe, instead of staying hidden until a page reload. (#4694)
|
||||
- [Chore] Runner-backed resource APIs now avoid redundant session reads, improving responsiveness under load. (#4695)
|
||||
- [Bug fix] ACP agents now report cached-read tokens, so token usage reflects what was actually billed (#4699)
|
||||
- [Bug fix] Built-in ACP agents like Grok Build now appear in `omni setup` instead of being invisible (#4700)
|
||||
- [Bug fix] `omnigent run --harness acp:<slug>` now works with remote servers by resolving the slug client-side and embedding the full agent config in the spec. ACP agent settings (session_id_mode, send_model, omnigent_mcp, env_passthrough) are now preserved through embedding. (#4702)
|
||||
- [Bug fix / Feature] `/model` now switches an ACP agent's model mid-conversation instead of being ignored, and keeps the chat history (#4703)
|
||||
- [Bug fix] A host name set in `config.yaml` is now kept when no `host_id` is present — the id is generated instead of overwriting your chosen name. (#4708)
|
||||
- [Bug fix] `omnigent resume` now lists only your own sessions, not ones shared with you (#4709)
|
||||
- [UI / Bug fix] The Inbox count badge now uses the same text and background colors as the selected session item in the sidebar (#4714)
|
||||
- [UI / Feature] Multi-session delete now shows a table of worktree branches you can pick to clean up (with a tri-state select-all header), instead of blocking branch cleanup behind single-session delete (#4715)
|
||||
- [Bug fix] OpenCode 1.18.x installs are now accepted; the version gate no longer rejects users who installed OpenCode via its official upstream route. (#4725)
|
||||
- [UI / Bug fix / Chore / Test/CI] Approval prompts now name the assistant that asked (Claude Code, Codex, Cursor, Antigravity, Kiro, Goose, Qwen Code, Hermes) instead of an internal policy id (#4735)
|
||||
- [UI / Bug fix] Opening the workspace folder browser no longer flashes an "Up one level" tooltip over the listing (#4742)
|
||||
- [Feature] Kubernetes sandbox runners now use Jobs with automatic restart on crash (up to 3 retries) and a liveness probe, replacing bare Pods that required manual intervention after a failure. (#4744)
|
||||
- [UI / Bug fix] The chat transcript now detects a silently dead live connection and reconnects on its own — worst case 45 s, instantly on tab refocus — instead of freezing until the page is reloaded. (#4750)
|
||||
- [Bug fix] Chat messages sent while the terminal's Claude composer is covered by the ctrl+r history search or a hand-opened `/model` picker now dismiss the overlay and deliver, instead of silently vanishing (#4751)
|
||||
- [Bug fix] Creating a session from the web UI is faster end-to-end: the chat page opens immediately and the terminal is ready sooner — including the first session after a host restart, which no longer pays a multi-second warmup. (#4752)
|
||||
- [UI / Bug fix] Answered question and plan cards stay outside the "Worked for" fold, read as settled, and survive a page reload (#4760)
|
||||
- [UI / Feature] Web terminals now attach directly over loopback when the runner is on the same machine as the browser, cutting keystroke echo from ~250 ms to under 10 ms against a remote server. (#4763)
|
||||
- [UI / Bug fix / Test/CI] The Sessions filter is now always visible, so session filtering is discoverable without hovering the sidebar header. (#4764)
|
||||
- [Feature] New `OMNIGENT_REQUIRE_WRAPPER` env var lets operators require the CLI be run through a wrapper (e.g. `isaac omni`) and refuse direct `omni` calls (#4766)
|
||||
- [UI / Bug fix / Chore / Test/CI] Chat output stays visible above a growing composer; bottom-following readers remain pinned while readers who scroll up—even just 50px—keep the same visible content. (#4767)
|
||||
- [Bug fix] `omnidev --vite-port` once again starts the frontend on the requested port. (#4770)
|
||||
- [Feature / Test/CI] macOS desktop app now ships an Intel (x64) build alongside Apple Silicon. (#4772)
|
||||
- [UI / Feature / Docs] Usage and web-driven harness setup can now be enabled per deployment with `OMNIGENT_FEATURES`. (#4775)
|
||||
- [UI / Bug fix / Feature] In-chat errors now provide expandable diagnostics and recovery-aware Retry, copy, and dismiss actions without replaying failed input, and cancelled retry requests no longer leave stale recovery results cached. (#4787)
|
||||
- [Feature / Docs] ArgoCD quick-start overlay for deploying Omnigent with the kubernetes sandbox provider (#4788)
|
||||
- [Bug fix] `omnigent[antigravity]` no longer crashes with a protobuf gencode/runtime version mismatch on startup. (#4795)
|
||||
- [UI / Bug fix] Server URLs in copyable connection and reconnect commands are now safely quoted. (#4817)
|
||||
- [Bug fix] Resumed Codex conversations now keep the authentication provider selected in Codex configuration. (#4818)
|
||||
- [Bug fix] `omnidev omnigent` commands now keep runtime state and configuration inside their development pod. (#4822)
|
||||
- [Bug fix] Native Windows: harness CLIs (codex, pi, claude-sdk, antigravity) no longer hang on spawn due to missing `SYSTEMROOT`/`COMSPEC`; Windows drive-letter workspace paths (`C:\…`) are now accepted; pre-release harness CLI versions (e.g. `0.146.0-alpha.9.2`) no longer fail the version gate. (#4886)
|
||||
- [UI / Feature] Background tasks that keep running after a turn ends now show as a pill above the composer instead of a "Working…" spinner (#4893)
|
||||
- [UI / Bug fix] Maximizing the workspace panel in the desktop app no longer tucks its tab icons under the macOS window controls. (#4897)
|
||||
- [Feature] Configured ACP agents (e.g. Devin) and installed ACP CLI harnesses (e.g. Grok Build) now appear in the web New Chat picker, like the native harnesses (#4909)
|
||||
- [Bug fix] Managed codex, claude-sdk (Polly/Debby), and pi harnesses resolve their launch model from the workspace's Unity Catalog model services, so they no longer fail against a Databricks AI Gateway that has retired the legacy `databricks-*` model namespace (#4915)
|
||||
- [UI / Feature] Devin is now a built-in harness — set it up in `omni setup`, launch it with `--harness devin` or from the New Chat picker, no `acp:` config needed (#4920)
|
||||
- [Bug fix] `omni setup` and the New Chat picker no longer show a duplicate — or silently ignore — a built-in ACP harness you configured yourself under the same name; your own command wins. (#4927)
|
||||
- [UI] Chat error banners are now a compact centered pill with inline Retry, matching the design prototype (#4931)
|
||||
- [UI / Feature] The chat header now shows the conversation's name, its project folder, and the sub-agent path, and title bars are a consistent 48px. (#4940)
|
||||
|
||||
## [v0.9.0] — 2026-08-11
|
||||
|
||||
- [UI / Bug fix] Recent servers remain one-click connectable and now include a separate copy action. (#2555)
|
||||
|
||||
+10
-6
@@ -85,19 +85,23 @@ cd omnigent
|
||||
|
||||
uv python install
|
||||
uv venv --python "$(cat .python-version)"
|
||||
uv sync --extra all --extra dev
|
||||
uv sync --extra all --group dev
|
||||
source .venv/bin/activate # or prefix commands with `uv run`
|
||||
```
|
||||
|
||||
Repository-only dependencies use PEP 735 groups: `lint` for static checks and
|
||||
code generation, `test` for pytest, and `dev` for both. Product capabilities
|
||||
remain installable extras. Plain `uv sync` installs neither group by default.
|
||||
|
||||
Common checks:
|
||||
|
||||
Pyrefly is the canonical Python type checker for the repository.
|
||||
|
||||
```bash
|
||||
uv run pytest # Python tests (e2e/live skipped by default)
|
||||
uv run ruff check . && uv run ruff format --check .
|
||||
uv run --no-sync pyrefly check # Python type checking (core and client SDK)
|
||||
uv run pre-commit run --all-files
|
||||
uv run --no-sync pytest # Python tests (e2e/live skipped by default)
|
||||
uv run --no-sync ruff check . && uv run --no-sync ruff format --check .
|
||||
uv run --no-sync pyrefly check # Python type checking (core and client SDK)
|
||||
uv run --no-sync pre-commit run --all-files
|
||||
```
|
||||
|
||||
When touching `web/`:
|
||||
@@ -135,7 +139,7 @@ test. A fresh worktree needs its own Python environment first:
|
||||
|
||||
```bash
|
||||
cd /path/to/omnigent-worktree
|
||||
uv sync --extra all --extra dev
|
||||
uv sync --extra all --group dev
|
||||
omnidev
|
||||
```
|
||||
|
||||
|
||||
@@ -365,8 +365,10 @@ Face Spaces**, **Modal**, **Cloudflare** (serverless, scale-to-zero), and
|
||||
covered too — and a **Cloudflare quick tunnel** (public) or **Tailscale**
|
||||
(private) reaches a server running on your own laptop without a deploy. The
|
||||
server can also provision a cloud sandbox per session (*managed hosts*), so no
|
||||
laptop has to stay online. The full menu of targets, the database options, and
|
||||
the sandbox setup live in
|
||||
laptop has to stay online. The full menu of targets, the database options, the
|
||||
sandbox setup, and
|
||||
[branding/white-labeling](https://github.com/omnigent-ai/omnigent/blob/main/deploy/README.md#branding-white-labeling)
|
||||
live in
|
||||
[`deploy/README.md`](https://github.com/omnigent-ai/omnigent/blob/main/deploy/README.md).
|
||||
|
||||
Once the server is up, sign in and register your laptop as a host:
|
||||
@@ -411,6 +413,9 @@ and they're in. Signup is invite-only.
|
||||
|
||||
- **Share a live session.** Hit **Share** in the web UI and send the link;
|
||||
teammates watch your agent work and chat with it in real time.
|
||||
- **Leave a shared session.** Done with a session someone shared with you?
|
||||
Pick **Leave session** from its sidebar row menu to drop it from your
|
||||
sidebar. Nothing is deleted — the owner keeps it and can share it again.
|
||||
- **Co-drive.** A teammate co-attaches to your running session; their
|
||||
messages execute on **your** machine. Great for pairing or handing the
|
||||
keyboard to a domain expert mid-investigation.
|
||||
|
||||
@@ -498,6 +498,40 @@ to set and sanitize the identity header, and read
|
||||
[`docker/README.md#header-proxy-mode-for-deploys-behind-an-existing-sso-proxy`](docker/README.md#header-proxy-mode-for-deploys-behind-an-existing-sso-proxy)
|
||||
first.
|
||||
|
||||
## Branding (white-labeling)
|
||||
|
||||
Customize the app name, landing heading, and logos with a `branding:` block in
|
||||
the server config (`omnigent server -c config.yaml`, or `<data_dir>/config.yaml`
|
||||
— `/data/config.yaml` in the Docker stack). Takes effect on the next server
|
||||
start.
|
||||
|
||||
```yaml
|
||||
branding:
|
||||
app_name: "Acme Agent" # tab title, sidebar wordmark, login screen
|
||||
heading: "How can I help?" # landing hero; "" hides it, omit to keep the default
|
||||
logo: # a bare string sets `main`; or per-variant:
|
||||
main: logo.png # branding-assets/logo.png
|
||||
loading: loading.webp # working indicator (falls back to main)
|
||||
favicon: favicon.png # browser-tab icon
|
||||
powered_by: true # "Powered by Omnigent" credit; false to hide
|
||||
```
|
||||
|
||||
Logo files must live under a dedicated `branding-assets/` directory beside the
|
||||
config file (for example, `/data/branding-assets/logo.png`). PNG, JPEG, GIF,
|
||||
WebP, and ICO files up to 5 MiB are accepted only after full decoder validation.
|
||||
ICO files must contain only PNG-backed entries; every directory entry is bounded
|
||||
and decoded independently, while DIB/BMP-backed entries are rejected. Malformed,
|
||||
truncated, oversized, overlapping, trailing-payload, SVG, symlinked, escaped, and
|
||||
non-image files are ignored. Images are also bounded to 4096 pixels per side,
|
||||
128 frames, 16 megapixels per frame, and 64 megapixels across all decoded frames.
|
||||
The values are served over the unauthenticated `GET /v1/info` and
|
||||
`GET /v1/branding/logo/<variant>` endpoints so the login screen is branded before
|
||||
sign-in. Any unset field keeps its built-in default, so a partial block is fine.
|
||||
|
||||
The small "Powered by Omnigent" credit under the landing composer appears only
|
||||
once you set custom branding; `powered_by: false` hides it even then. It always
|
||||
shows the Omnigent mascot, never your logo.
|
||||
|
||||
## Adding a new deploy target
|
||||
|
||||
Drop a new subdirectory under `deploy/<target>/` with a `README.md`
|
||||
|
||||
@@ -147,6 +147,17 @@ UC Volume wheel paths because `uv lock` validates path sources locally.
|
||||
|
||||
Re-running is safe — every step is idempotent.
|
||||
|
||||
Release features are off by default. Enable one or more for the whole app by
|
||||
adding the comma-separated deploy argument, then reload the web app after the
|
||||
redeploy:
|
||||
|
||||
```bash
|
||||
--features usage_page,harness_install
|
||||
```
|
||||
|
||||
See [`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for the current
|
||||
inventory and rollback procedure.
|
||||
|
||||
> [!TIP]
|
||||
> To lock against a private PyPI mirror or proxy instead of public
|
||||
> PyPI, set `UV_INDEX_URL` before running `deploy.py`.
|
||||
|
||||
@@ -21,6 +21,9 @@ variables:
|
||||
UC schema (catalog.schema) holding the OTel destination tables.
|
||||
The platform writes to <schema>.otel_logs, otel_metrics, otel_spans.
|
||||
default: main.omnigent_logs
|
||||
features:
|
||||
description: "Comma-separated deployment-wide release features."
|
||||
default: ""
|
||||
|
||||
resources:
|
||||
apps:
|
||||
@@ -44,6 +47,8 @@ resources:
|
||||
value_from: artifact_volume
|
||||
- name: OTEL_TRACES_SAMPLER
|
||||
value: 'always_on'
|
||||
- name: OMNIGENT_FEATURES
|
||||
value: "${var.features}"
|
||||
resources:
|
||||
- name: postgres
|
||||
postgres:
|
||||
|
||||
@@ -567,6 +567,14 @@ def _parse_args() -> argparse.Namespace:
|
||||
"<schema>.otel_{logs,metrics,spans}."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--features",
|
||||
default="",
|
||||
help=(
|
||||
"Comma-separated deployment-wide release features, e.g. "
|
||||
"'usage_page'. Empty keeps every release feature off."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--target",
|
||||
default="prod",
|
||||
@@ -746,6 +754,8 @@ def _bundle_vars(args: argparse.Namespace) -> list[str]:
|
||||
f"volume_name={args.volume_name}",
|
||||
"--var",
|
||||
f"otel_table_schema={args.otel_table_schema}",
|
||||
"--var",
|
||||
f"features={args.features}",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -15,6 +15,12 @@ POSTGRES_PASSWORD=change-me-please
|
||||
# Host port the omnigent container is published on. Default 8000.
|
||||
# OMNIGENT_PORT=8000
|
||||
|
||||
# ── Release features ─────────────────────────────────────
|
||||
# Comma-separated deployment-wide release features. Empty/unset keeps every
|
||||
# release feature off. Unknown names fail startup so typos cannot silently
|
||||
# change rollout behavior. Current keys: usage_page, harness_install.
|
||||
# OMNIGENT_FEATURES=usage_page
|
||||
|
||||
# ── Image ────────────────────────────────────────────────
|
||||
# The compose stack pulls a pre-built image from GHCR (built by CI on
|
||||
# every main-branch merge). Default: ghcr.io/omnigent-ai/omnigent-server.
|
||||
|
||||
@@ -39,6 +39,27 @@ Reset everything (drops the DB and the artifact store):
|
||||
docker compose down -v
|
||||
```
|
||||
|
||||
## Release features
|
||||
|
||||
Release features are deployment-wide and off by default. Enable one or more
|
||||
with the comma-separated `OMNIGENT_FEATURES` variable in `.env`, then recreate
|
||||
the server container:
|
||||
|
||||
```dotenv
|
||||
OMNIGENT_FEATURES=usage_page
|
||||
```
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
curl -s http://localhost:8000/v1/info | jq '.features'
|
||||
```
|
||||
|
||||
Known keys and their lifecycle are documented in
|
||||
[`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md). Unknown keys fail
|
||||
server startup so a typo cannot silently produce the wrong rollout. To roll
|
||||
back, remove the key (or empty the variable), run `docker compose up -d` again,
|
||||
and reload the web app.
|
||||
|
||||
## Multi-user mode (accounts — default)
|
||||
|
||||
Built-in accounts auth: no IdP to register, no proxy to host.
|
||||
|
||||
@@ -47,3 +47,16 @@ allowed_domains:
|
||||
# the built-in defaults (20 files / 256 MiB total).
|
||||
# copy_max_files: 20
|
||||
# copy_max_total_bytes: 268435456
|
||||
|
||||
# Branding / white-labeling. Customize the app name, landing heading, and
|
||||
# logos shown in the web UI. Logo files must live under branding-assets/ beside
|
||||
# this config file; served pre-auth so the login screen is branded too. Any unset
|
||||
# field keeps its built-in default. See deploy/README.md#branding-white-labeling.
|
||||
# branding:
|
||||
# app_name: "Acme Agent" # tab title, sidebar wordmark, login screen
|
||||
# heading: "How can I help?" # landing hero; "" hides it, omit for the default
|
||||
# logo: # a bare string sets `main`; or per-variant:
|
||||
# main: logo.png # hero / primary mark
|
||||
# loading: loading.webp # working indicator (falls back to main)
|
||||
# favicon: favicon.png # browser-tab icon
|
||||
# powered_by: true # "Powered by Omnigent" credit (only when branded); false to hide
|
||||
|
||||
@@ -62,6 +62,9 @@ services:
|
||||
ARTIFACT_DIR: /data/artifacts
|
||||
HOST: 0.0.0.0
|
||||
PORT: "8000"
|
||||
# Comma-separated deployment-wide release features. Empty means all
|
||||
# release features are off; see .env.example for the known keys.
|
||||
OMNIGENT_FEATURES: "${OMNIGENT_FEATURES:-}"
|
||||
# Anchor the server's data dir on the persistent volume so
|
||||
# file-backed operator config survives container restarts:
|
||||
# the admin roster (/data/admins) and allowed-domains file
|
||||
|
||||
@@ -304,6 +304,11 @@ def _build_routing(
|
||||
return _build_local_llm_routing_client(server_llm), settings
|
||||
|
||||
|
||||
def _resolve_execution_timeout(cfg: dict[str, Any]) -> int:
|
||||
"""Return the configured execution limit or the RuntimeCaps default."""
|
||||
return int(cfg.get("execution_timeout") or 7200)
|
||||
|
||||
|
||||
def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
"""Resolve config if needed, wire the stores, and build the app.
|
||||
|
||||
@@ -374,6 +379,7 @@ def build_app(resolved_config: _ResolvedConfig | None = None) -> _BuiltApp:
|
||||
routing_client, routing_settings = _build_routing(cfg, server_llm)
|
||||
|
||||
caps = RuntimeCaps(
|
||||
execution_timeout=_resolve_execution_timeout(cfg),
|
||||
default_policies=parse_default_policies(cfg.get("policies")),
|
||||
llm=server_llm,
|
||||
routing_client=routing_client,
|
||||
|
||||
@@ -91,6 +91,22 @@ Apply your chosen issuer with `kubectl apply -f <file>`. Without it, cert-manage
|
||||
logs `IssuerNotFound` and no certificate is issued (the server still runs — only
|
||||
TLS is affected).
|
||||
|
||||
## Release features
|
||||
|
||||
Release features are deployment-wide and off by default. Set the
|
||||
comma-separated `OMNIGENT_FEATURES` value in `base/configmap.yaml`, apply your
|
||||
Kustomize target, and restart the Deployment so every pod receives one fresh
|
||||
startup snapshot:
|
||||
|
||||
```bash
|
||||
kubectl kustomize deploy/kubernetes/base/ | kubectl apply -f -
|
||||
kubectl rollout restart deployment/omnigent
|
||||
kubectl rollout status deployment/omnigent
|
||||
```
|
||||
|
||||
Use the same restart after removing a feature for rollback. See
|
||||
[`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for known keys.
|
||||
|
||||
## Deploy with an external database
|
||||
|
||||
Use this path when you have a managed Postgres (RDS, Cloud SQL, Neon, etc.).
|
||||
@@ -270,6 +286,38 @@ kubectl apply -k deploy/kubernetes/overlays/sandbox-runners
|
||||
Both are detailed in
|
||||
[`overlays/sandbox-runners/README.md`](overlays/sandbox-runners/README.md#server-auth-managed-hosts).
|
||||
|
||||
## Deploy with ArgoCD
|
||||
|
||||
The `overlays/argocd/` overlay adds ArgoCD safety annotations (`Prune=false` on
|
||||
stateful resources, `ignoreDifferences` for operator-managed Secrets) onto
|
||||
`sandbox-runners`. ArgoCD renders Kustomize natively — no plugin needed.
|
||||
|
||||
**Prerequisites:** fork the repo and replace the placeholder values in
|
||||
`base/secret.yaml` in your fork (ArgoCD reads from Git, not your disk). The
|
||||
default `accounts` auth provider refuses the managed runner dial-back — use
|
||||
header/OIDC auth or single-user (see
|
||||
[sandbox-runners README § Server auth](overlays/sandbox-runners/README.md#server-auth-managed-hosts)).
|
||||
|
||||
```bash
|
||||
# 1. Edit application.yaml — set repoURL to your fork, targetRevision to your
|
||||
# branch — then apply:
|
||||
kubectl apply -f deploy/kubernetes/overlays/argocd/application.yaml
|
||||
|
||||
# 2. Wait for ArgoCD to create the runner namespace (up to 3 min without a webhook):
|
||||
kubectl wait --for=jsonpath='{.status.phase}'=Active \
|
||||
namespace/omnigent-sandboxes --timeout=300s
|
||||
|
||||
# 3. Create the harness-credentials Secret (see sandbox-runners README):
|
||||
kubectl create secret generic omnigent-creds -n omnigent-sandboxes \
|
||||
--from-literal=ANTHROPIC_API_KEY=sk-ant-... \
|
||||
--from-literal=OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
For production, manage `omnigent-creds` with
|
||||
[sealed-secrets](https://github.com/bitnami-labs/sealed-secrets) or
|
||||
[external-secrets](https://external-secrets.io/). See
|
||||
[`overlays/argocd/README.md`](overlays/argocd/README.md) for the full guide.
|
||||
|
||||
## Verify the deployment
|
||||
|
||||
Check the rollout and reach the server without a public domain:
|
||||
|
||||
@@ -8,6 +8,8 @@ data:
|
||||
HOST: "0.0.0.0"
|
||||
PORT: "8000"
|
||||
ARTIFACT_DIR: "/data/artifacts"
|
||||
# Comma-separated release features; empty keeps every feature off.
|
||||
OMNIGENT_FEATURES: ""
|
||||
OMNIGENT_ADMIN_CREDENTIALS_PATH: "/data/admin-credentials"
|
||||
OMNIGENT_AUTH_ENABLED: "1"
|
||||
OMNIGENT_AUTH_PROVIDER: "accounts"
|
||||
|
||||
@@ -0,0 +1,135 @@
|
||||
# ArgoCD overlay
|
||||
|
||||
Deploy Omnigent with the kubernetes sandbox provider via ArgoCD. This overlay
|
||||
adds safety annotations onto the
|
||||
[`sandbox-runners`](../sandbox-runners/README.md) overlay:
|
||||
|
||||
- **`Prune=false`** on Namespaces and the artifact PVC, so an accidental prune
|
||||
or Application deletion does not cascade to operator-created Secrets and
|
||||
runner Pods.
|
||||
- **Ingress in wave 1**, so its health check (which requires an ingress
|
||||
controller) does not gate the rest of the sync.
|
||||
|
||||
ArgoCD's built-in kind ordering already applies resources in dependency order
|
||||
(Namespace → SA → Role → ConfigMap → Secret → Service → Deployment → Ingress),
|
||||
so explicit sync-wave ordering for every resource is unnecessary.
|
||||
|
||||
ArgoCD renders Kustomize natively — no plugin or Helm chart needed.
|
||||
|
||||
## Quick start
|
||||
|
||||
1. **Fork the repo** — ArgoCD reads from Git, not your local disk. All edits
|
||||
below go into your fork and must be committed and pushed to the branch
|
||||
`targetRevision` names (default: `HEAD` / your default branch).
|
||||
|
||||
2. **Replace placeholder secrets** — `base/secret.yaml` ships `changeme`
|
||||
values. In your fork, set real values and commit:
|
||||
|
||||
```yaml
|
||||
# deploy/kubernetes/base/secret.yaml
|
||||
DATABASE_URL: "postgresql+psycopg://user:pass@your-db-host:5432/omnigent"
|
||||
OMNIGENT_ACCOUNTS_COOKIE_SECRET: "<run: openssl rand -hex 32>"
|
||||
```
|
||||
|
||||
For production, manage `omnigent-secrets` externally (sealed-secrets or
|
||||
external-secrets) and remove `secret.yaml` from the overlay render with a
|
||||
`$patch: delete` — see `openshift/kustomization.yaml:12-20` for the pattern.
|
||||
The Application's `ignoreDifferences` entry prevents `selfHeal` from
|
||||
reverting out-of-band edits to this Secret's data.
|
||||
|
||||
3. **Configure server auth** — the default `accounts` provider refuses the
|
||||
managed runner dial-back (`403`). Front the server with **header or OIDC
|
||||
auth**, or run single-user. See
|
||||
[`sandbox-runners/README.md` § Server auth](../sandbox-runners/README.md#server-auth-managed-hosts).
|
||||
|
||||
4. **Set your domain** *(optional)* — replace `omnigent.example.com` in
|
||||
`base/ingress.yaml`. To skip the Ingress entirely, add a `$patch: delete`
|
||||
in your fork's overlay (see `openshift/kustomization.yaml:12-20` for the
|
||||
pattern — do not delete `base/ingress.yaml` itself, as it is shared by all
|
||||
overlays).
|
||||
|
||||
5. **Edit and apply the Application CR:**
|
||||
|
||||
```bash
|
||||
# In application.yaml, set repoURL to your fork and targetRevision to
|
||||
# the branch you pushed to:
|
||||
kubectl apply -f deploy/kubernetes/overlays/argocd/application.yaml
|
||||
```
|
||||
|
||||
6. **Wait for the sync** — ArgoCD creates the namespaces asynchronously (up
|
||||
to 3 minutes without a webhook). Wait before creating the harness Secret:
|
||||
|
||||
```bash
|
||||
kubectl wait --for=jsonpath='{.status.phase}'=Active \
|
||||
namespace/omnigent-sandboxes --timeout=300s
|
||||
```
|
||||
|
||||
7. **Create the harness-credentials Secret** — LLM API keys for runner Pods.
|
||||
Not in Git (credentials don't belong there):
|
||||
|
||||
```bash
|
||||
kubectl create secret generic omnigent-creds -n omnigent-sandboxes \
|
||||
--from-literal=ANTHROPIC_API_KEY=sk-ant-... \
|
||||
--from-literal=OPENAI_API_KEY=sk-...
|
||||
```
|
||||
|
||||
For production, manage this with
|
||||
[sealed-secrets](https://github.com/bitnami-labs/sealed-secrets) or
|
||||
[external-secrets](https://external-secrets.io/).
|
||||
|
||||
## What ArgoCD does not create
|
||||
|
||||
ArgoCD does not *create* these resources — but it **owns the namespaces they
|
||||
live in**. Deleting the Application (with the default finalizer) deletes both
|
||||
namespaces and garbage-collects everything inside them, including:
|
||||
|
||||
- **`omnigent-creds` Secret** (step 7 above) — without it, runner Pods stall
|
||||
in `CreateContainerConfigError`. See the
|
||||
[sandbox-runners README](../sandbox-runners/README.md#apply) for which keys
|
||||
to set.
|
||||
- **OIDC / external-auth Secrets** — if you front the server with OIDC, create
|
||||
the provider Secret separately (see the
|
||||
[base README](../../README.md#use-your-own-idp-instead-oidc--optional)).
|
||||
|
||||
The `Prune=false` annotations protect Namespaces and the PVC during **sync**
|
||||
(accidental prune from a Git rename), but the Application finalizer bypasses
|
||||
them on **deletion**. To make `kubectl delete application` orphan resources
|
||||
instead of cascading, remove the `resources-finalizer.argocd.argoproj.io`
|
||||
finalizer from `application.yaml`.
|
||||
|
||||
## What automated sync does
|
||||
|
||||
- **`prune: true`** — resources that leave Git are deleted from the cluster on
|
||||
the next sync. `Prune=false` annotations on Namespaces and the PVC exempt
|
||||
them.
|
||||
- **`selfHeal: true`** — manual cluster edits are reverted to match Git.
|
||||
`ignoreDifferences` on `omnigent-secrets` and `omnigent-artifacts` exempts
|
||||
their data, so out-of-band credential edits and volume expansions are kept.
|
||||
- **Deleting the Application** — with the finalizer, deletes both namespaces,
|
||||
the artifact PVC, and everything inside them. Without it, orphans everything.
|
||||
|
||||
## Customizing
|
||||
|
||||
Fork the repo, edit, commit, and push — ArgoCD picks up changes on the next
|
||||
sync. Common adjustments:
|
||||
|
||||
- **Sandbox config** — `../sandbox-runners/sandbox-config.yaml` (namespace,
|
||||
image, node selector, resource limits, PVC mounts). Note: changes to
|
||||
ConfigMaps require a Pod restart to take effect (the server reads config at
|
||||
startup). Use `configMapGenerator` with a name-suffix hash to trigger an
|
||||
automatic rollout, or restart the Deployment manually after sync.
|
||||
- **Server resources** — `../../base/deployment.yaml`.
|
||||
- **Ingress** — `../../base/ingress.yaml` (hostname, TLS, annotations). To
|
||||
remove the Ingress, add a `$patch: delete` in the overlay (see
|
||||
`openshift/kustomization.yaml`).
|
||||
- **In-cluster Postgres** — use `overlays/openshift-postgres/` as a reference
|
||||
for composing two overlays that share a base; adding `../postgres/` as a
|
||||
direct resource causes a duplicate-base error. Alternatively, apply the
|
||||
Postgres StatefulSet separately.
|
||||
|
||||
## ApplicationSet (multi-environment)
|
||||
|
||||
For staging/production splits, use an ArgoCD
|
||||
[ApplicationSet](https://argo-cd.readthedocs.io/en/stable/operator-manual/applicationset/)
|
||||
with a list generator. Point each entry at a different `targetRevision` (branch)
|
||||
or fork the overlay directory per environment with its own config values.
|
||||
@@ -0,0 +1,79 @@
|
||||
---
|
||||
# Sample ArgoCD Application CR for deploying Omnigent with the kubernetes
|
||||
# sandbox provider. Fork the repo, edit config/secrets in your fork, then
|
||||
# set repoURL and targetRevision below. Apply to the argocd namespace.
|
||||
#
|
||||
# ArgoCD renders the Kustomize output natively — no Helm chart or plugin needed.
|
||||
#
|
||||
# TWO SECRETS ARE NOT IN GIT and must be created out of band (or via
|
||||
# sealed-secrets / external-secrets):
|
||||
#
|
||||
# 1. omnigent-secrets — DATABASE_URL + cookie secret (see base/secret.yaml
|
||||
# for the keys; replace the placeholder values in your fork, or manage
|
||||
# the Secret externally and remove secret.yaml from the overlay render
|
||||
# with a $patch: delete — see openshift/kustomization.yaml for the
|
||||
# pattern).
|
||||
# 2. omnigent-creds — harness LLM API keys for runner Pods (see the
|
||||
# sandbox-runners README).
|
||||
#
|
||||
# DELETION WARNING: the resources-finalizer below means
|
||||
# `kubectl delete application omnigent` deletes every tracked resource,
|
||||
# including both Namespace objects and the artifact PVC. The overlay's
|
||||
# Prune=false annotations prevent accidental pruning during sync, but the
|
||||
# finalizer bypasses them on Application deletion. Remove the finalizer
|
||||
# if you want `kubectl delete application` to orphan resources instead of
|
||||
# cascading.
|
||||
apiVersion: argoproj.io/v1alpha1
|
||||
kind: Application
|
||||
metadata:
|
||||
name: omnigent
|
||||
namespace: argocd
|
||||
finalizers:
|
||||
- resources-finalizer.argocd.argoproj.io
|
||||
spec:
|
||||
project: default
|
||||
source:
|
||||
repoURL: https://github.com/omnigent-ai/omnigent.git
|
||||
targetRevision: HEAD
|
||||
path: deploy/kubernetes/overlays/argocd
|
||||
destination:
|
||||
# Resources set their own namespaces explicitly (omnigent and
|
||||
# omnigent-sandboxes), so destination.namespace is not injected.
|
||||
server: https://kubernetes.default.svc
|
||||
ignoreDifferences:
|
||||
# The checked-in secret.yaml ships placeholder values. Operators replace
|
||||
# them out of band (kubectl edit, sealed-secrets, external-secrets), and
|
||||
# selfHeal must not revert those edits. Without this, selfHeal
|
||||
# continuously overwrites live credentials with the placeholder.
|
||||
#
|
||||
# The pointer targets /data, not /stringData, because ArgoCD normalizes
|
||||
# stringData into base64 /data on the live object before diffing.
|
||||
- group: ""
|
||||
kind: Secret
|
||||
name: omnigent-secrets
|
||||
namespace: omnigent
|
||||
jsonPointers:
|
||||
- /data
|
||||
# The API server mutates spec.resources.requests.storage on PVC creation
|
||||
# (rounding, defaulting). selfHeal would report a perpetual diff.
|
||||
- group: ""
|
||||
kind: PersistentVolumeClaim
|
||||
name: omnigent-artifacts
|
||||
namespace: omnigent
|
||||
jsonPointers:
|
||||
- /spec/resources/requests/storage
|
||||
syncPolicy:
|
||||
automated:
|
||||
prune: true
|
||||
selfHeal: true
|
||||
syncOptions:
|
||||
# Honour the ignoreDifferences entries above during sync, not just
|
||||
# during diff. Without this, a manual sync still overwrites the live
|
||||
# secret values even though the diff view hides them.
|
||||
- RespectIgnoreDifferences=true
|
||||
retry:
|
||||
limit: 3
|
||||
backoff:
|
||||
duration: 10s
|
||||
factor: 2
|
||||
maxDuration: 3m
|
||||
@@ -0,0 +1,46 @@
|
||||
apiVersion: kustomize.config.k8s.io/v1beta1
|
||||
kind: Kustomization
|
||||
|
||||
# ArgoCD overlay for the kubernetes sandbox provider. Adds safety annotations
|
||||
# (Prune=false on stateful resources, Ingress in a late wave) onto the
|
||||
# sandbox-runners overlay. ArgoCD's built-in kind ordering already sequences
|
||||
# Namespace → SA → Role → ConfigMap → Secret → Service → Deployment → Ingress,
|
||||
# so explicit sync waves are not needed for ordering — only to prevent the
|
||||
# Ingress health gate from blocking the sync on clusters without a controller.
|
||||
|
||||
resources:
|
||||
- ../sandbox-runners
|
||||
|
||||
patches:
|
||||
# Namespaces and the artifact PVC must survive Application deletion and
|
||||
# accidental prune (a rename in base/ or a stale targetRevision). Without
|
||||
# Prune=false, `kubectl delete application omnigent` cascades through the
|
||||
# finalizer to both namespaces and everything inside them — including the
|
||||
# operator-created omnigent-creds Secret and any pvc_mounts claims.
|
||||
- target:
|
||||
kind: Namespace
|
||||
patch: |
|
||||
- op: add
|
||||
path: /metadata/annotations/argocd.argoproj.io~1sync-options
|
||||
value: Prune=false
|
||||
- target:
|
||||
kind: PersistentVolumeClaim
|
||||
patch: |
|
||||
- op: add
|
||||
path: /metadata/annotations/argocd.argoproj.io~1sync-options
|
||||
value: Prune=false
|
||||
|
||||
# The Ingress depends on an ingress controller (nginx by default) and
|
||||
# cert-manager. ArgoCD scores an Ingress without status.loadBalancer as
|
||||
# Progressing. Wave 1 (everything else is implicit wave 0) means no later
|
||||
# sync wave gates on its health, so the *sync* completes. The Application
|
||||
# itself may still report Progressing indefinitely on a cluster without a
|
||||
# controller — with automated + selfHeal, that keeps the Application in a
|
||||
# perpetual reconcile loop (harmless but noisy). Install the controller or
|
||||
# add a custom health check that treats the Ingress as healthy.
|
||||
- target:
|
||||
kind: Ingress
|
||||
patch: |
|
||||
- op: add
|
||||
path: /metadata/annotations/argocd.argoproj.io~1sync-wave
|
||||
value: "1"
|
||||
@@ -1,42 +1,43 @@
|
||||
# Kubernetes sandbox runners (on-demand host Pods)
|
||||
|
||||
This Kustomize overlay turns on the **`kubernetes`** managed-sandbox provider: a
|
||||
`host_type: managed` session spawns one **runner Pod** that runs `omnigent host`
|
||||
as its container entrypoint and dials back to the server over the existing
|
||||
launch-token tunnel. It layers the RBAC + config the provider needs onto the
|
||||
base server deployment.
|
||||
`host_type: managed` session spawns a **batch/v1 Job** whose child Pod runs
|
||||
`omnigent host` as its container entrypoint and dials back to the server over the
|
||||
existing launch-token tunnel. It layers the RBAC + config the provider needs onto
|
||||
the base server deployment.
|
||||
|
||||
## Launch model: entrypoint-as-host
|
||||
|
||||
The runner Pod's container command **is** the host. An **init container**
|
||||
prepares the workspace (`mkdir` + optional `git clone`); the **main container**
|
||||
then runs `omnigent host` under a tiny PID-1 reaper. The host re-parents runner
|
||||
processes to PID 1, which the reaper reaps; SIGTERM is forwarded for graceful
|
||||
shutdown.
|
||||
The runner is launched as a **batch/v1 Job** (one Pod, `backoffLimit: 6`). The
|
||||
Job's child Pod runs `omnigent host` as its container command. An **init
|
||||
container** prepares the workspace (`mkdir` + optional `git clone`); the **main
|
||||
container** then runs `omnigent host` under a tiny PID-1 reaper. The host
|
||||
re-parents runner processes to PID 1, which the reaper reaps; SIGTERM is
|
||||
forwarded for graceful shutdown.
|
||||
|
||||
The launch token is delivered through a **per-Pod Kubernetes Secret** referenced
|
||||
The launch token is delivered through a **per-Job Kubernetes Secret** referenced
|
||||
by the Pod's `secretKeyRef` — it never enters the Pod spec, a command line, or
|
||||
an audit log. The launcher creates that Secret at provision and deletes it
|
||||
alongside the Pod at terminate.
|
||||
alongside the Job at terminate.
|
||||
|
||||
Because the host is **never started by `exec`-ing into an already-running
|
||||
container**, this provider needs **no `pods/exec` grant** — and avoids the
|
||||
exec-into-running-container class of runtime issues entirely. The server SA's
|
||||
rights are the minimum the launcher calls: create/get/delete Pods, get
|
||||
`pods/log` (start-failure diagnostics only), create/delete Secrets (the per-Pod
|
||||
token), and list events.
|
||||
rights are the minimum the launcher calls: create/get/delete Jobs,
|
||||
list/get Pods (to poll the Job's child), get `pods/log` (start-failure
|
||||
diagnostics only), create/delete Secrets (the per-Job token), and list events.
|
||||
|
||||
## Two-namespace, least-blast-radius design
|
||||
|
||||
| Namespace | Holds |
|
||||
|---|---|
|
||||
| `omnigent` | the server, its DB/PVC, its Secrets, the `omnigent-server` SA |
|
||||
| `omnigent-sandboxes` | runner Pods, the per-Pod token Secrets, the harness-creds Secret, the powerless `omnigent-runner` SA, the scoped Role + RoleBinding |
|
||||
| `omnigent-sandboxes` | runner Jobs (and their child Pods), the per-Job token Secrets, the harness-creds Secret, the powerless `omnigent-runner` SA, the scoped Role + RoleBinding |
|
||||
|
||||
The server SA's Pod/Secret rights are a **namespaced Role** bound (cross-namespace)
|
||||
to `omnigent-sandboxes` only — so a compromised server can manage runner Pods but
|
||||
**cannot** delete the server/DB Pods, read the server's Secrets, or execute
|
||||
commands inside any Pod. The runner namespace enforces Pod Security `restricted`;
|
||||
The server SA's Job/Pod/Secret rights are a **namespaced Role** bound
|
||||
(cross-namespace) to `omnigent-sandboxes` only — so a compromised server can
|
||||
manage runner Jobs but **cannot** delete the server/DB Pods, read the server's
|
||||
Secrets, or execute commands inside any Pod. The runner namespace enforces Pod Security `restricted`;
|
||||
the generated runner Pod is already restricted-compliant (non-root uid 1000, drop
|
||||
`ALL` caps, `seccompProfile: RuntimeDefault`, no privilege escalation).
|
||||
|
||||
@@ -83,7 +84,7 @@ runner Pod unexpectedly carries no credential:
|
||||
(`omnigent/server/managed_hosts.py`), e.g. "agent … is not a genuine built-in;
|
||||
omitting agent label".
|
||||
- A name that is not a valid label value logs a `WARNING` from
|
||||
`build_pod_manifest` (`omnigent/onboarding/sandboxes/kubernetes.py`), e.g.
|
||||
`build_job_manifest` (`omnigent/onboarding/sandboxes/kubernetes.py`), e.g.
|
||||
"agent … is not a valid omnigent.ai/agent value; runner Pod … stays
|
||||
unclassified". Note the gate upstream will already have logged this agent as
|
||||
classified, so this is the line that explains the missing label.
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
---
|
||||
# Namespaced Role granting the server EXACTLY what the entrypoint-as-host
|
||||
# launcher calls — nothing more (no watch, no pods/exec). Lives in the DEDICATED
|
||||
# runner namespace `omnigent-sandboxes` and is bound to the omnigent-server SA
|
||||
# (in `omnigent`) via the cross-namespace rolebinding.yaml. Because the grant is
|
||||
# a namespaced Role here, it can ONLY ever touch objects in `omnigent-sandboxes`,
|
||||
# never the server/DB Pods or Secrets in `omnigent`.
|
||||
# Namespaced Role granting what the entrypoint-as-host launcher needs (no watch,
|
||||
# no pods/exec). Lives in the DEDICATED runner namespace `omnigent-sandboxes`
|
||||
# and is bound to the omnigent-server SA (in `omnigent`) via the cross-namespace
|
||||
# rolebinding.yaml. Because the grant is a namespaced Role here, it can ONLY
|
||||
# ever touch objects in `omnigent-sandboxes`, never the server/DB Pods or
|
||||
# Secrets in `omnigent`.
|
||||
#
|
||||
# pods:create is retained temporarily for the bare-Pod → Job migration (see
|
||||
# TODO below). secrets:get is deliberately withheld — the launcher never reads
|
||||
# Secrets back, and the harness-credentials Secret is operator-managed.
|
||||
# pods/exec is withheld — the host is an entrypoint, not exec'd into.
|
||||
apiVersion: rbac.authorization.k8s.io/v1
|
||||
kind: Role
|
||||
metadata:
|
||||
@@ -14,34 +19,31 @@ metadata:
|
||||
app.kubernetes.io/name: omnigent
|
||||
app.kubernetes.io/component: server
|
||||
rules:
|
||||
# Manage the lifecycle of runner Pods, scoped to exactly what the launcher
|
||||
# calls: provision_managed_host() creates them, the pod-start wait reads them
|
||||
# (read_namespaced_pod is a `get`), and terminate() deletes them. The launcher
|
||||
# never watches Pods, so `watch` is omitted. NOTE: there is deliberately NO
|
||||
# `pods/exec` grant — the host runs as the Pod's OWN entrypoint
|
||||
# (`omnigent host` under a PID-1 reaper), so the server never execs into a
|
||||
# running container. Dropping exec removes the most powerful grant a
|
||||
# compromised server could abuse (arbitrary in-Pod command execution).
|
||||
# Manage the lifecycle of runner Jobs. The launcher creates a batch/v1 Job
|
||||
# (which spawns a child Pod), reads the Job's child Pod to poll start
|
||||
# readiness, and deletes the Job (cascading to its Pods) at terminate.
|
||||
- apiGroups: ["batch"]
|
||||
resources: ["jobs"]
|
||||
verbs: ["create", "get", "delete"]
|
||||
# The launcher lists Pods by job-name label to find the Job's child Pod,
|
||||
# then reads it to poll for Running phase. create/delete are retained so
|
||||
# old-server (bare Pod) + new-Role doesn't break, and so terminate() can
|
||||
# fall back to deleting a bare Pod created before the Job migration.
|
||||
# TODO(v0.29): remove create/delete once all runners have rolled past v0.28.
|
||||
- apiGroups: [""]
|
||||
resources: ["pods"]
|
||||
verbs: ["create", "get", "delete"]
|
||||
# Start-failure diagnostics ONLY: when a Pod won't start, the launcher tails
|
||||
# the failed container's log (e.g. the init container's `git clone` error) so
|
||||
# the launch error names WHAT failed instead of a generic timeout. Read-only.
|
||||
verbs: ["create", "list", "get", "delete"]
|
||||
# Start-failure diagnostics ONLY: tails the failed container's log.
|
||||
- apiGroups: [""]
|
||||
resources: ["pods/log"]
|
||||
verbs: ["get"]
|
||||
# The per-launch token rides a per-Pod Secret (referenced by the Pod's
|
||||
# `secretKeyRef`), so the launch token never enters the Pod spec or any
|
||||
# audit-logged surface. The launcher creates that Secret at provision and
|
||||
# deletes it alongside the Pod at terminate — hence create + delete (no get:
|
||||
# the launcher never reads Secrets back, and the harness-credentials Secret is
|
||||
# operator-managed, not touched here).
|
||||
# The per-launch token rides a per-Job Secret (referenced by the Pod's
|
||||
# `secretKeyRef`). The launcher creates that Secret before the Job and
|
||||
# deletes it alongside the Job at terminate.
|
||||
- apiGroups: [""]
|
||||
resources: ["secrets"]
|
||||
verbs: ["create", "delete"]
|
||||
# Surface scheduler/kubelet events (FailedScheduling, Failed pull, …) in the
|
||||
# provider's error messages when a Pod won't become ready.
|
||||
# Surface scheduler/kubelet events in the provider's error messages.
|
||||
- apiGroups: [""]
|
||||
resources: ["events"]
|
||||
verbs: ["list"]
|
||||
|
||||
@@ -404,6 +404,6 @@ upload, foreground streaming, attach, terminate, env passthrough, error handling
|
||||
and the managed-config parsing:
|
||||
|
||||
```bash
|
||||
uv pip install -e '.[openshell,dev]'
|
||||
pytest tests/onboarding/sandboxes/test_openshell.py tests/server/test_managed_hosts.py
|
||||
uv sync --extra openshell --group test
|
||||
uv run --no-sync pytest tests/onboarding/sandboxes/test_openshell.py tests/server/test_managed_hosts.py
|
||||
```
|
||||
|
||||
@@ -73,6 +73,14 @@ steps below are validated end-to-end:
|
||||
> visitor. Pre-seed `OMNIGENT_ACCOUNTS_INIT_ADMIN_PASSWORD`, or complete setup
|
||||
> promptly after the deploy goes live.
|
||||
|
||||
## Release features
|
||||
|
||||
In the Omnigent service's **Variables** tab, set `OMNIGENT_FEATURES` to a
|
||||
comma-separated enabled set such as `usage_page`. Railway redeploys the service
|
||||
automatically. Remove the key from the value to roll back, then reload the web
|
||||
app. See [`designs/FEATURE_FLAGS.md`](../../designs/FEATURE_FLAGS.md) for known
|
||||
keys.
|
||||
|
||||
## Use your own IdP instead (OIDC)
|
||||
|
||||
Prefer GitHub / Google / Okta login over built-in accounts? Switch the provider
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
# Release feature flags
|
||||
|
||||
Omnigent release features are deployment-wide, temporary rollout switches. They
|
||||
are not authorization controls or user preferences.
|
||||
|
||||
## Configuration
|
||||
|
||||
Set the comma-separated `OMNIGENT_FEATURES` environment variable and restart or
|
||||
redeploy the server:
|
||||
|
||||
```bash
|
||||
OMNIGENT_FEATURES=usage_page,harness_install
|
||||
```
|
||||
|
||||
Unset or empty means every release feature is off. Unknown names fail startup.
|
||||
The former `OMNIGENT_HARNESS_INSTALL_ENABLED` switch is rejected with a
|
||||
migration hint; use `OMNIGENT_FEATURES=harness_install` instead. The server resolves the set once at startup and publishes frontend-visible
|
||||
values in `GET /v1/info` under `features`. Users must reload the web app after a
|
||||
flag change because server capabilities are cached at page boot.
|
||||
|
||||
`omnigent/server/feature_flags.py` is the source of truth for known keys and
|
||||
lifecycle metadata.
|
||||
|
||||
## Inventory
|
||||
|
||||
| Key | Default | Owner | Review by | Purpose |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `usage_page` | Off | Web | 0.11.0 | Exposes the web Usage route, sidebar navigation, timeline, and cost breakdown details. The existing `GET /v1/usage` CLI API remains available while off. |
|
||||
| `harness_install` | Off | Onboarding | 0.11.0 | Allows the web UI to install or configure supported harnesses on a connected host. |
|
||||
|
||||
At the review release, each flag must be removed by making the feature
|
||||
unconditional, removing the feature, or moving a genuinely permanent operator
|
||||
policy into normal server configuration.
|
||||
|
||||
## Rollout and rollback
|
||||
|
||||
1. Deploy an immutable image with the feature absent from `OMNIGENT_FEATURES`.
|
||||
2. Enable it on one deployment, consistently across all replicas.
|
||||
3. Verify `GET /v1/info`, then reload and exercise the gated UI.
|
||||
4. Expand by deployment cohort.
|
||||
5. Roll back by removing the key and redeploying the same image.
|
||||
@@ -68,6 +68,21 @@ an empty DB they self-seed a small fallback session over HTTP (the
|
||||
`external_conversation_item` event — appends items without starting a task), so
|
||||
they still work with no runner or LLM.
|
||||
|
||||
### Hook spawn (no server)
|
||||
|
||||
| Journey | Operation timed |
|
||||
| --- | --- |
|
||||
| `native_hook_spawn` | Spawn the per-chunk `MessageDisplay` hook exactly as Claude Code does — isolated interpreter, module entrypoint, JSON payload on stdin |
|
||||
|
||||
Claude Code **blocks its TUI** on command hooks, so one hook subprocess's
|
||||
lifetime is user-visible streaming latency, and the same interpreter+import
|
||||
cost fronts every statusline refresh and per-tool-call policy hook. The
|
||||
journey needs no server or runner; registering it here rides hook spawn cost
|
||||
on the same nightly/release regression comparison as everything else
|
||||
(`omnigent/__init__` re-exports lazily so this stays ~interpreter-sized). The
|
||||
import-graph side of the guarantee is pinned deterministically by
|
||||
`tests/test_claude_native_message_display_hook.py`.
|
||||
|
||||
### Full-turn (runner + mock LLM)
|
||||
|
||||
These drive a real agent turn end-to-end — `POST …/events` → server → **runner**
|
||||
|
||||
@@ -44,6 +44,12 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
@@ -97,6 +103,9 @@ class Journey:
|
||||
per op, so 100+ iterations would blow the CI time budget; they cap at a
|
||||
few samples per run and lean on ``--runs`` for repeats. ``None`` (HTTP
|
||||
journeys) means no cap.
|
||||
:param skip_warmup: When ``True``, the warmup phase is skipped regardless
|
||||
of ``--warmup``. Useful for expensive journeys where even a single
|
||||
warmup iteration would waste significant time.
|
||||
:param description: Human-readable one-liner for ``--list``.
|
||||
"""
|
||||
|
||||
@@ -110,6 +119,7 @@ class Journey:
|
||||
needs_runner: bool = False
|
||||
needs_host: bool = False
|
||||
max_iterations: int | None = None
|
||||
skip_warmup: bool = False
|
||||
description: str = ""
|
||||
|
||||
async def run_setup(self, env: BenchEnvironment) -> JourneyContext:
|
||||
@@ -131,10 +141,13 @@ def _failure_reason(exc: Exception) -> str:
|
||||
"""Classify an exception into a stable failure-breakdown label.
|
||||
|
||||
HTTP status errors key off their status code (``"HTTP 500"``) so the same
|
||||
server error groups across ops; anything else keys off its class name.
|
||||
server error groups across ops; RuntimeErrors include the message so CI
|
||||
failure breakdowns show the actual cause; anything else keys off class name.
|
||||
"""
|
||||
if isinstance(exc, httpx.HTTPStatusError):
|
||||
return f"HTTP {exc.response.status_code}"
|
||||
if isinstance(exc, RuntimeError):
|
||||
return f"RuntimeError: {exc}"
|
||||
return exc.__class__.__name__
|
||||
|
||||
|
||||
@@ -235,7 +248,8 @@ async def run_latency(
|
||||
except Exception as exc: # noqa: BLE001 — a setup failure is a recorded data point
|
||||
return _setup_failed_result(exc)
|
||||
try:
|
||||
for _ in range(warmup):
|
||||
effective_warmup = 0 if journey.skip_warmup else warmup
|
||||
for _ in range(effective_warmup):
|
||||
with contextlib.suppress(Exception): # warmup errors are non-fatal
|
||||
await journey.run_prepare(env, ctx)
|
||||
await journey.measure(env, ctx)
|
||||
@@ -681,6 +695,12 @@ async def _measure_read_runner_file(env: BenchEnvironment, ctx: JourneyContext)
|
||||
|
||||
# ── policy evaluate ──────────────────────────────────────────
|
||||
|
||||
|
||||
def _bench_policy_allow(_event: dict) -> dict: # type: ignore[type-arg]
|
||||
"""Benchmark policy function: always ALLOW. Self-contained in this module."""
|
||||
return {"result": "allow"}
|
||||
|
||||
|
||||
_POLICY_EVALUATE_PAYLOAD = {
|
||||
"event": {
|
||||
"type": "PHASE_TOOL_CALL",
|
||||
@@ -707,15 +727,24 @@ async def _setup_policy_evaluate_session(env: BenchEnvironment) -> str:
|
||||
|
||||
import yaml
|
||||
|
||||
# Build a bundle like BenchEnvironment._agent_bundle but with a policy
|
||||
# declared so any_policies_apply is true and the full engine runs.
|
||||
executor: dict[str, object] = {
|
||||
"type": "omnigent",
|
||||
"model": env.model,
|
||||
"config": {"harness": env.harness},
|
||||
}
|
||||
config: dict[str, object] = {
|
||||
"spec_version": 1,
|
||||
"name": "bench-policy-agent",
|
||||
"prompt": "benchmark",
|
||||
"executor": executor,
|
||||
"guardrails": {
|
||||
"policies": {
|
||||
"allow_all": {
|
||||
"type": "function",
|
||||
"on": ["tool_call"],
|
||||
"function": "tests.runtime.policies.conftest._always_allow",
|
||||
"function": "dev.benchmarks.omnigent.journeys._bench_policy_allow",
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -728,16 +757,17 @@ async def _setup_policy_evaluate_session(env: BenchEnvironment) -> str:
|
||||
tar.addfile(info, io.BytesIO(payload))
|
||||
bundle = buf.getvalue()
|
||||
|
||||
# Register the agent + create a session in one call via the bundle upload
|
||||
# path (``POST /v1/sessions`` multipart). ``/v1/agents`` is GET-only.
|
||||
resp = await env.client.post(
|
||||
"/v1/agents",
|
||||
"/v1/sessions",
|
||||
data={"metadata": "{}"},
|
||||
files={"bundle": ("agent.tar.gz", bundle, "application/gzip")},
|
||||
)
|
||||
resp.raise_for_status()
|
||||
agent_id = resp.json()["id"]
|
||||
|
||||
session_resp = await env.client.post("/v1/sessions", json={"agent_id": agent_id})
|
||||
session_resp.raise_for_status()
|
||||
session_id = session_resp.json()["id"]
|
||||
body = resp.json()
|
||||
# Bundle upload returns ``session_id`` (not ``id``).
|
||||
session_id = body.get("session_id") or body["id"]
|
||||
|
||||
# Warm the spec + policy caches — the measured iteration is steady-state.
|
||||
for _ in range(2):
|
||||
@@ -759,6 +789,148 @@ async def _measure_policy_evaluate(env: BenchEnvironment, ctx: JourneyContext) -
|
||||
resp.raise_for_status()
|
||||
|
||||
|
||||
# ── CLI startup (omnigent polly against the local bench server) ──────────────
|
||||
|
||||
# Signal that the REPL is ready — the last spinner message before the prompt.
|
||||
# polly (omnigent run) emits this just before the agent REPL appears.
|
||||
_CLI_STARTUP_READY_SIGNAL = "Launching your agent"
|
||||
|
||||
# Per-attempt timeout. With the bench host daemon pre-running (needs_host=True),
|
||||
# polly reuses it; remaining work is session + runner connect ~5-20s on CI.
|
||||
_CLI_STARTUP_TIMEOUT_S = 60
|
||||
|
||||
# ~5s per attempt; cap so a large --iterations stays in budget.
|
||||
_CLI_STARTUP_MAX_ITERATIONS = 3
|
||||
|
||||
|
||||
async def _prepare_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> None:
|
||||
"""Stop stale daemons before each timed cli_startup iteration.
|
||||
|
||||
A leftover host daemon from the previous iteration causes the next
|
||||
``omnigent polly`` to fail with "runner tunnel rejection (HTTP 401)"
|
||||
or "host is on another replica". Runs outside the latency timer.
|
||||
"""
|
||||
del env
|
||||
omnigent_bin = os.environ.get("OMNIGENT_BIN") or shutil.which("omnigent")
|
||||
if omnigent_bin is None:
|
||||
return
|
||||
await asyncio.to_thread(
|
||||
subprocess.run,
|
||||
[omnigent_bin, "stop"],
|
||||
capture_output=True,
|
||||
timeout=15,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
async def _measure_cli_startup(env: BenchEnvironment, _ctx: JourneyContext) -> None:
|
||||
"""Time ``omnigent polly --server`` from invocation to REPL ready.
|
||||
|
||||
Spawns ``omnigent polly --server <local>`` via pexpect and times until
|
||||
``"Launching your agent…"`` appears — the last spinner message before the
|
||||
agent REPL. Using polly (the bundled openai-agents harness) avoids any
|
||||
external binary dependency while exercising the same startup path as
|
||||
``omnigent claude``: daemon start, session create, runner launch, and
|
||||
runner connect.
|
||||
|
||||
Requires ``pexpect``. No external LLM binary needed.
|
||||
|
||||
:param env: Benchmark environment — ``env.base_url`` is the local server URL.
|
||||
:param _ctx: Unused (no setup context).
|
||||
:raises RuntimeError: On timeout or process exit before the ready signal.
|
||||
"""
|
||||
try:
|
||||
import pexpect
|
||||
except ImportError as exc:
|
||||
raise RuntimeError(
|
||||
"pexpect is required for cli_startup. Install with: pip install pexpect"
|
||||
) from exc
|
||||
|
||||
omnigent_bin = os.environ.get("OMNIGENT_BIN") or shutil.which("omnigent")
|
||||
if omnigent_bin is None:
|
||||
raise RuntimeError("omnigent binary not found. Set OMNIGENT_BIN or add omnigent to PATH.")
|
||||
|
||||
child = pexpect.spawn(
|
||||
omnigent_bin,
|
||||
args=["polly", "--server", env.base_url],
|
||||
timeout=_CLI_STARTUP_TIMEOUT_S,
|
||||
encoding="utf-8",
|
||||
codec_errors="ignore",
|
||||
env=dict(os.environ),
|
||||
)
|
||||
try:
|
||||
idx = child.expect([pexpect.TIMEOUT, pexpect.EOF, _CLI_STARTUP_READY_SIGNAL])
|
||||
if idx == 0:
|
||||
raise RuntimeError(
|
||||
f"Timed out after {_CLI_STARTUP_TIMEOUT_S}s waiting for "
|
||||
f"{_CLI_STARTUP_READY_SIGNAL!r}"
|
||||
)
|
||||
if idx == 1:
|
||||
output = (child.before or "").strip()
|
||||
raise RuntimeError(
|
||||
f"Process exited before {_CLI_STARTUP_READY_SIGNAL!r}. "
|
||||
f"Last output: {output[-200:]!r}"
|
||||
)
|
||||
child.sendline("/exit")
|
||||
child.expect([pexpect.EOF, pexpect.TIMEOUT], timeout=10)
|
||||
finally:
|
||||
if child.isalive():
|
||||
child.terminate(force=True)
|
||||
|
||||
|
||||
# ── native hook spawn (no server involved) ───────────────────
|
||||
|
||||
# Claude Code blocks its TUI on command hooks, so one hook subprocess's whole
|
||||
# lifetime is user-visible latency: the MessageDisplay hook runs once per
|
||||
# streamed text chunk, and the same interpreter+import cost fronts every
|
||||
# statusline refresh and per-tool-call policy hook. Spawn the per-chunk hook
|
||||
# exactly as Claude Code does — isolated interpreter, module entrypoint, JSON
|
||||
# payload on stdin — and time the full process lifetime. The import-graph side
|
||||
# of this guarantee is pinned by tests/test_claude_native_message_display_hook.
|
||||
_HOOK_SPAWN_PAYLOAD = json.dumps(
|
||||
{
|
||||
"hook_event_name": "MessageDisplay",
|
||||
"message_id": "bench-message",
|
||||
"index": 0,
|
||||
"final": False,
|
||||
"delta": "benchmark chunk",
|
||||
}
|
||||
).encode()
|
||||
|
||||
|
||||
async def _setup_hook_spawn(env: BenchEnvironment) -> JourneyContext:
|
||||
"""A throwaway bridge dir for the hook's appended deltas file."""
|
||||
del env
|
||||
return tempfile.mkdtemp(prefix="omnigent-bench-hook-")
|
||||
|
||||
|
||||
async def _measure_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Spawn the MessageDisplay hook once, as Claude Code does, and wait."""
|
||||
del env
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
sys.executable,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_message_display_hook",
|
||||
"--bridge-dir",
|
||||
str(ctx),
|
||||
stdin=asyncio.subprocess.PIPE,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, stderr = await proc.communicate(_HOOK_SPAWN_PAYLOAD)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(
|
||||
f"hook exited {proc.returncode}: {stderr.decode('utf-8', 'replace')[:200]}"
|
||||
)
|
||||
|
||||
|
||||
async def _teardown_hook_spawn(env: BenchEnvironment, ctx: JourneyContext) -> None:
|
||||
"""Remove the throwaway bridge dir."""
|
||||
del env
|
||||
shutil.rmtree(str(ctx), ignore_errors=True)
|
||||
|
||||
|
||||
# ── registry ─────────────────────────────────────────────────
|
||||
|
||||
ALL_JOURNEYS: dict[str, Journey] = {
|
||||
@@ -904,9 +1076,33 @@ ALL_JOURNEYS: dict[str, Journey] = {
|
||||
max_iterations=_RUNNER_FS_MAX_ITERATIONS,
|
||||
description="GET .../environments/default/filesystem/{path} — runner file read proxy.",
|
||||
),
|
||||
Journey(
|
||||
name="native_hook_spawn",
|
||||
kind="latency",
|
||||
measure=_measure_hook_spawn,
|
||||
setup=_setup_hook_spawn,
|
||||
teardown=_teardown_hook_spawn,
|
||||
description="Spawn the per-chunk MessageDisplay hook exactly as Claude Code does.",
|
||||
),
|
||||
Journey(
|
||||
name="cli_startup",
|
||||
kind="latency",
|
||||
measure=_measure_cli_startup,
|
||||
prepare=_prepare_cli_startup,
|
||||
max_iterations=_CLI_STARTUP_MAX_ITERATIONS,
|
||||
skip_warmup=True,
|
||||
description=(
|
||||
"Spawn `omnigent polly --server` and time invocation → REPL ready "
|
||||
"(daemon + session + runner connect). No LLM call needed. "
|
||||
"Requires pexpect."
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
# Registry alias — kept for callers that enumerate opt-in journeys explicitly.
|
||||
OPT_IN_JOURNEYS: dict[str, Journey] = {}
|
||||
|
||||
|
||||
def resolve_journeys(names: list[str] | None) -> list[Journey]:
|
||||
"""Resolve requested journey *names* (or all when ``None``/empty).
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
"""Render ``run.py`` JSON reports as GitHub-flavoured markdown result matrices.
|
||||
|
||||
Where ``compare.py`` renders a baseline-vs-candidate regression table, this
|
||||
renders the absolute numbers of one or more standalone reports — the
|
||||
journey × metric matrix a CI job appends to ``$GITHUB_STEP_SUMMARY``. Given
|
||||
several reports (e.g. the nightly's sqlite / postgres / mysql legs) it also
|
||||
leads with a cross-report P50 matrix so the backends can be compared side by
|
||||
side.
|
||||
|
||||
Usage::
|
||||
|
||||
report_markdown.py REPORT.json [REPORT.json ...] [--title TEXT]
|
||||
|
||||
Prints markdown to stdout. Report labels come from each report's
|
||||
``config.backend``; when two reports share a backend the filename stem is
|
||||
appended to keep the columns distinguishable.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Keep table cells one-line and skimmable: a skip reason is an exception
|
||||
# rendering, which can run long and embed newlines that would break the row.
|
||||
_MAX_NOTE_LEN = 100
|
||||
|
||||
|
||||
def _fmt_ms(value: object) -> str:
|
||||
"""Format a millisecond metric, or ``—`` when it is absent."""
|
||||
return f"{value:.1f}" if isinstance(value, (int, float)) else "—"
|
||||
|
||||
|
||||
def _fmt_rps(value: object) -> str:
|
||||
"""Format a requests-per-second metric, or ``—`` when it is absent."""
|
||||
return f"{value:.0f}" if isinstance(value, (int, float)) else "—"
|
||||
|
||||
|
||||
def _one_line(text: str) -> str:
|
||||
"""Collapse *text* onto one bounded line so it can live in a table cell."""
|
||||
flattened = " ".join(str(text).split())
|
||||
if len(flattened) > _MAX_NOTE_LEN:
|
||||
return flattened[: _MAX_NOTE_LEN - 1] + "…"
|
||||
return flattened
|
||||
|
||||
|
||||
def _journey_note(block: dict) -> str:
|
||||
"""The Notes cell for one journey block: skip reason / failure marker."""
|
||||
if block.get("skipped"):
|
||||
return _one_line(f"⚠️ skipped — {block.get('error', 'unknown error')}")
|
||||
summary = block.get("summary") or {}
|
||||
if summary and not summary.get("runs_ok"):
|
||||
return "❌ every run failed"
|
||||
return ""
|
||||
|
||||
|
||||
def _runs_cell(block: dict) -> str:
|
||||
"""The Runs cell: ``ok/total``, or ``—`` for a skipped journey."""
|
||||
summary = block.get("summary") or {}
|
||||
total = summary.get("runs_total")
|
||||
if not isinstance(total, int):
|
||||
return "—"
|
||||
return f"{summary.get('runs_ok', 0)}/{total}"
|
||||
|
||||
|
||||
def _caption(report: dict) -> str:
|
||||
"""One italic line of run context under a section heading."""
|
||||
config = report.get("config") or {}
|
||||
parts: list[str] = []
|
||||
iterations = config.get("iterations")
|
||||
runs = config.get("runs")
|
||||
if iterations is not None and runs is not None:
|
||||
parts.append(f"{iterations} iterations × {runs} runs")
|
||||
warmup = config.get("warmup")
|
||||
if warmup is not None:
|
||||
parts.append(f"warmup {warmup}")
|
||||
harness = report.get("harness")
|
||||
if harness:
|
||||
parts.append(str(harness))
|
||||
sha = report.get("git_sha")
|
||||
if sha:
|
||||
parts.append(f"`{str(sha)[:8]}`")
|
||||
return f"_{' · '.join(parts)}_" if parts else ""
|
||||
|
||||
|
||||
def _report_section(label: str, report: dict) -> list[str]:
|
||||
"""Markdown lines for one report: heading, caption, journey × metric table."""
|
||||
lines = [f"### {label}", ""]
|
||||
caption = _caption(report)
|
||||
if caption:
|
||||
lines.extend([caption, ""])
|
||||
lines.extend(
|
||||
[
|
||||
"| Journey | Mean ms | P50 ms | P95 ms | P99 ms | Req/s | Runs | Notes |",
|
||||
"| --- | ---: | ---: | ---: | ---: | ---: | ---: | --- |",
|
||||
]
|
||||
)
|
||||
for name, block in (report.get("journeys") or {}).items():
|
||||
summary = block.get("summary") or {}
|
||||
lines.append(
|
||||
f"| {name} "
|
||||
f"| {_fmt_ms(summary.get('avg_mean_ms'))} "
|
||||
f"| {_fmt_ms(summary.get('avg_p50_ms'))} "
|
||||
f"| {_fmt_ms(summary.get('avg_p95_ms'))} "
|
||||
f"| {_fmt_ms(summary.get('avg_p99_ms'))} "
|
||||
f"| {_fmt_rps(summary.get('avg_rps'))} "
|
||||
f"| {_runs_cell(block)} "
|
||||
f"| {_journey_note(block)} |"
|
||||
)
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def _journey_order(labeled_reports: list[tuple[str, dict]]) -> list[str]:
|
||||
"""Union of journey names, keeping each report's insertion order."""
|
||||
ordered: list[str] = []
|
||||
for _, report in labeled_reports:
|
||||
for name in report.get("journeys") or {}:
|
||||
if name not in ordered:
|
||||
ordered.append(name)
|
||||
return ordered
|
||||
|
||||
|
||||
def _cross_matrix(labeled_reports: list[tuple[str, dict]]) -> list[str]:
|
||||
"""Journey × report P50 matrix so several reports compare side by side."""
|
||||
labels = [label for label, _ in labeled_reports]
|
||||
lines = [
|
||||
"### P50 across reports",
|
||||
"",
|
||||
"| Journey | " + " | ".join(f"{label} P50 ms" for label in labels) + " |",
|
||||
"| --- | " + " | ".join("---:" for _ in labels) + " |",
|
||||
]
|
||||
for name in _journey_order(labeled_reports):
|
||||
cells = []
|
||||
for _, report in labeled_reports:
|
||||
block = (report.get("journeys") or {}).get(name) or {}
|
||||
cells.append(_fmt_ms((block.get("summary") or {}).get("avg_p50_ms")))
|
||||
lines.append(f"| {name} | " + " | ".join(cells) + " |")
|
||||
lines.append("")
|
||||
return lines
|
||||
|
||||
|
||||
def build_markdown(labeled_reports: list[tuple[str, dict]], title: str | None = None) -> str:
|
||||
"""Render *labeled_reports* as one markdown document.
|
||||
|
||||
:param labeled_reports: ``(label, report)`` pairs, where *report* is a
|
||||
parsed ``run.py`` JSON report and *label* names it (e.g. its backend).
|
||||
:param title: Optional top-level heading, e.g. ``"Benchmark results"``.
|
||||
:returns: GitHub-flavoured markdown ending in a newline.
|
||||
"""
|
||||
lines: list[str] = []
|
||||
if title:
|
||||
lines.extend([f"## {title}", ""])
|
||||
if len(labeled_reports) > 1:
|
||||
lines.extend(_cross_matrix(labeled_reports))
|
||||
for label, report in labeled_reports:
|
||||
lines.extend(_report_section(label, report))
|
||||
return "\n".join(lines).rstrip("\n") + "\n"
|
||||
|
||||
|
||||
def _label_for(path: Path, report: dict, seen: set[str]) -> str:
|
||||
"""Label a report by backend, disambiguating duplicates with the filename."""
|
||||
backend = (report.get("config") or {}).get("backend")
|
||||
label = str(backend) if backend else path.stem
|
||||
if label in seen:
|
||||
label = f"{label} ({path.stem})"
|
||||
seen.add(label)
|
||||
return label
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
"""CLI entry point: render the given report files to stdout."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("reports", nargs="+", type=Path, help="run.py JSON report file(s).")
|
||||
parser.add_argument("--title", default=None, help="Optional top-level heading.")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
labeled: list[tuple[str, dict]] = []
|
||||
seen: set[str] = set()
|
||||
for path in args.reports:
|
||||
report = json.loads(path.read_text())
|
||||
labeled.append((_label_for(path, report, seen), report))
|
||||
sys.stdout.write(build_markdown(labeled, title=args.title))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -32,8 +32,7 @@ Runs from a **repo checkout** (it imports `dev.benchmarks` + `tests`), with the
|
||||
harness + bench deps:
|
||||
|
||||
```bash
|
||||
pip install -e '.[loadtest,dev,agents-sdk]'
|
||||
# or: uv sync --extra loadtest --extra dev --extra agents-sdk
|
||||
uv sync --extra loadtest --extra agents-sdk
|
||||
```
|
||||
|
||||
## Run
|
||||
|
||||
+2
-2
@@ -26,7 +26,7 @@ Writes a timestamped result set:
|
||||
summary.md human-readable latency write-up (this tool)
|
||||
|
||||
Runs from a repo checkout only (imports ``dev.benchmarks`` + ``tests``), with
|
||||
the ``[loadtest,dev,agents-sdk]`` extras. Knobs: ``--users`` (N hosts),
|
||||
the ``loadtest`` and ``agents-sdk`` extras. Knobs: ``--users`` (N hosts),
|
||||
``--spawn-rate``, ``--run-time``, ``--sessions-per-user``, ``--turns-per-session``,
|
||||
``--reply-words``, ``--out-dir``.
|
||||
"""
|
||||
@@ -363,7 +363,7 @@ def main() -> int:
|
||||
if importlib.util.find_spec(mod) is None:
|
||||
sys.exit(
|
||||
f"{pkg} not importable under {sys.executable} — install the extras: "
|
||||
"pip install -e '.[loadtest,dev,agents-sdk]' (run from a repo checkout)."
|
||||
"uv sync --extra loadtest --extra agents-sdk (run from a repo checkout)."
|
||||
)
|
||||
out_dir = _resolve_out_dir(args.out_dir)
|
||||
return asyncio.run(_boot_and_run(args, out_dir))
|
||||
|
||||
@@ -50,7 +50,7 @@ Run it from anywhere inside the checkout — it walks up to the repo root
|
||||
|---|---|---|
|
||||
| server | `uv run omnigent --log-to-stderr server --host 127.0.0.1 --port <p> --database-uri … --artifact-location …` | Waited on via `GET /health`. |
|
||||
| host | `uv run omnigent --log-to-stderr host --server http://127.0.0.1:<p>` | Started once the server is healthy. |
|
||||
| vite | `pnpm run dev -- --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
|
||||
| vite | `pnpm run dev --host <host> --port <p> --strictPort` (cwd `web/`) | `OMNIGENT_URL` points its proxy at the pod's server. |
|
||||
|
||||
Before Vite starts (and on a manual Vite restart), omnidev runs `pnpm install`
|
||||
in `web/` when needed — `node_modules/` is missing, or `package.json` /
|
||||
|
||||
@@ -143,6 +143,16 @@ mod tests {
|
||||
.find(|(k, _)| k == "OMNIGENT_DATABASE_URI")
|
||||
.map(|(_, v)| v.clone());
|
||||
assert_eq!(db, Some(pod.db_uri()));
|
||||
|
||||
let config_home = cmd
|
||||
.env
|
||||
.iter()
|
||||
.find(|(k, _)| k == "OMNIGENT_CONFIG_HOME")
|
||||
.map(|(_, v)| v.clone());
|
||||
assert_eq!(config_home, Some(pod.config_dir().display().to_string()));
|
||||
|
||||
assert!(cmd.env.iter().all(|(k, _)| k != "HOME"));
|
||||
assert!(cmd.env.iter().all(|(k, _)| !k.starts_with("XDG_")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -120,7 +120,7 @@ impl ProcSpec {
|
||||
}
|
||||
}
|
||||
|
||||
/// `pnpm run dev -- --host <host> --port <p> --strictPort`, from `web/`.
|
||||
/// `pnpm run dev --host <host> --port <p> --strictPort`, from `web/`.
|
||||
/// `OMNIGENT_URL` (in the pod env) points Vite's proxy at this pod's backend.
|
||||
pub fn vite(pod: &Pod) -> ProcSpec {
|
||||
if let Some(profile) = &pod.profile {
|
||||
@@ -128,10 +128,10 @@ impl ProcSpec {
|
||||
}
|
||||
ProcSpec {
|
||||
program: "pnpm".into(),
|
||||
// pnpm forwards script arguments directly; `--` would make Vite ignore the flags.
|
||||
args: vec![
|
||||
"run".into(),
|
||||
"dev".into(),
|
||||
"--".into(),
|
||||
"--host".into(),
|
||||
pod.vite_host.clone(),
|
||||
"--port".into(),
|
||||
@@ -151,7 +151,7 @@ mod tests {
|
||||
use crate::profile::{ProcessProfile, Profile};
|
||||
|
||||
#[test]
|
||||
fn vite_uses_configured_bind_host_but_backend_url_stays_loopback() {
|
||||
fn vite_forwards_configured_host_and_port_but_backend_url_stays_loopback() {
|
||||
let repo = tempdir();
|
||||
let pod_dir = tempdir();
|
||||
let pod = Pod::create(
|
||||
@@ -167,8 +167,18 @@ mod tests {
|
||||
.unwrap();
|
||||
|
||||
let vite = ProcSpec::vite(&pod);
|
||||
let host_flag = vite.args.iter().position(|arg| arg == "--host").unwrap();
|
||||
assert_eq!(vite.args[host_flag + 1], "0.0.0.0");
|
||||
assert_eq!(
|
||||
vite.args,
|
||||
[
|
||||
"run",
|
||||
"dev",
|
||||
"--host",
|
||||
"0.0.0.0",
|
||||
"--port",
|
||||
"19292",
|
||||
"--strictPort",
|
||||
]
|
||||
);
|
||||
assert_eq!(pod.server_url(), "http://127.0.0.1:19191");
|
||||
}
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
## 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.
|
||||
- Pre-commit gate (must pass): `uv sync --group dev && uv run --no-sync ruff check --fix && uv run --no-sync ruff format && uv run --no-sync pyrefly check && uv run --no-sync 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).
|
||||
@@ -80,7 +80,7 @@ def test_cancel_cascade_steps_true_on_200(monkeypatch):
|
||||
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 2: Run, verify FAIL** — `uv run --group test 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`).
|
||||
@@ -241,7 +241,7 @@ def test_handle_user_interaction_raises_on_500(monkeypatch):
|
||||
|
||||
- [ ] **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 3:** Run the full gate: `uv sync --group dev && uv run --no-sync ruff check --fix && uv run --no-sync ruff format && uv run --no-sync pyrefly check && uv run --no-sync pytest` (targeted antigravity suites + server).
|
||||
- [ ] **Step 4:** Commit (`refactor(antigravity-native): retire transcript forwarder + durable cursor (RPC reader supersedes)`).
|
||||
|
||||
---
|
||||
|
||||
@@ -293,11 +293,11 @@ This integration is a **separate package** (`omnigent-slack`) with heavy deps
|
||||
(slack_bolt, aiohttp) kept out of the core `omnigent` install. It resolves as an
|
||||
editable path dep of the root `omnigent` package via the `slack` extra (see
|
||||
`[tool.uv.sources]` in the root `pyproject.toml`), and shares the root's dev
|
||||
tooling (ruff, mypy, pytest) and config rather than carrying its own. Work on it
|
||||
tooling (Ruff, Pyrefly, pytest) and config rather than carrying its own. Work on it
|
||||
from the repo-root env:
|
||||
|
||||
```bash
|
||||
# From the repo root — add the slack extra to your existing extras:
|
||||
uv sync --extra slack # e.g. --extra all --extra dev --extra slack
|
||||
uv run omni integration slack
|
||||
# From the repo root — install the Slack capability and contributor tooling:
|
||||
uv sync --extra slack --group dev
|
||||
uv run --no-sync omni integration slack
|
||||
```
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "omnigent-slack"
|
||||
version = "0.10.0.dev0"
|
||||
version = "0.11.0.dev0"
|
||||
description = "Slack Socket Mode bot that drives Omnigent sessions."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
|
||||
@@ -14,7 +14,7 @@ _check-uv:
|
||||
uv run --no-sync pre-commit --version
|
||||
|
||||
_ensure-uv:
|
||||
uv sync --extra all --extra dev
|
||||
uv sync --extra all --group dev
|
||||
|
||||
# --- iOS Ruby dependencies ---
|
||||
|
||||
@@ -87,11 +87,11 @@ electron-build: _ensure-web _ensure-electron
|
||||
|
||||
[group('lint')]
|
||||
lint: _ensure-uv
|
||||
uv run pre-commit run
|
||||
uv run --no-sync pre-commit run
|
||||
|
||||
[group('lint')]
|
||||
lint-all: _ensure-uv
|
||||
uv run pre-commit run --all-files
|
||||
uv run --no-sync pre-commit run --all-files
|
||||
|
||||
[group('lint')]
|
||||
typecheck-python: _ensure-uv
|
||||
@@ -108,4 +108,4 @@ lint-ts:
|
||||
|
||||
[group('lint')]
|
||||
normalize-locks: _ensure-uv
|
||||
uv run scripts/normalize_uv_lock_registry.py uv.lock || true
|
||||
uv run --no-sync scripts/normalize_uv_lock_registry.py uv.lock || true
|
||||
|
||||
+204
-65
@@ -29,72 +29,211 @@ from omnigent._env_compat import mirror_legacy_env as _mirror_legacy_env # noqa
|
||||
|
||||
_mirror_legacy_env()
|
||||
|
||||
from omnigent.inner.datamodel import ( # noqa: E402 — must follow md5 patch
|
||||
AgentDef,
|
||||
Connection,
|
||||
Credentials,
|
||||
History,
|
||||
Memory,
|
||||
MemoryConfig,
|
||||
Message,
|
||||
ParamDef,
|
||||
SessionState,
|
||||
)
|
||||
from omnigent.inner.executor import ( # noqa: E402 — must follow md5 patch
|
||||
Executor,
|
||||
ExecutorConfig,
|
||||
ExecutorError,
|
||||
ExecutorEvent,
|
||||
TextChunk,
|
||||
ToolCallComplete,
|
||||
ToolCallRequest,
|
||||
TurnCancelled,
|
||||
TurnComplete,
|
||||
)
|
||||
from omnigent.inner.policies import ( # noqa: E402 — must follow md5 patch
|
||||
FunctionPolicy,
|
||||
Policy,
|
||||
PolicyAction,
|
||||
PolicyResult,
|
||||
PromptPolicy,
|
||||
)
|
||||
from omnigent.inner.tools import ( # noqa: E402 — must follow md5 patch
|
||||
AgentTool,
|
||||
CancellableFunctionTool,
|
||||
FunctionTool,
|
||||
HandoffTool,
|
||||
InheritedTool,
|
||||
MCPTool,
|
||||
SkillTool,
|
||||
Tool,
|
||||
)
|
||||
# The public names below re-export lazily (PEP 562). This package init is on
|
||||
# the hot path of every ``python -m omnigent.<hook>`` subprocess Claude Code
|
||||
# spawns — once per streamed text chunk (the TUI blocks on the MessageDisplay
|
||||
# hook), per statusline refresh, and per tool call — and eagerly importing the
|
||||
# datamodel/executor graph here cost those spawns ~250 ms each. Names resolve
|
||||
# on first attribute access and are cached in module globals; the import-graph
|
||||
# guards live in tests/test_claude_native_message_display_hook.py and the
|
||||
# wall-clock trend in the ``native_hook_spawn`` benchmark journey.
|
||||
import importlib # noqa: E402
|
||||
from typing import TYPE_CHECKING, Any # noqa: E402
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor as ClaudeSDKExecutor
|
||||
from omnigent.inner.codex_executor import CodexExecutor as CodexExecutor
|
||||
from omnigent.inner.databricks_executor import DatabricksExecutor as DatabricksExecutor
|
||||
from omnigent.inner.datamodel import (
|
||||
AgentDef as AgentDef,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
Connection as Connection,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
Credentials as Credentials,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
History as History,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
Memory as Memory,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
MemoryConfig as MemoryConfig,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
Message as Message,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
ParamDef as ParamDef,
|
||||
)
|
||||
from omnigent.inner.datamodel import (
|
||||
SessionState as SessionState,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
Executor as Executor,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ExecutorConfig as ExecutorConfig,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ExecutorError as ExecutorError,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ExecutorEvent as ExecutorEvent,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
TextChunk as TextChunk,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ToolCallComplete as ToolCallComplete,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
ToolCallRequest as ToolCallRequest,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
TurnCancelled as TurnCancelled,
|
||||
)
|
||||
from omnigent.inner.executor import (
|
||||
TurnComplete as TurnComplete,
|
||||
)
|
||||
from omnigent.inner.loader import load_agent_def as load_agent_def
|
||||
from omnigent.inner.open_responses_sdk import OpenResponsesExecutor as OpenResponsesExecutor
|
||||
from omnigent.inner.openai_agents_sdk_executor import (
|
||||
OpenAIAgentsSDKExecutor as OpenAIAgentsSDKExecutor,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
FunctionPolicy as FunctionPolicy,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
Policy as Policy,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
PolicyAction as PolicyAction,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
PolicyResult as PolicyResult,
|
||||
)
|
||||
from omnigent.inner.policies import (
|
||||
PromptPolicy as PromptPolicy,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
AgentTool as AgentTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
CancellableFunctionTool as CancellableFunctionTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
FunctionTool as FunctionTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
HandoffTool as HandoffTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
InheritedTool as InheritedTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
MCPTool as MCPTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
SkillTool as SkillTool,
|
||||
)
|
||||
from omnigent.inner.tools import (
|
||||
Tool as Tool,
|
||||
)
|
||||
from omnigent.inner.tracing import (
|
||||
disable_tracing as disable_tracing,
|
||||
)
|
||||
from omnigent.inner.tracing import (
|
||||
enable_tracing as enable_tracing,
|
||||
)
|
||||
from omnigent.inner.tracing import (
|
||||
is_tracing_enabled as is_tracing_enabled,
|
||||
)
|
||||
|
||||
# Public name → defining module for the always-present re-exports.
|
||||
_LAZY_EXPORTS = {
|
||||
"AgentDef": "omnigent.inner.datamodel",
|
||||
"Connection": "omnigent.inner.datamodel",
|
||||
"Credentials": "omnigent.inner.datamodel",
|
||||
"History": "omnigent.inner.datamodel",
|
||||
"Memory": "omnigent.inner.datamodel",
|
||||
"MemoryConfig": "omnigent.inner.datamodel",
|
||||
"Message": "omnigent.inner.datamodel",
|
||||
"ParamDef": "omnigent.inner.datamodel",
|
||||
"SessionState": "omnigent.inner.datamodel",
|
||||
"Executor": "omnigent.inner.executor",
|
||||
"ExecutorConfig": "omnigent.inner.executor",
|
||||
"ExecutorError": "omnigent.inner.executor",
|
||||
"ExecutorEvent": "omnigent.inner.executor",
|
||||
"TextChunk": "omnigent.inner.executor",
|
||||
"ToolCallComplete": "omnigent.inner.executor",
|
||||
"ToolCallRequest": "omnigent.inner.executor",
|
||||
"TurnCancelled": "omnigent.inner.executor",
|
||||
"TurnComplete": "omnigent.inner.executor",
|
||||
"FunctionPolicy": "omnigent.inner.policies",
|
||||
"Policy": "omnigent.inner.policies",
|
||||
"PolicyAction": "omnigent.inner.policies",
|
||||
"PolicyResult": "omnigent.inner.policies",
|
||||
"PromptPolicy": "omnigent.inner.policies",
|
||||
"AgentTool": "omnigent.inner.tools",
|
||||
"CancellableFunctionTool": "omnigent.inner.tools",
|
||||
"FunctionTool": "omnigent.inner.tools",
|
||||
"HandoffTool": "omnigent.inner.tools",
|
||||
"InheritedTool": "omnigent.inner.tools",
|
||||
"MCPTool": "omnigent.inner.tools",
|
||||
"SkillTool": "omnigent.inner.tools",
|
||||
"Tool": "omnigent.inner.tools",
|
||||
"load_agent_def": "omnigent.inner.loader",
|
||||
"disable_tracing": "omnigent.inner.tracing",
|
||||
"enable_tracing": "omnigent.inner.tracing",
|
||||
"is_tracing_enabled": "omnigent.inner.tracing",
|
||||
}
|
||||
|
||||
# Optional executors resolve to ``None`` when their extra's dependencies are
|
||||
# absent, matching the former eager try/except imports. Databricks also
|
||||
# tolerates ``OSError``: its SDK can raise one probing credentials at import.
|
||||
_OPTIONAL_EXPORTS = {
|
||||
"DatabricksExecutor": ("omnigent.inner.databricks_executor", (OSError, ImportError)),
|
||||
"ClaudeSDKExecutor": ("omnigent.inner.claude_sdk_executor", (ImportError,)),
|
||||
"OpenResponsesExecutor": ("omnigent.inner.open_responses_sdk", (ImportError,)),
|
||||
"OpenAIAgentsSDKExecutor": ("omnigent.inner.openai_agents_sdk_executor", (ImportError,)),
|
||||
"CodexExecutor": ("omnigent.inner.codex_executor", (ImportError,)),
|
||||
}
|
||||
|
||||
|
||||
def __getattr__(name: str) -> Any:
|
||||
"""Resolve a lazy re-export (or submodule) on first attribute access."""
|
||||
target = _LAZY_EXPORTS.get(name)
|
||||
if target is not None:
|
||||
value = getattr(importlib.import_module(target), name)
|
||||
globals()[name] = value
|
||||
return value
|
||||
optional = _OPTIONAL_EXPORTS.get(name)
|
||||
if optional is not None:
|
||||
target, absent_exceptions = optional
|
||||
try:
|
||||
value = getattr(importlib.import_module(target), name)
|
||||
except absent_exceptions:
|
||||
value = None
|
||||
globals()[name] = value
|
||||
return value
|
||||
# The eager imports used to bind ``inner`` (and other submodules touched
|
||||
# by them) as package attributes; keep ``omnigent.<submodule>`` access
|
||||
# working for consumers that only ran ``import omnigent``.
|
||||
try:
|
||||
return importlib.import_module(f"{__name__}.{name}")
|
||||
except ModuleNotFoundError as exc:
|
||||
if exc.name != f"{__name__}.{name}":
|
||||
raise
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from None
|
||||
|
||||
|
||||
def __dir__() -> list[str]:
|
||||
"""Include the lazy re-exports in ``dir(omnigent)``."""
|
||||
return sorted(set(globals()) | set(__all__))
|
||||
|
||||
try:
|
||||
from omnigent.inner.databricks_executor import DatabricksExecutor
|
||||
except (OSError, ImportError):
|
||||
DatabricksExecutor = None # type: ignore[misc,assignment]
|
||||
try:
|
||||
from omnigent.inner.claude_sdk_executor import ClaudeSDKExecutor
|
||||
except ImportError:
|
||||
ClaudeSDKExecutor = None # type: ignore[misc,assignment]
|
||||
try:
|
||||
from omnigent.inner.open_responses_sdk import OpenResponsesExecutor
|
||||
except ImportError:
|
||||
OpenResponsesExecutor = None # type: ignore[misc,assignment]
|
||||
try:
|
||||
from omnigent.inner.openai_agents_sdk_executor import OpenAIAgentsSDKExecutor
|
||||
except ImportError:
|
||||
OpenAIAgentsSDKExecutor = None # type: ignore[misc,assignment]
|
||||
try:
|
||||
from omnigent.inner.codex_executor import CodexExecutor
|
||||
except ImportError:
|
||||
CodexExecutor = None # type: ignore[misc,assignment]
|
||||
from omnigent.inner.loader import load_agent_def # noqa: E402 — must follow md5 patch
|
||||
from omnigent.inner.tracing import ( # noqa: E402 — must follow md5 patch
|
||||
disable_tracing,
|
||||
enable_tracing,
|
||||
is_tracing_enabled,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"AgentDef",
|
||||
|
||||
@@ -24,6 +24,14 @@ spawn-env builder. Rows own their auth and model selection (``OWN_AUTH``): no
|
||||
Omnigent credential or model override is wired, so a ``/model`` pick is
|
||||
rejected up front rather than silently dropped.
|
||||
|
||||
One consequence worth knowing before adding a row: the generic ACP spawn env is
|
||||
deny-by-default and a row has no ``env_passthrough`` of its own (only a
|
||||
user-configured ``acp:<slug>`` agent can declare one), so a row's CLI reaches the
|
||||
agent with the base environment only. A vendor that configures or authenticates
|
||||
*solely* from an environment variable therefore needs a user-configured agent
|
||||
rather than a row here; a vendor that reads stored credentials from disk (Devin,
|
||||
Grok's OAuth login) works as a row.
|
||||
|
||||
This module stays import-light (stdlib + :mod:`omnigent.harness_install_spec`)
|
||||
so the registry, onboarding, and runner layers can all read it without cycles.
|
||||
"""
|
||||
@@ -73,6 +81,24 @@ class AcpCliHarness:
|
||||
# Keyed by canonical harness id. Keep keys sorted; each row's registrations
|
||||
# derive from here (see the module docstring for the full list).
|
||||
ACP_CLI_HARNESSES: dict[str, AcpCliHarness] = {
|
||||
# Devin (Cognition's ``devin`` CLI) drives ``devin acp`` — its ACP stdio
|
||||
# server. Ships via a curl installer (not npm) and authenticates through its
|
||||
# own ``devin auth login``, which writes a credential file it reads back at
|
||||
# spawn; Omnigent stores nothing. The row runs Devin's account-default model:
|
||||
# a row carries no per-user model, and ``DEVIN_MODEL`` cannot reach the agent
|
||||
# (see the env note above), so pinning a model needs a user-configured
|
||||
# ``acp:<slug>`` agent whose command passes ``--model``.
|
||||
"devin": AcpCliHarness(
|
||||
install=HarnessInstallSpec(
|
||||
"Devin",
|
||||
"devin",
|
||||
None,
|
||||
login_args=("auth", "login"),
|
||||
install_hint="curl -fsSL https://cli.devin.ai/install.sh | bash",
|
||||
auth_hint="run `devin auth login` (Omnigent stores no Devin credential)",
|
||||
),
|
||||
args=("acp",),
|
||||
),
|
||||
# Grok Build (xAI's ``grok`` CLI) drives ``grok agent stdio``. Ships via a
|
||||
# curl installer (not npm) and authenticates through its own ``grok login``
|
||||
# (xAI OAuth, device-code capable) or ``XAI_API_KEY``; Omnigent stores no
|
||||
|
||||
@@ -75,7 +75,6 @@ from tempfile import TemporaryDirectory
|
||||
import click
|
||||
import httpx
|
||||
import yaml
|
||||
from omnigent_client._http import is_loopback_url
|
||||
|
||||
from omnigent._native_resume_hint import echo_native_resume_hint
|
||||
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
|
||||
@@ -131,6 +130,7 @@ from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.host.daemon_launch import (
|
||||
error_text,
|
||||
launch_or_reuse_daemon_runner,
|
||||
open_daemon_client,
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
@@ -479,7 +479,11 @@ def _run_with_remote_server(
|
||||
from omnigent.cli import _ensure_host_daemon
|
||||
from omnigent.host.identity import load_or_create_host_identity
|
||||
|
||||
headers = _remote_headers(server_url=base_url)
|
||||
# This machine's host id keys the WebSocket attach handshake (and its
|
||||
# reconnects) to the replica holding the runner's tunnel; the CLI can set WS
|
||||
# headers, so it rides the header (emitted only on a host-sharded deployment).
|
||||
host_id = load_or_create_host_identity().host_id
|
||||
headers = _remote_headers(server_url=base_url, host_id=host_id)
|
||||
try:
|
||||
resolved_session_id = _resolve_session_id_for_resume(
|
||||
base_url=base_url,
|
||||
@@ -499,7 +503,6 @@ def _run_with_remote_server(
|
||||
with runner_startup_progress(initial_message="Preparing Antigravity...") as progress:
|
||||
progress.update("Connecting to local daemon...")
|
||||
_ensure_host_daemon(base_url)
|
||||
host_id = load_or_create_host_identity().host_id
|
||||
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
|
||||
prepared = await _prepare_antigravity_terminal_via_daemon(
|
||||
base_url=base_url,
|
||||
@@ -529,7 +532,7 @@ def _run_with_remote_server(
|
||||
|
||||
:returns: None.
|
||||
"""
|
||||
new_headers = _remote_headers(server_url=base_url)
|
||||
new_headers = _remote_headers(server_url=base_url, host_id=host_id)
|
||||
headers.clear()
|
||||
headers.update(new_headers)
|
||||
|
||||
@@ -590,12 +593,9 @@ async def _prepare_antigravity_terminal(
|
||||
:raises click.ClickException: If any server operation fails.
|
||||
"""
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
) as client:
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(base_url, headers=headers, timeout=timeout) as client:
|
||||
bridge_id: str
|
||||
conversation_id: str
|
||||
resume = False
|
||||
@@ -787,12 +787,7 @@ async def _prepare_antigravity_terminal_via_daemon(
|
||||
:raises click.ClickException: If setup fails.
|
||||
"""
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
) as client:
|
||||
async with open_daemon_client(base_url, headers, host_id, timeout=timeout) as client:
|
||||
bridge_id: str
|
||||
conversation_id: str
|
||||
resume = False
|
||||
@@ -1622,10 +1617,11 @@ async def _close_antigravity_terminal(
|
||||
:param terminal_id: Terminal resource id.
|
||||
:returns: None.
|
||||
"""
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
async with open_server_client(
|
||||
base_url,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(10.0),
|
||||
) as client:
|
||||
|
||||
@@ -3269,8 +3269,10 @@ async def run_reader_with_bridge(
|
||||
# would keep targeting the rotated-away session).
|
||||
current = {"session_id": session_id}
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(
|
||||
base_url,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
timeout=httpx.Timeout(_READER_CLIENT_TIMEOUT_SECONDS),
|
||||
|
||||
+142
-33
@@ -603,9 +603,51 @@ def _is_url(target: str) -> bool:
|
||||
# Server URL client helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Client-side ``session_id → host_id`` tracking, for routing a session's
|
||||
# tunnel-bound traffic to the right server replica when the server runs
|
||||
# multiple.
|
||||
#
|
||||
# A host's control tunnel and its runners' tunnels register on a single
|
||||
# replica, so the turn / resource / stream calls for a session running on that
|
||||
# host must name the host or they can reach a different replica than the runner
|
||||
# tunnel they need. The host_id is the routing key; it's populated when the CLI
|
||||
# learns a session's host (session GETs, daemon launch) and read once when a
|
||||
# client for that session is built — callers pass the session_id they already
|
||||
# have to ``_server_auth`` rather than the auth re-deriving it per request. This
|
||||
# is the Python peer of the web UI's ``sessionHost.ts``. (The host_id is
|
||||
# translated into the routing header inside ``cli_auth.databricks_request_headers``;
|
||||
# OSS only ever threads a host_id.)
|
||||
_session_hosts: dict[str, str] = {}
|
||||
|
||||
|
||||
def set_session_host(session_id: str, host_id: str | None) -> None:
|
||||
"""Record (or clear) the host a session is bound to.
|
||||
|
||||
A ``None``/empty host clears any stale mapping so a session that loses its
|
||||
host binding stops routing to the old replica.
|
||||
|
||||
:param session_id: Session id, e.g. ``"conv_abc123"``.
|
||||
:param host_id: The bound host id, e.g. ``"host_abc123"``, or ``None``.
|
||||
"""
|
||||
if host_id:
|
||||
_session_hosts[session_id] = host_id
|
||||
else:
|
||||
_session_hosts.pop(session_id, None)
|
||||
|
||||
|
||||
def get_session_host(session_id: str) -> str | None:
|
||||
"""Return a session's bound host id, or ``None`` when unknown.
|
||||
|
||||
:param session_id: Session id, e.g. ``"conv_abc123"``.
|
||||
:returns: The host id, or ``None`` (session not seen yet, or hostless).
|
||||
"""
|
||||
return _session_hosts.get(session_id)
|
||||
|
||||
|
||||
def _remote_headers(
|
||||
server_url: str | None = None,
|
||||
*,
|
||||
host_id: str | None,
|
||||
) -> dict[str, str]:
|
||||
"""
|
||||
Build headers for remote AP-server requests.
|
||||
@@ -626,6 +668,11 @@ def _remote_headers(
|
||||
|
||||
:param server_url: Optional remote server URL for looking up
|
||||
stored OIDC tokens, e.g. ``"http://localhost:6767"``.
|
||||
:param host_id: The host a request is scoped to, or ``None`` when the
|
||||
caller has no host to name (a host-less read, or a runner-internal
|
||||
request that keys off the runner-env host_id). Required-keyword with
|
||||
no default so every call site consciously decides — pass the request
|
||||
path's host when it has one rather than silently defaulting to unkeyed.
|
||||
:returns: Headers to pass to httpx / OmnigentClient.
|
||||
"""
|
||||
# Resolve the bearer in the documented precedence order (one credential
|
||||
@@ -654,11 +701,14 @@ def _remote_headers(
|
||||
headers["Authorization"] = f"Bearer {creds.token}"
|
||||
# Workspace routing: when a ?o= selector was recorded at login, name the
|
||||
# workspace or the request routes to the account. Merged onto the result
|
||||
# because these ad-hoc requests carry no httpx Auth.
|
||||
# because these ad-hoc requests carry no httpx Auth. ``host_id`` pins a
|
||||
# host-scoped request to the server replica holding that host's tunnel
|
||||
# (translated to the routing header inside the builder); callers derive it
|
||||
# from the request path.
|
||||
if server_url:
|
||||
from omnigent.cli_auth import databricks_request_headers
|
||||
|
||||
headers.update(databricks_request_headers(server_url))
|
||||
headers.update(databricks_request_headers(server_url, host_id=host_id))
|
||||
return headers
|
||||
|
||||
|
||||
@@ -725,12 +775,18 @@ class _DatabricksTokenAuth(httpx.Auth):
|
||||
def __init__(
|
||||
self,
|
||||
server_url: str | None = None,
|
||||
*,
|
||||
session_id: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
:param server_url: Remote server URL for looking up stored
|
||||
OIDC tokens, e.g. ``"http://localhost:6767"``.
|
||||
:param session_id: The single session this client drives; its
|
||||
requests are pinned to that session's host replica. ``None``
|
||||
for a hostless / local client.
|
||||
"""
|
||||
self._server_url = server_url
|
||||
self._session_id = session_id
|
||||
raw = os.environ.get(_REMOTE_AUTH_TOKEN_ENV)
|
||||
self._static_token = raw.strip() if raw else None
|
||||
# Lazily-resolved, then reused, SDK auth (one Config → one token
|
||||
@@ -740,6 +796,19 @@ class _DatabricksTokenAuth(httpx.Auth):
|
||||
self._sdk_auth: _DatabricksBearerAuth | None = None
|
||||
self._sdk_auth_resolved = False
|
||||
|
||||
def pin_session(self, session_id: str | None) -> None:
|
||||
"""Repoint this auth at a different session's host.
|
||||
|
||||
``auth_flow`` reads ``get_session_host(self._session_id)`` per request,
|
||||
so changing the pinned session id changes which host replica the slice
|
||||
key routes to — without rebuilding the client. Used when a client
|
||||
outlives the session it was built for (e.g. a ``--fork`` in the REPL
|
||||
resumes under a new conversation id on a new host).
|
||||
|
||||
:param session_id: The session id to pin to, or ``None`` to unpin.
|
||||
"""
|
||||
self._session_id = session_id
|
||||
|
||||
def _sdk_token(self) -> str | None:
|
||||
"""
|
||||
Return a bearer token from the reused SDK auth, or ``None``.
|
||||
@@ -794,27 +863,34 @@ class _DatabricksTokenAuth(httpx.Auth):
|
||||
:yields: The request with auth header set.
|
||||
"""
|
||||
# Workspace routing (empty when none recorded); independent of the
|
||||
# credential branch below.
|
||||
# credential branch below. On a host-sharded deployment, also pin the
|
||||
# turn/resource/stream traffic for this client's session to the replica
|
||||
# holding its runner tunnel — the slice key is the session's host_id,
|
||||
# from the session→host map. An unsharded server has no sharding layer,
|
||||
# so no key.
|
||||
if self._server_url:
|
||||
from omnigent.cli_auth import databricks_request_headers
|
||||
|
||||
request.headers.update(databricks_request_headers(self._server_url))
|
||||
session_host = get_session_host(self._session_id) if self._session_id else None
|
||||
request.headers.update(
|
||||
databricks_request_headers(self._server_url, host_id=session_host)
|
||||
)
|
||||
if self._static_token:
|
||||
request.headers["Authorization"] = f"Bearer {self._static_token}"
|
||||
yield request
|
||||
return
|
||||
# Check stored OIDC token from `omnigent login`.
|
||||
if self._server_url:
|
||||
from omnigent.cli_auth import load_token
|
||||
else:
|
||||
# Check stored OIDC token from `omnigent login`, then fall back to
|
||||
# the reused Databricks SDK auth.
|
||||
oidc_token = None
|
||||
if self._server_url:
|
||||
from omnigent.cli_auth import load_token
|
||||
|
||||
oidc_token = load_token(self._server_url)
|
||||
oidc_token = load_token(self._server_url)
|
||||
if oidc_token:
|
||||
request.headers["Authorization"] = f"Bearer {oidc_token}"
|
||||
yield request
|
||||
return
|
||||
token = self._sdk_token()
|
||||
if token:
|
||||
request.headers["Authorization"] = f"Bearer {token}"
|
||||
else:
|
||||
token = self._sdk_token()
|
||||
if token:
|
||||
request.headers["Authorization"] = f"Bearer {token}"
|
||||
yield request
|
||||
|
||||
|
||||
@@ -842,6 +918,8 @@ def _server_headers(
|
||||
|
||||
def _server_auth(
|
||||
server_url: str | None = None,
|
||||
*,
|
||||
session_id: str | None,
|
||||
) -> httpx.Auth | None:
|
||||
"""
|
||||
Build an httpx Auth for a remote Omnigent server client.
|
||||
@@ -854,21 +932,29 @@ def _server_auth(
|
||||
|
||||
:param server_url: Optional remote server URL for looking up
|
||||
stored OIDC tokens.
|
||||
:param session_id: The single session this client drives, e.g.
|
||||
``"conv_abc123"``. When set, the auth pins every request to the
|
||||
replica holding that session's runner tunnel (its host, looked up
|
||||
in the session→host map). Required-keyword with no default so every
|
||||
caller consciously decides: pass the session when known so its
|
||||
traffic co-locates, or ``None`` before a session exists / for a
|
||||
host-less client (the slice key then falls back to the runner-env
|
||||
or CLI-own-host id inside ``databricks_request_headers``).
|
||||
:returns: Auth instance, or ``None``.
|
||||
"""
|
||||
raw = os.environ.get(_REMOTE_AUTH_TOKEN_ENV)
|
||||
if raw and raw.strip():
|
||||
return _DatabricksTokenAuth(server_url=server_url)
|
||||
return _DatabricksTokenAuth(server_url=server_url, session_id=session_id)
|
||||
# Check stored `omnigent login` records: a session JWT or a
|
||||
# Databricks Apps pointer record.
|
||||
if server_url:
|
||||
from omnigent.cli_auth import load_databricks_workspace_host, load_token
|
||||
|
||||
if load_token(server_url) or load_databricks_workspace_host(server_url):
|
||||
return _DatabricksTokenAuth(server_url=server_url)
|
||||
return _DatabricksTokenAuth(server_url=server_url, session_id=session_id)
|
||||
creds = _read_databrickscfg(None)
|
||||
if creds is not None and creds.token:
|
||||
return _DatabricksTokenAuth(server_url=server_url)
|
||||
return _DatabricksTokenAuth(server_url=server_url, session_id=session_id)
|
||||
return None
|
||||
|
||||
|
||||
@@ -1146,7 +1232,7 @@ def _wrapper_label_for_conversation(
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/v1/sessions/{conversation_id}",
|
||||
headers=_remote_headers(server_url=base_url),
|
||||
headers=_remote_headers(server_url=base_url, host_id=None),
|
||||
timeout=10.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
@@ -1235,7 +1321,7 @@ def _attach_session_info(
|
||||
try:
|
||||
resp = httpx.get(
|
||||
f"{base_url}/v1/sessions/{conversation_id}",
|
||||
headers=_remote_headers(server_url=base_url),
|
||||
headers=_remote_headers(server_url=base_url, host_id=None),
|
||||
timeout=10.0,
|
||||
)
|
||||
except httpx.HTTPError as exc:
|
||||
@@ -1249,6 +1335,15 @@ def _attach_session_info(
|
||||
return empty
|
||||
if not isinstance(body, dict):
|
||||
return empty
|
||||
# Record the session's host so host-scoped requests (turn dispatch,
|
||||
# resource, stream) can reach the replica holding that host's runner tunnel.
|
||||
# Always write — clearing a stale mapping when the server now reports no
|
||||
# host (e.g. the session's runner was torn down) is as important as setting
|
||||
# one, so later requests for a hostless session don't keep a dead slice key.
|
||||
session_host = body.get("host_id")
|
||||
set_session_host(
|
||||
conversation_id, session_host if isinstance(session_host, str) and session_host else None
|
||||
)
|
||||
runner_id = body.get("runner_id")
|
||||
snapshot_online = body.get("runner_online")
|
||||
if not isinstance(runner_id, str) or not runner_id:
|
||||
@@ -1286,7 +1381,7 @@ def _pick_agent(base_url: str, *, quiet: bool = False) -> str:
|
||||
"""
|
||||
resp = httpx.get(
|
||||
f"{base_url}/v1/sessions",
|
||||
headers=_remote_headers(server_url=base_url),
|
||||
headers=_remote_headers(server_url=base_url, host_id=None),
|
||||
params={"limit": 100},
|
||||
timeout=10.0,
|
||||
)
|
||||
@@ -1494,6 +1589,7 @@ async def _prepare_chat_session_via_daemon(
|
||||
)
|
||||
from omnigent.host.daemon_launch import (
|
||||
launch_or_reuse_daemon_runner,
|
||||
open_daemon_client,
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
@@ -1526,14 +1622,10 @@ async def _prepare_chat_session_via_daemon(
|
||||
) from exc
|
||||
|
||||
# A separate raw httpx client for the host-runner protocol (the daemon
|
||||
# launch helpers operate on httpx, not the SDK).
|
||||
# launch helpers operate on httpx, not the SDK), pinned to the host's replica.
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
async with open_daemon_client(
|
||||
base_url, headers, host_id, auth=auth, timeout=timeout
|
||||
) as client:
|
||||
if progress is not None:
|
||||
progress.update(STARTUP_PHASE_CONNECTING)
|
||||
@@ -1542,6 +1634,9 @@ async def _prepare_chat_session_via_daemon(
|
||||
)
|
||||
if progress is not None:
|
||||
progress.update(STARTUP_PHASE_LAUNCHING_AGENT)
|
||||
# Record the session's host so its turn/resource/stream traffic reaches
|
||||
# the replica holding the host's runner tunnel.
|
||||
set_session_host(session_id, host_id)
|
||||
runner_id = await launch_or_reuse_daemon_runner(
|
||||
client,
|
||||
host_id=host_id,
|
||||
@@ -1651,8 +1746,8 @@ def _chat_via_daemon(
|
||||
# the spinner before printing its interactive prompt.
|
||||
_await_accounts_first_run_setup(base_url, progress=progress)
|
||||
|
||||
headers = _remote_headers(server_url=base_url)
|
||||
auth = _server_auth(server_url=base_url)
|
||||
headers = _remote_headers(server_url=base_url, host_id=None)
|
||||
auth = _server_auth(server_url=base_url, session_id=None)
|
||||
host_id = load_or_create_host_identity().host_id
|
||||
workspace = str(Path.cwd().resolve())
|
||||
|
||||
@@ -2101,7 +2196,7 @@ def _run_headless_prompt(
|
||||
async with OmnigentClient(
|
||||
base_url=base_url,
|
||||
headers=_server_headers(runner_id=runner_id),
|
||||
auth=_server_auth(server_url=base_url),
|
||||
auth=_server_auth(server_url=base_url, session_id=None),
|
||||
) as client:
|
||||
# Both a local bundle and a remote registered agent go through
|
||||
# the sessions API; _query_sessions_once picks the create route
|
||||
@@ -3862,10 +3957,13 @@ def _run_repl(
|
||||
if attach_harness is not None:
|
||||
launch_harness = attach_harness
|
||||
|
||||
# Named so a --fork below can repoint it: the auth pins the slice key
|
||||
# to whichever session it names, and a fork lands under a new id.
|
||||
server_auth = _server_auth(server_url=base_url, session_id=resume_conversation_id)
|
||||
async with OmnigentClient(
|
||||
base_url=base_url,
|
||||
headers=_server_headers(runner_id=runner_id),
|
||||
auth=_server_auth(server_url=base_url),
|
||||
auth=server_auth,
|
||||
) as client:
|
||||
# When --fork is set, call the fork endpoint before
|
||||
# entering the REPL so the user lands in the fork.
|
||||
@@ -3876,6 +3974,17 @@ def _run_repl(
|
||||
except Exception as exc:
|
||||
raise click.ClickException(f"Fork failed: {exc}") from exc
|
||||
effective_resume_id = fork_result["id"]
|
||||
# The fork is a fresh session on (possibly) a different host.
|
||||
# Record its host and repoint the auth from the source session
|
||||
# to the fork, so this client's requests route to the fork's
|
||||
# replica instead of the source's for the rest of the REPL.
|
||||
fork_host = fork_result.get("host_id")
|
||||
set_session_host(
|
||||
effective_resume_id,
|
||||
fork_host if isinstance(fork_host, str) and fork_host else None,
|
||||
)
|
||||
if isinstance(server_auth, _DatabricksTokenAuth):
|
||||
server_auth.pin_session(effective_resume_id)
|
||||
click.echo(
|
||||
f"Conversation forked. To return to the previous "
|
||||
f"conversation, run --resume {fork_session_id}",
|
||||
@@ -3956,7 +4065,7 @@ def _run_one_shot(
|
||||
async with OmnigentClient(
|
||||
base_url=base_url,
|
||||
headers=_server_headers(runner_id=runner_id),
|
||||
auth=_server_auth(server_url=base_url),
|
||||
auth=_server_auth(server_url=base_url, session_id=resume_conversation_id),
|
||||
) as client:
|
||||
# Both a local bundle and a remote registered agent go through
|
||||
# the sessions API; _query_sessions_once picks the create route
|
||||
|
||||
+24
-26
@@ -32,8 +32,8 @@ from omnigent.runtime.tool_result_replay import (
|
||||
)
|
||||
|
||||
# termios/tty are POSIX-only and drive the native (tmux/PTY) Claude terminal,
|
||||
# which is disabled on Windows. Guard the import (special-cased by mypy, which
|
||||
# type-checks on Linux) so importing this module never crashes the CLI there.
|
||||
# which is disabled on Windows. Guard the import so static checking keeps the
|
||||
# POSIX path typed without making module import crash the CLI on Windows.
|
||||
if sys.platform != "win32":
|
||||
import termios
|
||||
import tty
|
||||
@@ -108,6 +108,7 @@ from omnigent.host.daemon_launch import (
|
||||
DAEMON_POLL_INTERVAL_S,
|
||||
error_text,
|
||||
launch_or_reuse_daemon_runner,
|
||||
open_daemon_client,
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
@@ -132,7 +133,7 @@ from omnigent.native_terminal import (
|
||||
terminal_attach_url as _attach_url,
|
||||
)
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
from omnigent.terminals.ws_bridge import (
|
||||
from omnigent.terminals.close_codes import (
|
||||
WS_CLOSE_TERMINAL_DETACHED,
|
||||
WS_CLOSE_TERMINAL_NOT_FOUND,
|
||||
)
|
||||
@@ -3161,10 +3162,11 @@ async def _is_terminal_resource_gone(
|
||||
f"/v1/sessions/{url_component(session_id)}"
|
||||
f"/resources/terminals/{url_component(terminal_id)}"
|
||||
)
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
try:
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
async with open_server_client(
|
||||
base_url,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(timeout_s),
|
||||
) as client:
|
||||
@@ -3264,12 +3266,11 @@ async def _close_claude_terminal(
|
||||
f"/v1/sessions/{url_component(session_id)}"
|
||||
f"/resources/terminals/{url_component(terminal_id)}"
|
||||
)
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(10.0),
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
async with open_server_client(
|
||||
base_url, headers=headers, timeout=httpx.Timeout(10.0)
|
||||
) as client:
|
||||
await client.delete(path)
|
||||
|
||||
@@ -3407,12 +3408,7 @@ async def _prepare_claude_terminal_via_daemon(
|
||||
startup_profiler = startup_profiler or StartupProfiler(name="omnigent claude", enabled=False)
|
||||
persist_args = list(_strip_resume_from_claude_args(claude_args))
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
) as client:
|
||||
async with open_daemon_client(base_url, headers, host_id, timeout=timeout) as client:
|
||||
startup_profiler.mark("daemon prepare http client ready")
|
||||
# Resuming an existing session must not re-close its terminal on
|
||||
# exit; a fresh launch owns teardown.
|
||||
@@ -3639,8 +3635,13 @@ def _run_with_remote_server(
|
||||
from omnigent.host.identity import load_or_create_host_identity
|
||||
|
||||
startup_profiler = startup_profiler or StartupProfiler(name="omnigent claude", enabled=False)
|
||||
# This machine's host id keys the WebSocket attach handshake (and its
|
||||
# reconnects) to the replica holding the runner's tunnel. A browser WS can't
|
||||
# set request headers, but the CLI can, so this rides the header — the
|
||||
# builder emits it only on a host-sharded deployment.
|
||||
host_id = load_or_create_host_identity().host_id
|
||||
startup_profiler.mark("remote headers resolving")
|
||||
headers = _remote_headers(server_url=base_url)
|
||||
headers = _remote_headers(server_url=base_url, host_id=host_id)
|
||||
startup_profiler.mark("remote headers resolved")
|
||||
# ``headers`` carries the bearer for the WebSocket attach handshake
|
||||
# (refreshed in place by ``_recover``). For HTTP requests we additionally
|
||||
@@ -3648,7 +3649,7 @@ def _run_with_remote_server(
|
||||
# long-lived transcript-forwarder client survives the ~1h Databricks
|
||||
# OAuth token TTL.
|
||||
startup_profiler.mark("remote auth resolving")
|
||||
forwarder_auth = _server_auth(server_url=base_url)
|
||||
forwarder_auth = _server_auth(server_url=base_url, session_id=None)
|
||||
startup_profiler.mark("remote auth resolved")
|
||||
prepared: PreparedClaudeTerminal | None = None
|
||||
# Bound before the attach call so the ``finally`` can read it even
|
||||
@@ -3764,7 +3765,7 @@ def _run_with_remote_server(
|
||||
daemon-spawned runner died, the server relaunches it on the
|
||||
next message (host-bound auto-relaunch).
|
||||
"""
|
||||
new_headers = _remote_headers(server_url=base_url)
|
||||
new_headers = _remote_headers(server_url=base_url, host_id=host_id)
|
||||
headers.clear()
|
||||
headers.update(new_headers)
|
||||
|
||||
@@ -3846,12 +3847,9 @@ async def _prepare_claude_terminal(
|
||||
"""
|
||||
startup_profiler = startup_profiler or StartupProfiler(name="omnigent claude", enabled=False)
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
) as client:
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(base_url, headers=headers, timeout=timeout) as client:
|
||||
startup_profiler.mark("prepare http client ready")
|
||||
cold_resume_args: tuple[str, ...] = ()
|
||||
# Cold resume = session existed but no live terminal. Even when
|
||||
|
||||
@@ -55,23 +55,22 @@ from urllib import error, request
|
||||
from omnigent._platform import stable_user_id
|
||||
from omnigent.claude_model_vocabulary import MODEL_VOCABULARY_ENV_VARS
|
||||
from omnigent.claude_native_message_display_hook import MESSAGE_DELTAS_FILE
|
||||
from omnigent.claude_native_status import CONTEXT_RAW_FILE
|
||||
from omnigent.json_types import JsonObject as _JsonObject
|
||||
from omnigent.kiro_native_bridge import bridge_root as kiro_bridge_root
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec
|
||||
from omnigent.inner.os_env import OSEnvironment
|
||||
from omnigent.llms.context_window import ModelPricing
|
||||
|
||||
from omnigent.inner.bundle_skills import claude_native_skill_args
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.hook_scripts.subagent_router import (
|
||||
AGENT_TOOL_MATCHER as CLAUDE_SUBAGENT_TOOL_MATCHER,
|
||||
)
|
||||
from omnigent.inner.os_env import OSEnvironment, create_os_environment
|
||||
from omnigent.reasoning_effort import CLAUDE_EFFORTS
|
||||
from omnigent.tools.base import Tool, ToolContext
|
||||
from omnigent.tools.builtins.os_env import build_os_env_tools
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -96,6 +95,10 @@ _RECENT_LOCAL_COMMAND_LINE_LIMIT = 200
|
||||
_RECENT_LOCAL_COMMAND_WINDOW_S = 10.0
|
||||
_FORKED_FROM_LINE_LIMIT = 200
|
||||
_TOOL_RELAY_FILE = "tool_relay.json"
|
||||
# Shell-sourceable sibling of tool_relay.json so the curl-based hook
|
||||
# commands can discover the live relay without a JSON parser. Re-written
|
||||
# on every relay start, so hooks survive runner restarts (new port).
|
||||
_TOOL_RELAY_ENV_FILE = "tool_relay.env"
|
||||
_TMUX_FILE = "tmux.json"
|
||||
_PERMISSION_HOOK_FILE = "permission_hook.json"
|
||||
_CONTEXT_FILE = "context.json"
|
||||
@@ -181,6 +184,32 @@ _DRAFT_NEEDLE_MAX_CHARS = 24
|
||||
# picker the person opened by hand covers the input box, so an injection would
|
||||
# be lost; the readiness gate treats it as "not ready".
|
||||
_MODEL_PICKER_OPEN_HINT = "use this session only"
|
||||
# Header of the ctrl+r prompt-history search. Like the picker it covers the
|
||||
# input box, but its selected history row renders the composer's ``❯`` glyph
|
||||
# above the filter box's frame rule, so the readiness scan alone reads it as
|
||||
# a mounted input box — keystrokes would land in the filter field, and the
|
||||
# submit Enter would replay whatever old prompt is selected.
|
||||
_REVERSE_SEARCH_OPEN_HINT = "Search prompts ·"
|
||||
# Surfaces a person can leave covering the composer from the embedded
|
||||
# terminal. Each documents Escape as its dismissal ("Esc to cancel"), which
|
||||
# closes it without committing anything and restores the empty input box, so
|
||||
# an injected web-UI message reclaims the pane instead of typing into the
|
||||
# surface. Shell mode (``!``) also occupies the composer but has no safe
|
||||
# textual marker: its footer line ("! for shell mode") appears verbatim in
|
||||
# the ``?`` shortcuts panel while the composer is fully usable.
|
||||
_OCCUPIED_INPUT_HINTS: tuple[str, ...] = (
|
||||
_REVERSE_SEARCH_OPEN_HINT,
|
||||
_MODEL_PICKER_OPEN_HINT,
|
||||
)
|
||||
# How long to keep dismissing an occupying surface that verifiably stays on
|
||||
# screen, and the spacing between repeated Escapes — a busy repaint can
|
||||
# swallow one (same reasoning as ``_SUBMIT_RETRY_INTERVAL_S``). The spacing
|
||||
# also bounds a residual hazard: were a successful Escape's repaint to
|
||||
# outlast it, the stale hint would draw a retry onto the bare composer
|
||||
# (interrupting a turn). 0.75s dwarfs a TUI repaint, so that window is
|
||||
# accepted rather than confirmation-gated.
|
||||
_OCCUPIED_INPUT_DISMISS_TIMEOUT_S = 3.0
|
||||
_OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S = 0.75
|
||||
# Titles of the confirmation dialog Claude Code pops when a switch invalidates
|
||||
# the prompt cache — one component, titled for what is being switched. It only
|
||||
# appears on a session with history, and it took ~1.9s to render on a warm
|
||||
@@ -1356,22 +1385,18 @@ def build_hook_settings(
|
||||
"command": command,
|
||||
}
|
||||
# ``MessageDisplay`` fires once per streamed assistant-text chunk and
|
||||
# Claude blocks on the hook, so it gets a dedicated stdlib-only
|
||||
# appender module instead of the heavier observer ``hook`` above —
|
||||
# the per-chunk subprocess must stay cheap. It just appends the
|
||||
# chunk to ``<bridge_dir>/message_deltas.jsonl``; the forwarder tails
|
||||
# that file and publishes ``response.output_text.delta`` events.
|
||||
message_display_command_parts = [
|
||||
python,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_message_display_hook",
|
||||
"--bridge-dir",
|
||||
str(bridge_dir),
|
||||
]
|
||||
# Claude blocks on the hook, so the hot path must not even pay an
|
||||
# interpreter spawn: a /bin/sh appender writes Claude's raw payload
|
||||
# (flattened to one line — JSON strings never carry literal newlines)
|
||||
# to ``message_deltas.jsonl``. The reader parses records by key and
|
||||
# skips non-delta lines, so raw envelopes need no Python-side shaping.
|
||||
deltas_quoted = shlex.quote(str(bridge_dir / MESSAGE_DELTAS_FILE))
|
||||
message_display_hook = {
|
||||
"type": "command",
|
||||
"command": shlex.join(message_display_command_parts),
|
||||
"command": (
|
||||
"p=$(cat | tr -d '\\r\\n'); "
|
||||
f'[ -n "$p" ] && printf \'%s\\n\' "$p" >> {deltas_quoted}; :'
|
||||
),
|
||||
}
|
||||
hooks: dict[str, list[_JsonObject]] = {
|
||||
"SessionStart": [{"hooks": [session_start_hook]}],
|
||||
@@ -1462,19 +1487,43 @@ def build_hook_settings(
|
||||
hooks["PermissionRequest"] = [{"hooks": [permission_hook]}]
|
||||
|
||||
# Policy-gate native Claude Code tools, not just relay/MCP tools.
|
||||
evaluate_policy_command_parts = [
|
||||
python,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_hook",
|
||||
"evaluate-policy",
|
||||
"--bridge-dir",
|
||||
str(bridge_dir),
|
||||
]
|
||||
# The hook is a bare curl against the relay's evaluate-policy
|
||||
# endpoint (which owns all transformation and verdict logic), so
|
||||
# Claude's blocking tool-call path pays no interpreter spawn. The
|
||||
# relay's coordinates are re-read from tool_relay.env on every
|
||||
# event, so hooks survive runner restarts. Before the relay exists
|
||||
# (it starts in the background at session create — a very early
|
||||
# hook can beat it) or when curl fails, the same stdin is
|
||||
# replayed into the Python hook, which owns the direct-server
|
||||
# path and the phase-aware fail-closed contract — exactly the
|
||||
# pre-curl behavior.
|
||||
relay_env_quoted = shlex.quote(str(bridge_dir / _TOOL_RELAY_ENV_FILE))
|
||||
evaluate_policy_python = shlex.join(
|
||||
[
|
||||
python,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_hook",
|
||||
"evaluate-policy",
|
||||
"--bridge-dir",
|
||||
str(bridge_dir),
|
||||
]
|
||||
)
|
||||
evaluate_policy_command = (
|
||||
"p=$(cat); "
|
||||
f"if [ -r {relay_env_quoted} ]; then . {relay_env_quoted}; "
|
||||
"out=$(printf '%s' \"$p\" | curl -sf --max-time 86400 "
|
||||
'-H "Authorization: Bearer $OMNIGENT_RELAY_TOKEN" '
|
||||
"-H 'Content-Type: application/json' --data-binary @- "
|
||||
'"$OMNIGENT_RELAY_URL/hook/claude/evaluate-policy" 2>/dev/null) '
|
||||
"&& { printf '%s' \"$out\"; exit 0; }; fi; "
|
||||
f"printf '%s' \"$p\" | {evaluate_policy_python}"
|
||||
)
|
||||
evaluate_policy_hook: _JsonObject = {
|
||||
"type": "command",
|
||||
"command": shlex.join(evaluate_policy_command_parts),
|
||||
"command": evaluate_policy_command,
|
||||
}
|
||||
|
||||
# In bypassPermissions mode PermissionRequest never fires, so
|
||||
# AskUserQuestion needs its own PreToolUse hook to surface the
|
||||
# form. It's a no-op in other modes to avoid double-surfacing.
|
||||
@@ -1559,20 +1608,20 @@ def build_hook_settings(
|
||||
if api_key_helper:
|
||||
settings["apiKeyHelper"] = api_key_helper
|
||||
# Override Claude Code's statusLine so we receive its stdin (the
|
||||
# only place ``context_window`` surfaces). Chain to whatever the
|
||||
# user had globally so claude-hud / their bar still renders.
|
||||
status_parts = [
|
||||
python,
|
||||
"-I",
|
||||
"-m",
|
||||
"omnigent.claude_native_status",
|
||||
"--bridge-dir",
|
||||
str(bridge_dir),
|
||||
]
|
||||
# only place ``context_window`` surfaces). A /bin/sh shim captures
|
||||
# the raw payload atomically (no interpreter spawn on Claude's
|
||||
# blocking statusLine path — the forwarder normalizes it into
|
||||
# ``context.json``) and chains to whatever the user had globally so
|
||||
# claude-hud / their bar still renders.
|
||||
raw_quoted = shlex.quote(str(bridge_dir / CONTEXT_RAW_FILE))
|
||||
status_command = (
|
||||
f"p=$(cat); printf '%s' \"$p\" > {raw_quoted}.$$.tmp"
|
||||
f" && mv -f {raw_quoted}.$$.tmp {raw_quoted}"
|
||||
)
|
||||
chain_command = read_user_status_line_command()
|
||||
if chain_command is not None:
|
||||
status_parts.extend(["--chain", chain_command])
|
||||
settings["statusLine"] = {"type": "command", "command": shlex.join(status_parts)}
|
||||
status_command += f"; printf '%s' \"$p\" | ( {chain_command} )"
|
||||
settings["statusLine"] = {"type": "command", "command": status_command}
|
||||
return settings
|
||||
|
||||
|
||||
@@ -1730,6 +1779,9 @@ def augment_claude_args(
|
||||
)
|
||||
if append_system_prompt:
|
||||
args.extend(["--append-system-prompt", append_system_prompt])
|
||||
# Imported here: bundle-skills parsing rides the spec graph; launch-only.
|
||||
from omnigent.inner.bundle_skills import claude_native_skill_args
|
||||
|
||||
args.extend(
|
||||
claude_native_skill_args(
|
||||
bundle_dir,
|
||||
@@ -2871,6 +2923,11 @@ def inject_user_message(
|
||||
(see :func:`_wait_for_claude_prompt_ready`). The second gate closes
|
||||
a race on freshly-created sessions where the first message would
|
||||
otherwise be typed into a still-booting TUI and silently dropped.
|
||||
Between the two, any surface the person left covering the composer
|
||||
from the embedded terminal — a ctrl+r history search, a hand-opened
|
||||
``/model`` picker — is dismissed with Escape
|
||||
(see :func:`_restore_occupied_input`), so the message reclaims the
|
||||
input box instead of typing into that surface.
|
||||
|
||||
Delivered as one bracketed paste via ``tmux load-buffer`` (from a
|
||||
temp file) + ``paste-buffer -p`` so interior newlines ride as raw CR
|
||||
@@ -2904,6 +2961,11 @@ def inject_user_message(
|
||||
after repeated submit Enters (message not delivered).
|
||||
"""
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
# A ctrl+r history search or hand-opened /model picker left covering
|
||||
# the composer swallows everything typed below — and can hide the
|
||||
# prompt glyph, wedging the readiness gate — so reclaim the input box
|
||||
# before waiting on it.
|
||||
_restore_occupied_input(info["socket_path"], info["tmux_target"])
|
||||
# tmux.json only means the tmux session exists; Claude Code's input
|
||||
# box mounts a few seconds later. Block until the prompt renders so
|
||||
# the first message isn't typed into a still-booting TUI and dropped.
|
||||
@@ -3123,6 +3185,11 @@ def inject_slash_command(
|
||||
"""
|
||||
Type a Claude Code slash command into the tmux pane and submit it.
|
||||
|
||||
A surface the person left covering the composer from the embedded
|
||||
terminal (ctrl+r history search, hand-opened ``/model`` picker) is
|
||||
dismissed first — see :func:`_restore_occupied_input` — so the
|
||||
command cannot be typed into it.
|
||||
|
||||
:param bridge_dir: Bridge directory path, e.g.
|
||||
``/tmp/omnigent/claude-native/<digest>``.
|
||||
:param command: Single-line slash command including the leading
|
||||
@@ -3159,6 +3226,10 @@ def inject_slash_command(
|
||||
info = _wait_for_tmux_info(bridge_dir, timeout_s=timeout_s)
|
||||
socket_path = info["socket_path"]
|
||||
tmux_target = info["tmux_target"]
|
||||
# Same reclaim as inject_user_message: a ctrl+r search or hand-opened
|
||||
# /model picker left covering the composer would swallow the C-u and
|
||||
# the typed command.
|
||||
_restore_occupied_input(socket_path, tmux_target)
|
||||
# ``C-u`` clears any draft the user is mid-typing; otherwise the
|
||||
# paste below concatenates with their text and Enter submits
|
||||
# ``<their-draft>/effort high`` as a turn. Unlike Escape it does
|
||||
@@ -3514,6 +3585,55 @@ def claude_pane_ready(bridge_dir: Path) -> bool:
|
||||
return _claude_prompt_rendered(pane)
|
||||
|
||||
|
||||
def _restore_occupied_input(socket_path: str, tmux_target: str) -> None:
|
||||
"""
|
||||
Dismiss a terminal-opened surface occupying Claude's input box.
|
||||
|
||||
A person can leave the composer covered from the embedded terminal —
|
||||
the ctrl+r prompt-history search, or a hand-opened ``/model`` picker
|
||||
(:data:`_OCCUPIED_INPUT_HINTS`). Keystrokes injected while one is up
|
||||
land in that surface instead of the chat input: the history search
|
||||
filters on the pasted text and its Enter replays whatever old prompt
|
||||
is selected. Each surface documents Escape as its dismissal ("Esc to
|
||||
cancel"), closing it without committing anything and restoring the
|
||||
empty input box, so the web-UI message wins the pane.
|
||||
|
||||
Escape is only sent while a hint is verifiably in the current
|
||||
capture — never blind, because on the bare composer Escape interrupts
|
||||
an in-flight turn. An empty (torn) capture means "unknown" and gets
|
||||
no Escape. A swallowed Escape is re-sent while the surface remains,
|
||||
spaced by :data:`_OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S`.
|
||||
Best-effort: a surface that outlives
|
||||
:data:`_OCCUPIED_INPUT_DISMISS_TIMEOUT_S` is left on screen and the
|
||||
caller's readiness gate or delivery verification fails loud, exactly
|
||||
as it did before this restore existed.
|
||||
|
||||
:param socket_path: Absolute path to the tmux socket.
|
||||
:param tmux_target: tmux pane target string, e.g. ``"main"``.
|
||||
:returns: None.
|
||||
"""
|
||||
deadline = time.monotonic() + _OCCUPIED_INPUT_DISMISS_TIMEOUT_S
|
||||
last_escape: float | None = None
|
||||
while True:
|
||||
pane = _capture_pane(socket_path, tmux_target)
|
||||
hint = next((text for text in _OCCUPIED_INPUT_HINTS if text in pane), None)
|
||||
if hint is None:
|
||||
return
|
||||
now = time.monotonic()
|
||||
if now >= deadline:
|
||||
_logger.warning(
|
||||
"claude-native: input box still occupied (%r) after %.1fs; proceeding",
|
||||
hint,
|
||||
_OCCUPIED_INPUT_DISMISS_TIMEOUT_S,
|
||||
)
|
||||
return
|
||||
if last_escape is None or now - last_escape >= _OCCUPIED_INPUT_DISMISS_RETRY_INTERVAL_S:
|
||||
_logger.info("claude-native: dismissing %r covering the input box", hint)
|
||||
_run_tmux(socket_path, "send-keys", "-t", tmux_target, "Escape")
|
||||
last_escape = now
|
||||
time.sleep(_CLAUDE_READY_POLL_INTERVAL_S)
|
||||
|
||||
|
||||
def _claude_prompt_rendered(pane: str) -> bool:
|
||||
"""
|
||||
Return whether Claude Code's input prompt is rendered in a pane.
|
||||
@@ -3856,6 +3976,7 @@ def start_tool_relay(
|
||||
loop,
|
||||
policy_client=policy_client,
|
||||
session_id=session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
)
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler_cls)
|
||||
host, port = _http_server_host_port(httpd)
|
||||
@@ -3869,6 +3990,13 @@ def start_tool_relay(
|
||||
if session_id is not None:
|
||||
relay_info["session_id"] = session_id
|
||||
_write_json_file(bridge_dir / _TOOL_RELAY_FILE, relay_info)
|
||||
# token_urlsafe's alphabet is [A-Za-z0-9_-], safe inside single quotes.
|
||||
env_path = bridge_dir / _TOOL_RELAY_ENV_FILE
|
||||
env_path.write_text(
|
||||
f"OMNIGENT_RELAY_URL='http://{host}:{port}'\nOMNIGENT_RELAY_TOKEN='{token}'\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
os.chmod(env_path, 0o600)
|
||||
thread = threading.Thread(
|
||||
target=httpd.serve_forever,
|
||||
name="claude-native-tool-relay",
|
||||
@@ -4069,6 +4197,7 @@ def _tool_relay_handler_factory(
|
||||
*,
|
||||
policy_client: httpx.AsyncClient | None = None,
|
||||
session_id: str | None = None,
|
||||
bridge_dir: Path | None = None,
|
||||
) -> type[BaseHTTPRequestHandler]:
|
||||
"""
|
||||
Create an HTTP handler class for active-turn tool calls.
|
||||
@@ -4103,7 +4232,11 @@ def _tool_relay_handler_factory(
|
||||
|
||||
:returns: None.
|
||||
"""
|
||||
if self.path not in ("/tool", "/policies/evaluate"):
|
||||
if self.path not in (
|
||||
"/tool",
|
||||
"/policies/evaluate",
|
||||
"/hook/claude/evaluate-policy",
|
||||
):
|
||||
self.send_error(HTTPStatus.NOT_FOUND)
|
||||
return
|
||||
if self.headers.get("Authorization") != f"Bearer {token}":
|
||||
@@ -4113,6 +4246,9 @@ def _tool_relay_handler_factory(
|
||||
if payload is None:
|
||||
self.send_error(HTTPStatus.BAD_REQUEST)
|
||||
return
|
||||
if self.path == "/hook/claude/evaluate-policy":
|
||||
self._handle_hook_evaluate(payload)
|
||||
return
|
||||
if self.path == "/policies/evaluate":
|
||||
self._handle_policy_evaluate(payload)
|
||||
return
|
||||
@@ -4125,6 +4261,89 @@ def _tool_relay_handler_factory(
|
||||
arguments = {}
|
||||
self._send_json(_run_relay_tool(tool_executor, loop, name, arguments))
|
||||
|
||||
def _respond_hook_output(self, output: dict[str, object] | None) -> None:
|
||||
"""Answer a hook-evaluate request with final hook output JSON.
|
||||
|
||||
:param output: Hook output dict, or ``None`` for "no opinion"
|
||||
(empty body — Claude proceeds).
|
||||
"""
|
||||
raw = b"" if output is None else json.dumps(output).encode("utf-8")
|
||||
self.send_response(HTTPStatus.OK)
|
||||
self.send_header("Content-Type", "application/json")
|
||||
self.send_header("Content-Length", str(len(raw)))
|
||||
self.end_headers()
|
||||
if raw:
|
||||
self.wfile.write(raw)
|
||||
|
||||
def _handle_hook_evaluate(self, payload: _JsonObject) -> None:
|
||||
"""Serve one Claude policy hook event end to end.
|
||||
|
||||
The hook subprocess is a bare curl: this endpoint does the
|
||||
payload→EvaluationRequest transform, the upstream evaluate
|
||||
call, and the EvaluationResponse→hook-output transform, so the
|
||||
blocking hook path pays no interpreter spawn. Responses are
|
||||
always 200; enforcement failures are expressed as fail-closed
|
||||
hook output.
|
||||
"""
|
||||
# Heavy policy imports stay off this module's import path (hook
|
||||
# subprocesses import it); the relay runs inside the runner
|
||||
# process where these modules are already loaded.
|
||||
from omnigent.native_policy_hook import (
|
||||
evaluation_response_to_hook_output,
|
||||
fail_closed_hook_output,
|
||||
hook_payload_to_evaluation_request,
|
||||
)
|
||||
|
||||
raw_event = payload.get("hook_event_name")
|
||||
hook_event = raw_event if isinstance(raw_event, str) else ""
|
||||
if policy_client is None or session_id is None:
|
||||
self._respond_hook_output(None)
|
||||
return
|
||||
eval_request = hook_payload_to_evaluation_request(hook_event, payload)
|
||||
if eval_request is None:
|
||||
self._respond_hook_output(None)
|
||||
return
|
||||
context = eval_request["event"]["context"]
|
||||
context["harness"] = "claude-native"
|
||||
if bridge_dir is not None:
|
||||
status_model = read_claude_status_model(bridge_dir)
|
||||
if status_model:
|
||||
context["model"] = status_model
|
||||
# Stable re-attach id: a retried long-poll reattaches to the
|
||||
# same parked ASK instead of raising a second approval card.
|
||||
request_body = {
|
||||
**eval_request,
|
||||
"_omnigent_elicitation_id": f"elicit_evaluate_{secrets.token_hex(16)}",
|
||||
}
|
||||
import urllib.parse as _up
|
||||
|
||||
url = f"/v1/sessions/{_up.quote(session_id, safe='')}/policies/evaluate"
|
||||
verdict: object = None
|
||||
last_error: str | None = None
|
||||
for attempt in range(3):
|
||||
if attempt:
|
||||
time.sleep(0.4)
|
||||
future = asyncio.run_coroutine_threadsafe(
|
||||
policy_client.post(url, json=request_body), loop
|
||||
)
|
||||
try:
|
||||
resp = future.result(timeout=86400.0)
|
||||
except Exception as exc: # noqa: BLE001 — shaped fail-closed below
|
||||
last_error = str(exc).strip() or type(exc).__name__
|
||||
continue
|
||||
if resp.status_code != HTTPStatus.OK:
|
||||
last_error = f"server returned HTTP {resp.status_code}"
|
||||
continue
|
||||
try:
|
||||
verdict = json.loads(resp.content)
|
||||
except (ValueError, TypeError):
|
||||
last_error = "malformed EvaluationResponse body"
|
||||
break
|
||||
if not isinstance(verdict, dict) or not verdict.get("result"):
|
||||
self._respond_hook_output(fail_closed_hook_output(hook_event, last_error))
|
||||
return
|
||||
self._respond_hook_output(evaluation_response_to_hook_output(hook_event, verdict))
|
||||
|
||||
def _handle_policy_evaluate(self, payload: _JsonObject) -> None:
|
||||
if policy_client is None or session_id is None:
|
||||
self.send_error(HTTPStatus.SERVICE_UNAVAILABLE)
|
||||
@@ -4692,6 +4911,14 @@ def _build_tools(config: _JsonObject) -> tuple[dict[str, Tool], Callable[[], Non
|
||||
:returns: ``(tools, close_tools)`` where ``close_tools``
|
||||
releases any helper processes.
|
||||
"""
|
||||
# Imported here, not at module top: this drags the tools/spec/pydantic
|
||||
# graph (~300 ms of interpreter startup), and this module is on the
|
||||
# import path of every per-chunk/per-tool-call Claude hook subprocess.
|
||||
# Only the bridge MCP server (launch path) ever builds these tools.
|
||||
from omnigent.inner.datamodel import OSEnvSandboxSpec, OSEnvSpec
|
||||
from omnigent.inner.os_env import create_os_environment
|
||||
from omnigent.tools.builtins.os_env import build_os_env_tools
|
||||
|
||||
workspace_raw = config.get("workspace")
|
||||
workspace = Path(workspace_raw) if isinstance(workspace_raw, str) and workspace_raw else None
|
||||
os_env: OSEnvironment | None = None
|
||||
|
||||
@@ -45,6 +45,7 @@ from omnigent.claude_native_bridge import (
|
||||
write_active_session_id,
|
||||
)
|
||||
from omnigent.claude_native_message_display_hook import MESSAGE_DELTAS_FILE
|
||||
from omnigent.claude_native_status import sync_raw_status_context
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.reasoning_effort import CLAUDE_EFFORTS, EFFORT_CLEAR_VALUES
|
||||
|
||||
@@ -782,6 +783,9 @@ async def forward_claude_transcript_to_session(
|
||||
# the per-poll cost reconciliation from re-parsing unchanged transcripts.
|
||||
# Reset on /clear and /fork rotations alongside ``dedupe``.
|
||||
cost_cache: dict[Path, _TranscriptCostCacheEntry] = {}
|
||||
# (mtime_ns, size) of the statusLine shim's raw capture last normalized
|
||||
# into context.json (see claude_native_status.sync_raw_status_context).
|
||||
status_raw_sig: tuple[int, int] | None = None
|
||||
# Per-process latch: once we PATCH the conversation with the
|
||||
# Claude-native session id, never PATCH again. Persists for the
|
||||
# lifetime of the forwarder task; the server's idempotence handles
|
||||
@@ -794,9 +798,9 @@ async def forward_claude_transcript_to_session(
|
||||
task_statuses: dict[str, str] = {}
|
||||
task_order: list[str] = []
|
||||
timeout = httpx.Timeout(_POST_TIMEOUT_S)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url, headers=headers, auth=auth, timeout=timeout
|
||||
) as client:
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(base_url, headers=headers, auth=auth, timeout=timeout) as client:
|
||||
while True:
|
||||
try:
|
||||
async with asyncio.timeout(_FORWARD_LOOP_STALL_DEADLINE_S):
|
||||
@@ -901,6 +905,9 @@ async def forward_claude_transcript_to_session(
|
||||
session_id=current_session_id,
|
||||
bridge_dir=bridge_dir,
|
||||
)
|
||||
# Normalize the statusLine shim's raw capture into
|
||||
# context.json (one stat when nothing changed).
|
||||
status_raw_sig = sync_raw_status_context(bridge_dir, status_raw_sig)
|
||||
transcript_path = read_transcript_path(bridge_dir)
|
||||
if transcript_path is not None:
|
||||
state = await _ensure_state_for_transcript(
|
||||
|
||||
@@ -11,8 +11,7 @@ import sys
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from omnigent.claude_native_bridge import (
|
||||
BRIDGE_ID_LABEL_KEY,
|
||||
@@ -28,17 +27,13 @@ from omnigent.claude_native_bridge import (
|
||||
url_component,
|
||||
write_active_session_id,
|
||||
)
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.native_policy_hook import (
|
||||
_is_login_redirect_or_unauthorized,
|
||||
evaluation_response_to_hook_output,
|
||||
fail_closed_hook_output,
|
||||
hook_payload_to_evaluation_request,
|
||||
policy_hook_reauth,
|
||||
post_evaluate_with_retry,
|
||||
read_relay_policy_config,
|
||||
relay_policy_evaluate_url,
|
||||
)
|
||||
|
||||
# The observer path (the default, most frequent invocation — Claude blocks
|
||||
# on it per Stop/UserPromptSubmit/TaskCreated/...) must not pay the
|
||||
# httpx/policy import cost; those are imported inside the subcommands and
|
||||
# helpers that actually speak HTTP.
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
# Client-side budget for the permission-request long-poll to AP. Held
|
||||
# at one day so the hook subprocess waits ~indefinitely for a verdict
|
||||
@@ -128,17 +123,23 @@ def _env_float(name: str, default: float) -> float:
|
||||
# down server can no longer re-POST for a day. Overridable for operators who
|
||||
# want more slack against a flaky upstream.
|
||||
_PERMISSION_MAX_CONSECUTIVE_FAILURES = max(1, _env_int("OMNIGENT_HOOK_MAX_RETRIES", 8))
|
||||
# httpx errors that mean the request never reached a live server (no response
|
||||
# was ever begun). These are unambiguous hard failures — the server is down /
|
||||
# unreachable, not holding a poll. Everything else under ``httpx.HTTPError``
|
||||
# that is not a 4xx/5xx status (RemoteProtocolError, ReadError, ReadTimeout, …)
|
||||
# means the connection was established and then severed mid-poll.
|
||||
_NEVER_CONNECTED_ERRORS = (
|
||||
httpx.ConnectError,
|
||||
httpx.ConnectTimeout,
|
||||
httpx.PoolTimeout,
|
||||
httpx.ProxyError,
|
||||
)
|
||||
|
||||
|
||||
def _never_connected_errors() -> tuple[type[Exception], ...]:
|
||||
"""httpx errors meaning the request never reached a live server.
|
||||
|
||||
No response was ever begun — unambiguous hard failures (the server is
|
||||
down / unreachable), not a held poll. Everything else under
|
||||
``httpx.HTTPError`` that is not a 4xx/5xx status (RemoteProtocolError,
|
||||
ReadError, ReadTimeout, …) means the connection was established and
|
||||
then severed mid-poll. A function, not a module constant, so the
|
||||
hook's hot observer path never imports httpx.
|
||||
"""
|
||||
import httpx
|
||||
|
||||
return (httpx.ConnectError, httpx.ConnectTimeout, httpx.PoolTimeout, httpx.ProxyError)
|
||||
|
||||
|
||||
# An established connection that drops in under this many seconds is treated as
|
||||
# a flapping/crash-looping server (a hard failure), NOT a genuinely-parked poll
|
||||
# a proxy severed. Comfortably below any real idle-proxy timeout (typically
|
||||
@@ -365,6 +366,18 @@ def _rotate_session_on_clear(bridge_dir: Path) -> str | None:
|
||||
if isinstance(raw_headers, dict)
|
||||
else {}
|
||||
)
|
||||
import httpx
|
||||
|
||||
# Route the whole rotation sequence (GET old, POST /v1/sessions or /fork,
|
||||
# PATCH new, DELETE old) to the replica holding this host's tunnel: a managed
|
||||
# create/fork notifies the host inline over its pod-local tunnel, so an
|
||||
# off-replica request can't reach it. This hook client carries no
|
||||
# _RunnerDatabricksAuth, so key the reused headers dict from the runner-env
|
||||
# host_id (databricks_request_headers reads OMNIGENT_RUNNER_SLICE_KEY when no
|
||||
# explicit host_id; emitted only on the workspace mount).
|
||||
from omnigent.cli_auth import databricks_request_headers
|
||||
|
||||
headers.update(databricks_request_headers(ap_server_url))
|
||||
try:
|
||||
with httpx.Client(
|
||||
headers=headers, timeout=httpx.Timeout(_SESSION_ROTATION_TIMEOUT_S)
|
||||
@@ -411,6 +424,18 @@ def _rotate_session_on_fork(bridge_dir: Path) -> str | None:
|
||||
if isinstance(raw_headers, dict)
|
||||
else {}
|
||||
)
|
||||
import httpx
|
||||
|
||||
# Route the whole rotation sequence (GET old, POST /v1/sessions or /fork,
|
||||
# PATCH new, DELETE old) to the replica holding this host's tunnel: a managed
|
||||
# create/fork notifies the host inline over its pod-local tunnel, so an
|
||||
# off-replica request can't reach it. This hook client carries no
|
||||
# _RunnerDatabricksAuth, so key the reused headers dict from the runner-env
|
||||
# host_id (databricks_request_headers reads OMNIGENT_RUNNER_SLICE_KEY when no
|
||||
# explicit host_id; emitted only on the workspace mount).
|
||||
from omnigent.cli_auth import databricks_request_headers
|
||||
|
||||
headers.update(databricks_request_headers(ap_server_url))
|
||||
try:
|
||||
with httpx.Client(
|
||||
headers=headers, timeout=httpx.Timeout(_SESSION_ROTATION_TIMEOUT_S)
|
||||
@@ -489,6 +514,8 @@ def _create_clear_replacement_session(
|
||||
)
|
||||
bind_resp.raise_for_status()
|
||||
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
|
||||
terminal_id = terminal_resource_id("claude", "main")
|
||||
transfer_resp = client.post(
|
||||
(
|
||||
@@ -569,6 +596,8 @@ def _create_fork_replacement_session(
|
||||
)
|
||||
bind_resp.raise_for_status()
|
||||
|
||||
from omnigent.entities.session_resources import terminal_resource_id
|
||||
|
||||
terminal_id = terminal_resource_id("claude", "main")
|
||||
transfer_resp = client.post(
|
||||
(
|
||||
@@ -648,7 +677,7 @@ def _post_hook_with_reattach(
|
||||
Failure classification:
|
||||
|
||||
* **Hard failure → count toward the cap.** A 5xx, or a connection that
|
||||
never established (:data:`_NEVER_CONNECTED_ERRORS`), or an established
|
||||
never established (:func:`_never_connected_errors`), or an established
|
||||
connection that dropped in under :data:`_PERMISSION_HELD_POLL_FLOOR_S`
|
||||
(a flapping/crash-looping server). This is the spin.
|
||||
* **Held-poll sever → reset the counter.** An established connection that
|
||||
@@ -701,6 +730,10 @@ def _post_hook_with_reattach(
|
||||
"_omnigent_elicitation_id": f"elicit_claude_{secrets.token_hex(16)}",
|
||||
}
|
||||
backoff_s = _PERMISSION_RETRY_INITIAL_BACKOFF_S
|
||||
import httpx
|
||||
|
||||
from omnigent.native_policy_hook import _is_login_redirect_or_unauthorized
|
||||
|
||||
timeout = httpx.Timeout(_PERMISSION_TIMEOUT_S, connect=_PERMISSION_CONNECT_TIMEOUT_S)
|
||||
# Absolute backstop: even a run of held-poll severs (which don't count
|
||||
# toward the hard-failure cap) can't loop past the day-long human-answer
|
||||
@@ -751,7 +784,7 @@ def _post_hook_with_reattach(
|
||||
# Classify by HOW it failed, not by elapsed time (a proxy severs a
|
||||
# legitimately-held poll in seconds-to-minutes, so wall-clock can't
|
||||
# tell it from a down server — #1782 Polly review).
|
||||
never_connected = isinstance(exc, _NEVER_CONNECTED_ERRORS)
|
||||
never_connected = isinstance(exc, _never_connected_errors())
|
||||
held_s = time.monotonic() - attempt_started
|
||||
# Hard failure iff the server was never reached, OR an established
|
||||
# connection dropped so fast it's a flap rather than a parked poll.
|
||||
@@ -801,6 +834,8 @@ def _main_permission_request(argv: list[str]) -> int:
|
||||
:returns: Process exit code. Returns ``0`` on transport failures so
|
||||
Claude Code falls back to its terminal prompt.
|
||||
"""
|
||||
from omnigent.native_policy_hook import policy_hook_reauth
|
||||
|
||||
args = _parse_permission_args(argv)
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
@@ -826,6 +861,13 @@ def _main_permission_request(argv: list[str]) -> int:
|
||||
raw_headers = config.get("ap_auth_headers")
|
||||
if isinstance(raw_headers, dict):
|
||||
headers = {str(key): str(value) for key, value in raw_headers.items()}
|
||||
# A permission request raises a web elicitation that parks in the pod-local
|
||||
# registry on the replica holding this session's runner tunnel; an unkeyed
|
||||
# POST lands elsewhere and the approval is silently lost. Key from the
|
||||
# runner-env host_id (reads OMNIGENT_RUNNER_SLICE_KEY; workspace mount only).
|
||||
from omnigent.cli_auth import databricks_request_headers
|
||||
|
||||
headers.update(databricks_request_headers(ap_server_url))
|
||||
url = (
|
||||
f"{ap_server_url.rstrip('/')}/v1/sessions/"
|
||||
f"{url_component(session_id)}/hooks/permission-request"
|
||||
@@ -867,6 +909,8 @@ def _main_ask_user_question(argv: list[str]) -> int:
|
||||
:returns: Process exit code. Returns ``0`` on any failure so Claude Code
|
||||
falls back to its terminal TUI prompt rather than blocking.
|
||||
"""
|
||||
from omnigent.native_policy_hook import policy_hook_reauth
|
||||
|
||||
args = _parse_permission_args(argv)
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
@@ -898,6 +942,12 @@ def _main_ask_user_question(argv: list[str]) -> int:
|
||||
raw_headers = config.get("ap_auth_headers")
|
||||
if isinstance(raw_headers, dict):
|
||||
headers = {str(key): str(value) for key, value in raw_headers.items()}
|
||||
# Same as the permission-request hook: the elicitation parks in the pod-local
|
||||
# registry on the session's tunnel replica, so key the POST from the
|
||||
# runner-env host_id or an off-replica landing silently drops the prompt.
|
||||
from omnigent.cli_auth import databricks_request_headers
|
||||
|
||||
headers.update(databricks_request_headers(ap_server_url))
|
||||
url = (
|
||||
f"{ap_server_url.rstrip('/')}/v1/sessions/"
|
||||
f"{url_component(session_id)}/hooks/permission-request"
|
||||
@@ -995,6 +1045,16 @@ def _main_evaluate_policy(argv: list[str]) -> int:
|
||||
:returns: Process exit code. Always ``0`` — blocking verdicts
|
||||
are expressed via the JSON output, not exit codes.
|
||||
"""
|
||||
from omnigent.native_policy_hook import (
|
||||
evaluation_response_to_hook_output,
|
||||
fail_closed_hook_output,
|
||||
hook_payload_to_evaluation_request,
|
||||
policy_hook_reauth,
|
||||
post_evaluate_with_retry,
|
||||
read_relay_policy_config,
|
||||
relay_policy_evaluate_url,
|
||||
)
|
||||
|
||||
args = _parse_evaluate_policy_args(argv)
|
||||
raw = sys.stdin.read()
|
||||
try:
|
||||
@@ -1048,6 +1108,12 @@ def _main_evaluate_policy(argv: list[str]) -> int:
|
||||
raw_headers = config.get("ap_auth_headers")
|
||||
if isinstance(raw_headers, dict):
|
||||
headers = {str(k): str(v) for k, v in raw_headers.items()}
|
||||
# This posts to the session's policy registry on the replica holding its
|
||||
# tunnel; key from the runner-env host_id so it isn't misrouted. (The
|
||||
# relay branch above targets a relay token URL, so it needs no key.)
|
||||
from omnigent.cli_auth import databricks_request_headers
|
||||
|
||||
headers.update(databricks_request_headers(ap_server_url))
|
||||
session_component = url_component(session_id)
|
||||
url = f"{ap_server_url.rstrip('/')}/v1/sessions/{session_component}/policies/evaluate"
|
||||
reauth = policy_hook_reauth(ap_server_url, headers)
|
||||
|
||||
@@ -47,6 +47,8 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent.process_logging import data_dir
|
||||
|
||||
# Env-var override for the persistent state root. Reserved for tests
|
||||
# (and for advanced users who want to put state on a non-default
|
||||
# volume). When unset, the module falls back to
|
||||
@@ -91,8 +93,8 @@ def _claude_native_state_root() -> Path:
|
||||
|
||||
Honors the :data:`_STATE_ROOT_ENV_VAR` override so tests can
|
||||
point the state tree at a per-test ``tmp_path`` without
|
||||
clobbering the user's real home directory. Production callers
|
||||
leave the env unset and get the default
|
||||
clobbering the user's state. Otherwise the root follows
|
||||
``OMNIGENT_DATA_DIR``, falling back to
|
||||
``~/.omnigent/claude-native``.
|
||||
|
||||
Lazy: created on first write, never on read (the resume / picker
|
||||
@@ -105,7 +107,7 @@ def _claude_native_state_root() -> Path:
|
||||
override = os.environ.get(_STATE_ROOT_ENV_VAR)
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path.home() / ".omnigent" / "claude-native"
|
||||
return data_dir() / "claude-native"
|
||||
|
||||
|
||||
def _state_dir_for_conversation_id(conversation_id: str) -> Path:
|
||||
|
||||
@@ -20,6 +20,93 @@ import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
_CONTEXT_FILE = "context.json"
|
||||
# Raw statusLine stdin captured by the shell shim the settings now install
|
||||
# (no Python spawn on Claude's blocking statusLine path). The forwarder
|
||||
# normalizes it into ``context.json`` via :func:`sync_raw_status_context`.
|
||||
CONTEXT_RAW_FILE = "context_raw.json"
|
||||
|
||||
|
||||
def normalize_status_payload(payload: dict[str, object]) -> dict[str, object] | None:
|
||||
"""
|
||||
Extract the ``context.json`` record from a raw statusLine payload.
|
||||
|
||||
:param payload: Decoded statusLine stdin JSON from Claude Code.
|
||||
:returns: The record to persist, or ``None`` when the payload carries
|
||||
no usable ``context_window`` (nothing worth recording).
|
||||
"""
|
||||
context = payload.get("context_window")
|
||||
if not isinstance(context, dict):
|
||||
return None
|
||||
size = context.get("context_window_size")
|
||||
usage = context.get("current_usage")
|
||||
if not isinstance(size, int) or size <= 0:
|
||||
return None
|
||||
record: dict[str, object] = {"context_window_size": size}
|
||||
if isinstance(usage, dict):
|
||||
record["current_usage"] = usage
|
||||
used_pct = context.get("used_percentage")
|
||||
if isinstance(used_pct, (int, float)):
|
||||
record["used_percentage"] = used_pct
|
||||
# Claude Code's statusLine stdin carries a top-level ``cost`` block with
|
||||
# its own cumulative session billing; the forwarder reports it because
|
||||
# claude-native produces no ``response.completed`` cost events.
|
||||
cost = payload.get("cost")
|
||||
if isinstance(cost, dict):
|
||||
total_cost = cost.get("total_cost_usd")
|
||||
if (
|
||||
isinstance(total_cost, (int, float))
|
||||
and not isinstance(total_cost, bool)
|
||||
and total_cost >= 0
|
||||
):
|
||||
record["total_cost_usd"] = float(total_cost)
|
||||
# The active model, rewritten on every render — including right after an
|
||||
# in-pane ``/model`` switch — so gates see the switch before the next turn.
|
||||
model = payload.get("model")
|
||||
model_id: str | None = None
|
||||
if isinstance(model, dict):
|
||||
raw_model = model.get("id") or model.get("display_name")
|
||||
if isinstance(raw_model, str) and raw_model.strip():
|
||||
model_id = raw_model.strip()
|
||||
elif isinstance(model, str) and model.strip():
|
||||
model_id = model.strip()
|
||||
if model_id is not None:
|
||||
record["model"] = model_id
|
||||
return record
|
||||
|
||||
|
||||
def sync_raw_status_context(
|
||||
bridge_dir: Path,
|
||||
last_sig: tuple[int, int] | None,
|
||||
) -> tuple[int, int] | None:
|
||||
"""
|
||||
Normalize the shim's raw statusLine capture into ``context.json``.
|
||||
|
||||
Called from the forwarder's poll loop. Cheap when nothing changed —
|
||||
one ``stat`` against the remembered ``(mtime_ns, size)`` signature.
|
||||
|
||||
:param bridge_dir: Bridge directory shared with the statusLine shim.
|
||||
:param last_sig: Signature returned by the previous call, or ``None``.
|
||||
:returns: The new signature to carry forward (unchanged on a miss or
|
||||
an unparseable file, so the next poll retries).
|
||||
"""
|
||||
raw_path = bridge_dir / CONTEXT_RAW_FILE
|
||||
try:
|
||||
stat = raw_path.stat()
|
||||
except OSError:
|
||||
return last_sig
|
||||
sig = (stat.st_mtime_ns, stat.st_size)
|
||||
if sig == last_sig:
|
||||
return last_sig
|
||||
try:
|
||||
payload = json.loads(raw_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return last_sig
|
||||
if not isinstance(payload, dict):
|
||||
return sig
|
||||
record = normalize_status_payload(payload)
|
||||
if record is not None:
|
||||
_write_record_atomic(bridge_dir, record)
|
||||
return sig
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -67,71 +154,43 @@ def _write_context_atomic(bridge_dir: Path, payload: dict[str, object]) -> None:
|
||||
Persist the statusLine payload's context fields to ``context.json``.
|
||||
|
||||
Atomic write so the forwarder never observes a half-written file.
|
||||
Soft-fails (writes nothing) when ``context_window`` is missing or
|
||||
malformed — there's nothing useful to record.
|
||||
Soft-fails (writes nothing) when the payload carries no usable
|
||||
``context_window``. Retained for older bridge dirs whose settings
|
||||
still invoke this module; new settings install a shell shim and the
|
||||
forwarder normalizes via :func:`sync_raw_status_context`.
|
||||
|
||||
:param bridge_dir: Bridge directory shared with the forwarder.
|
||||
:param payload: Decoded statusLine stdin JSON.
|
||||
"""
|
||||
context = payload.get("context_window")
|
||||
if not isinstance(context, dict):
|
||||
record = normalize_status_payload(payload)
|
||||
if record is None:
|
||||
return
|
||||
size = context.get("context_window_size")
|
||||
usage = context.get("current_usage")
|
||||
if not isinstance(size, int) or size <= 0:
|
||||
return
|
||||
record: dict[str, object] = {"context_window_size": size}
|
||||
if isinstance(usage, dict):
|
||||
record["current_usage"] = usage
|
||||
used_pct = context.get("used_percentage")
|
||||
if isinstance(used_pct, (int, float)):
|
||||
record["used_percentage"] = used_pct
|
||||
# Claude Code's statusLine stdin carries a top-level ``cost`` block with
|
||||
# its own cumulative session billing. Capture ``total_cost_usd`` so the
|
||||
# forwarder can report it (claude-native produces no ``response.completed``
|
||||
# event, so the Omnigent relay's cost accumulation never runs for it).
|
||||
cost = payload.get("cost")
|
||||
if isinstance(cost, dict):
|
||||
total_cost = cost.get("total_cost_usd")
|
||||
if (
|
||||
isinstance(total_cost, (int, float))
|
||||
and not isinstance(total_cost, bool)
|
||||
and total_cost >= 0
|
||||
):
|
||||
record["total_cost_usd"] = float(total_cost)
|
||||
# Claude Code's statusLine stdin carries the active model as a ``model``
|
||||
# block (``{"id": "claude-opus-4-8", "display_name": "Opus"}``), rewritten
|
||||
# on every render — including right after an in-pane ``/model`` switch.
|
||||
# Capture the concrete id so the forwarder can mirror the switch to
|
||||
# ``model_override`` on the next poll, before the user's next message,
|
||||
# rather than waiting for the next turn's transcript to reveal the model
|
||||
# (which lagged model-gated policies by one turn). Defensive about the
|
||||
# shape: accept a ``{id|display_name}`` dict or a bare string.
|
||||
model = payload.get("model")
|
||||
model_id: str | None = None
|
||||
if isinstance(model, dict):
|
||||
raw_model = model.get("id") or model.get("display_name")
|
||||
if isinstance(raw_model, str) and raw_model.strip():
|
||||
model_id = raw_model.strip()
|
||||
elif isinstance(model, str) and model.strip():
|
||||
model_id = model.strip()
|
||||
if model_id is not None:
|
||||
record["model"] = model_id
|
||||
try:
|
||||
bridge_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".context-", dir=str(bridge_dir))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(record, handle, separators=(",", ":"))
|
||||
os.replace(tmp_path, str(bridge_dir / _CONTEXT_FILE))
|
||||
except OSError:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
_write_record_atomic(bridge_dir, record)
|
||||
except OSError as exc:
|
||||
print(f"omnigent claude status: write failed: {exc}", file=sys.stderr)
|
||||
|
||||
|
||||
def _write_record_atomic(bridge_dir: Path, record: dict[str, object]) -> None:
|
||||
"""
|
||||
Atomically write one normalized record to ``context.json``.
|
||||
|
||||
:param bridge_dir: Bridge directory shared with the forwarder.
|
||||
:param record: Normalized record from :func:`normalize_status_payload`.
|
||||
:raises OSError: When the temp-file write or replace fails.
|
||||
"""
|
||||
bridge_dir.mkdir(parents=True, exist_ok=True)
|
||||
fd, tmp_path = tempfile.mkstemp(prefix=".context-", dir=str(bridge_dir))
|
||||
try:
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
||||
json.dump(record, handle, separators=(",", ":"))
|
||||
os.replace(tmp_path, str(bridge_dir / _CONTEXT_FILE))
|
||||
except OSError:
|
||||
with contextlib.suppress(OSError):
|
||||
os.unlink(tmp_path)
|
||||
raise
|
||||
|
||||
|
||||
def _chain(command: str, stdin_payload: str) -> None:
|
||||
"""
|
||||
Exec the user's pre-existing statusLine command, piping our stdin.
|
||||
|
||||
+197
-53
@@ -70,7 +70,7 @@ from omnigent.inner import _proc, ui
|
||||
from omnigent.integration_daemon import IntegrationDaemon
|
||||
from omnigent.json_types import JsonObject as _JsonObject
|
||||
from omnigent.onboarding.sandboxes import available_providers as _sandbox_providers
|
||||
from omnigent.process_logging import LOG_LEVEL_ENV_VAR, LOG_TO_STDERR_ENV_VAR
|
||||
from omnigent.process_logging import LOG_LEVEL_ENV_VAR, LOG_TO_STDERR_ENV_VAR, data_dir, env_truthy
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import socket
|
||||
@@ -1930,6 +1930,44 @@ def _warn_deprecated_harness_path_env_vars() -> None:
|
||||
)
|
||||
|
||||
|
||||
REQUIRE_WRAPPER_ENV = "OMNIGENT_REQUIRE_WRAPPER"
|
||||
WRAPPER_COMMAND_ENV = "OMNIGENT_WRAPPER_COMMAND"
|
||||
WRAPPER_BYPASS_ENV = "OMNIGENT_WRAPPER_BYPASS"
|
||||
|
||||
|
||||
def _wrapper_guard_error(env: Mapping[str, str], prog: str) -> str | None:
|
||||
"""Return the block message when a naked ``omni`` call is refused, else ``None``.
|
||||
|
||||
A deployment that wraps the CLI (e.g. ``isaac omni``) sets
|
||||
``OMNIGENT_REQUIRE_WRAPPER`` so direct calls are refused; the wrapper sets
|
||||
``OMNIGENT_WRAPPER_BYPASS`` around its own invocation to pass through, and
|
||||
``OMNIGENT_WRAPPER_COMMAND`` names the command to suggest instead.
|
||||
"""
|
||||
if not env_truthy(env.get(REQUIRE_WRAPPER_ENV)):
|
||||
return None
|
||||
if env_truthy(env.get(WRAPPER_BYPASS_ENV)):
|
||||
return None
|
||||
redirect = (env.get(WRAPPER_COMMAND_ENV) or "").strip()
|
||||
if redirect:
|
||||
detail = f"Use `{redirect}` instead, or set {WRAPPER_BYPASS_ENV}=1 to run it directly."
|
||||
else:
|
||||
detail = f"Set {WRAPPER_BYPASS_ENV}=1 to run it directly."
|
||||
return f"Error: running `{prog}` directly is disabled in this environment.\n{detail}"
|
||||
|
||||
|
||||
def _enforce_wrapper_guard() -> None:
|
||||
"""Exit early when a naked ``omni``/``omnigent`` call is blocked by an operator."""
|
||||
# argv[0] is the console-script name (``omni``/``omnigent``); ``python -m
|
||||
# omnigent`` reports ``__main__.py``, so fall back to the canonical name.
|
||||
prog = os.path.basename(sys.argv[0])
|
||||
if not prog or prog == "__main__.py":
|
||||
prog = "omnigent"
|
||||
message = _wrapper_guard_error(os.environ, prog)
|
||||
if message is not None:
|
||||
click.echo(message, err=True)
|
||||
raise SystemExit(2)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""
|
||||
Console-script entry point for ``omnigent``.
|
||||
@@ -1961,6 +1999,11 @@ def main() -> None:
|
||||
|
||||
install_crash_handler(app_name="omnigent", repo="omnigent-ai/omnigent")
|
||||
|
||||
# Operators can force all use through a wrapper (e.g. `isaac omni`) by
|
||||
# setting OMNIGENT_REQUIRE_WRAPPER; the wrapper sets OMNIGENT_WRAPPER_BYPASS
|
||||
# to pass through. Refuse naked calls before any work happens.
|
||||
_enforce_wrapper_guard()
|
||||
|
||||
cwd = os.getcwd()
|
||||
if cwd not in sys.path:
|
||||
sys.path.insert(0, cwd)
|
||||
@@ -2185,7 +2228,7 @@ def _runner_loopback_host(host: str) -> str:
|
||||
return "127.0.0.1" if host in {"0.0.0.0", "::", ""} else host
|
||||
|
||||
|
||||
_HOST_PID_PATH = Path.home() / ".omnigent" / "host.pid"
|
||||
_HOST_PID_PATH = data_dir() / "host.pid"
|
||||
|
||||
|
||||
# host.pid records the daemon PID + the "target" it serves: a normalized
|
||||
@@ -2394,6 +2437,7 @@ def _daemon_host_online(record: _HostDaemonRecord, *, timeout_s: float = 2.0) ->
|
||||
method="GET",
|
||||
path=f"/v1/hosts/{url_component(host_id)}",
|
||||
timeout_s=timeout_s,
|
||||
host_id=host_id,
|
||||
)
|
||||
if result.status_code != 200 or not isinstance(result.body, dict):
|
||||
return False
|
||||
@@ -2887,7 +2931,7 @@ def _foreground_daemon_record(
|
||||
started_at=int(time.time()),
|
||||
host_id=host_id,
|
||||
resolved_server_url=server_url.rstrip("/") if mode == "local" else None,
|
||||
config_sig=server_config_signature(),
|
||||
config_sig=server_config_signature(include_features=mode == "local"),
|
||||
)
|
||||
|
||||
|
||||
@@ -2927,11 +2971,13 @@ def _claim_foreground_daemon_record(
|
||||
"""
|
||||
conflict = _live_daemon_conflict(record)
|
||||
if conflict is not None:
|
||||
# server_url is None in local mode; "" makes the hint say --server "".
|
||||
stop_command = _host_stop_command(conflict.server_url or "")
|
||||
raise click.ClickException(
|
||||
"A host daemon is already running for this server "
|
||||
f"(pid={conflict.pid}, target={conflict.target}). "
|
||||
"Run `omnigent host status` to inspect it or "
|
||||
"`omnigent host stop --server ...` to stop it first."
|
||||
f"Run `omnigent host status` to inspect it or `{stop_command}` "
|
||||
"to stop it first."
|
||||
)
|
||||
previous = _find_daemon_record(record.target)
|
||||
if previous is not None and not _pid_alive(previous.pid):
|
||||
@@ -3007,14 +3053,15 @@ def _ensure_host_daemon(server_url: str | None) -> bool:
|
||||
mode_args = ["--local"] if not server_url else ["--server", server_url]
|
||||
args = [sys.executable, "-m", "omnigent.host._daemon_entry", *mode_args]
|
||||
spawned = _spawn_host_daemon_process(
|
||||
args=args, env=_build_host_daemon_env(server_url=server_url)
|
||||
args=args,
|
||||
env=_build_host_daemon_env(server_url=server_url),
|
||||
)
|
||||
if spawned is None:
|
||||
return False
|
||||
_persist_spawned_daemon(
|
||||
target=target,
|
||||
spawned=spawned,
|
||||
config_sig=server_config_signature(),
|
||||
config_sig=server_config_signature(include_features=not server_url),
|
||||
)
|
||||
return decision.config_changed
|
||||
|
||||
@@ -3114,9 +3161,10 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
today. A non-200 answer that carries the Databricks edge signature
|
||||
(302 to the workspace OAuth page, or a DatabricksRealm 401) means
|
||||
the run would otherwise die much later with an opaque "non-JSON
|
||||
response (status=302)" traceback from the session-create call. On a
|
||||
TTY we run the same flow ``omnigent login`` would and continue;
|
||||
headless invocations get the exact command to run instead.
|
||||
response (status=302)" traceback from the session-create call. First,
|
||||
it asks the SDK for a fresh workspace token; only then does a TTY run
|
||||
the same flow ``omnigent login`` would, while headless invocations get
|
||||
the exact command to run instead.
|
||||
|
||||
Non-Databricks postures are deliberately left alone: local accounts
|
||||
servers auto-authenticate downstream (magic-link redeem), and
|
||||
@@ -3136,11 +3184,12 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
import httpx as _httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
from omnigent.cli_auth import load_databricks_org_id, store_databricks_auth
|
||||
|
||||
try:
|
||||
probe = _httpx.get(
|
||||
f"{server}/v1/me",
|
||||
headers=_remote_headers(server_url=server),
|
||||
headers=_remote_headers(server_url=server, host_id=None),
|
||||
timeout=10.0,
|
||||
)
|
||||
except _httpx.HTTPError:
|
||||
@@ -3152,6 +3201,13 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
workspace_host = _databricks_workspace_login_target(server, probe)
|
||||
if workspace_host is None:
|
||||
return
|
||||
org_id = load_databricks_org_id(server)
|
||||
token = _databricks_workspace_token(workspace_host)
|
||||
if token is not None:
|
||||
refreshed_probe = _verify_databricks_server_token(server, token, org_id)
|
||||
if refreshed_probe.status_code == 200:
|
||||
store_databricks_auth(server, workspace_host, org_id=org_id)
|
||||
return
|
||||
login_cmd = f"omnigent login {server}"
|
||||
if non_interactive or not sys.stdin.isatty():
|
||||
raise click.ClickException(
|
||||
@@ -3159,11 +3215,7 @@ def _ensure_databricks_server_auth(server: str, *, non_interactive: bool = False
|
||||
f"HTTP {probe.status_code}). Run `{login_cmd}` and retry."
|
||||
)
|
||||
click.echo(f"Not signed in to {server} — running `{login_cmd}` first.")
|
||||
# Recover the ``?o=`` selector from a prior login record so a re-login
|
||||
# still targets the right workspace.
|
||||
from omnigent.cli_auth import load_databricks_org_id
|
||||
|
||||
_databricks_login(server, workspace_host, org_id=load_databricks_org_id(server))
|
||||
_databricks_login(server, workspace_host, org_id=org_id)
|
||||
|
||||
|
||||
def _ensure_backend(server: str | None) -> str:
|
||||
@@ -3788,6 +3840,10 @@ def server(
|
||||
|
||||
cfg = _load_config(config_path)
|
||||
|
||||
# Let the server-config reader (branding) see the same ``-c`` file.
|
||||
if config_path:
|
||||
os.environ["OMNIGENT_CONFIG"] = str(Path(config_path).resolve())
|
||||
|
||||
# CLI args take precedence over config file, which takes precedence
|
||||
# over defaults.
|
||||
db_uri = database_uri or cfg.get("database_uri", _default_db_uri())
|
||||
@@ -5686,6 +5742,7 @@ def import_session_command(
|
||||
import httpx
|
||||
|
||||
from omnigent.chat import _remote_headers
|
||||
from omnigent.conversation_browser import conversation_url
|
||||
from omnigent.session_import import (
|
||||
ImportSource,
|
||||
SessionImportNotFoundError,
|
||||
@@ -5754,7 +5811,7 @@ def import_session_command(
|
||||
response = httpx.post(
|
||||
f"{base_url}/v1/imports",
|
||||
json=payload,
|
||||
headers=_remote_headers(server_url=base_url),
|
||||
headers=_remote_headers(server_url=base_url, host_id=None),
|
||||
timeout=120.0,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
@@ -5791,13 +5848,17 @@ def import_session_command(
|
||||
)
|
||||
continue
|
||||
imported_count += 1
|
||||
# Surface the browser URL, not the bare id, so the user can open the
|
||||
# imported session straight into the web (where it offers the resume
|
||||
# picker). Maps a Databricks API base to its workspace SPA link.
|
||||
session_link = conversation_url(base_url, session_id)
|
||||
if is_batch:
|
||||
click.echo(
|
||||
f"Imported {item_count} item(s) from {current_source_session_id} "
|
||||
f"into {session_id}."
|
||||
f"into {session_link}"
|
||||
)
|
||||
else:
|
||||
click.echo(f"Imported {item_count} item(s) into {session_id}.")
|
||||
click.echo(f"Imported {item_count} item(s) into {session_link}")
|
||||
|
||||
if is_batch:
|
||||
click.echo(f"\nImported: {imported_count}")
|
||||
@@ -5952,7 +6013,7 @@ def usage(limit: int, server: str | None, as_json: bool) -> None:
|
||||
|
||||
with httpx.Client(
|
||||
base_url=base_url,
|
||||
headers=_remote_headers(server_url=base_url),
|
||||
headers=_remote_headers(server_url=base_url, host_id=None),
|
||||
timeout=60.0,
|
||||
trust_env=_trust_env_for(base_url),
|
||||
) as client:
|
||||
@@ -6036,7 +6097,7 @@ def session_export(session_id: str, output: str | None, server: str | None) -> N
|
||||
|
||||
with httpx.Client(
|
||||
base_url=base_url,
|
||||
headers=_remote_headers(server_url=base_url),
|
||||
headers=_remote_headers(server_url=base_url, host_id=None),
|
||||
timeout=30.0,
|
||||
trust_env=_trust_env_for(base_url),
|
||||
) as client:
|
||||
@@ -6278,7 +6339,7 @@ def session_import(input_path: str, title: str | None, server: str | None) -> No
|
||||
|
||||
with httpx.Client(
|
||||
base_url=base_url,
|
||||
headers=_remote_headers(server_url=base_url),
|
||||
headers=_remote_headers(server_url=base_url, host_id=None),
|
||||
timeout=120.0,
|
||||
trust_env=_trust_env_for(base_url),
|
||||
) as client:
|
||||
@@ -6808,7 +6869,7 @@ def _dispatch_native_terminal_harness(
|
||||
session_id = _resolve_latest_conversation_id(
|
||||
base_url=server,
|
||||
agent_name=native_agent.agent_name,
|
||||
headers=_remote_headers(server_url=server),
|
||||
headers=_remote_headers(server_url=server, host_id=None),
|
||||
)
|
||||
# The user explicitly asked to continue; if there's nothing to continue,
|
||||
# fail loud rather than silently starting fresh (matches the REPL's
|
||||
@@ -7823,6 +7884,9 @@ def _prompt_stop_local_server() -> None:
|
||||
# server URL, missing credentials) leaves nothing on the terminal, so we wait
|
||||
# this long and surface its log instead of falsely reporting success.
|
||||
_BACKGROUND_HOST_GRACE_S = 2.0
|
||||
# A detached process isn't ready merely because its PID survived. Wait until
|
||||
# the server confirms the host row and live tunnel are online.
|
||||
_BACKGROUND_HOST_REGISTRATION_GRACE_S = 30.0
|
||||
|
||||
|
||||
def _confirm_background_host_alive(record: _HostDaemonRecord) -> None:
|
||||
@@ -7835,18 +7899,50 @@ def _confirm_background_host_alive(record: _HostDaemonRecord) -> None:
|
||||
deadline = time.time() + _BACKGROUND_HOST_GRACE_S
|
||||
while True:
|
||||
if not _pid_alive(record.pid):
|
||||
from omnigent._runner_startup import format_runner_log_tail
|
||||
|
||||
log_path = Path(record.log_path) if record.log_path else None
|
||||
raise click.ClickException(
|
||||
"The host daemon exited immediately after starting."
|
||||
f"{format_runner_log_tail(log_path)}"
|
||||
f"{_background_host_log_detail(record.log_path)}"
|
||||
)
|
||||
if time.time() >= deadline:
|
||||
return
|
||||
time.sleep(0.1)
|
||||
|
||||
|
||||
def _background_host_log_detail(log_path: str | None) -> str:
|
||||
"""Return the host log path and a short failure tail."""
|
||||
if log_path is None:
|
||||
return ""
|
||||
path = Path(log_path)
|
||||
detail = f"\nHost log: {path}"
|
||||
try:
|
||||
tail = path.read_bytes()[-4096:].decode("utf-8", errors="replace").strip()
|
||||
except OSError:
|
||||
return detail
|
||||
if tail:
|
||||
detail += "\n--- host log tail ---\n" + "\n".join(tail.splitlines()[-12:])
|
||||
return detail
|
||||
|
||||
|
||||
def _confirm_background_host_registered(record: _HostDaemonRecord) -> None:
|
||||
"""Wait until the detached daemon completes server registration."""
|
||||
deadline = time.monotonic() + _BACKGROUND_HOST_REGISTRATION_GRACE_S
|
||||
while True:
|
||||
if not _pid_alive(record.pid):
|
||||
raise click.ClickException(
|
||||
"The host daemon exited before registering with the server."
|
||||
f"{_background_host_log_detail(record.log_path)}"
|
||||
)
|
||||
if _daemon_host_online(record, timeout_s=1.0):
|
||||
return
|
||||
if time.monotonic() >= deadline:
|
||||
raise click.ClickException(
|
||||
"The host daemon started but did not register with the server "
|
||||
f"within {_BACKGROUND_HOST_REGISTRATION_GRACE_S:.0f}s."
|
||||
f"{_background_host_log_detail(record.log_path)}"
|
||||
)
|
||||
time.sleep(0.2)
|
||||
|
||||
|
||||
def _run_background_host(
|
||||
server: str | None,
|
||||
*,
|
||||
@@ -7874,7 +7970,7 @@ def _run_background_host(
|
||||
:param non_interactive: When ``True``, never launch the browser login —
|
||||
fail with the ``omnigent login`` hint instead.
|
||||
:raises click.ClickException: If the daemon cannot be spawned, exits
|
||||
immediately after starting, or (local mode) never serves its local
|
||||
immediately, fails to register, or (local mode) never serves its local
|
||||
Omnigent server.
|
||||
"""
|
||||
if server:
|
||||
@@ -7884,30 +7980,40 @@ def _run_background_host(
|
||||
_ensure_host_daemon(server or None)
|
||||
record = _find_daemon_record(target)
|
||||
if record is None:
|
||||
# No record for this target: either the live local-mode daemon already
|
||||
# serves the requested URL, or the spawn itself failed.
|
||||
# A local daemon may already own the requested URL under its local
|
||||
# registry key. It is reusable only after its host is online too.
|
||||
if _local_daemon_serves_target(target, server or None):
|
||||
click.echo(f"The local host daemon already serves {target}.")
|
||||
return
|
||||
local_record = _find_daemon_record(_LOCAL_DAEMON_MARKER)
|
||||
if local_record is not None:
|
||||
_confirm_background_host_registered(local_record)
|
||||
click.echo(f"The local host daemon already serves {target}.")
|
||||
return
|
||||
raise click.ClickException(
|
||||
"Could not spawn the background host daemon. See ~/.omnigent/logs/host/ for details."
|
||||
)
|
||||
if previous is not None and previous.pid == record.pid:
|
||||
headline = _cli_style("Host daemon already running", fg="yellow", bold=True)
|
||||
else:
|
||||
_confirm_background_host_alive(record)
|
||||
headline = _cli_style("Started the host daemon in the background", fg="green", bold=True)
|
||||
reused = previous is not None and previous.pid == record.pid
|
||||
try:
|
||||
if not reused:
|
||||
_confirm_background_host_alive(record)
|
||||
if record.mode == "local":
|
||||
# The status probe needs the daemon-owned server's loopback URL.
|
||||
server_url = _discover_local_server_url()
|
||||
_update_daemon_resolved_server_url(target, server_url)
|
||||
record = _find_daemon_record(target) or record
|
||||
else:
|
||||
server_url = target
|
||||
_confirm_background_host_registered(record)
|
||||
except click.ClickException:
|
||||
if not reused:
|
||||
with contextlib.suppress(click.ClickException):
|
||||
_terminate_daemon(record, force=True)
|
||||
raise
|
||||
headline = _cli_style(
|
||||
"Host daemon already running" if reused else "Started the host daemon in the background",
|
||||
fg="yellow" if reused else "green",
|
||||
bold=True,
|
||||
)
|
||||
click.echo(f"{headline} (pid {record.pid}).")
|
||||
if record.mode == "local":
|
||||
# A local-mode daemon owns the local Omnigent server, so this command is
|
||||
# the whole "start everything" step — wait for that server and report
|
||||
# its URL, otherwise the Web UI is unreachable without a follow-up
|
||||
# `omnigent server status`. Resolved after the headline above so a cold
|
||||
# start isn't a silent terminal.
|
||||
server_url = _discover_local_server_url()
|
||||
_update_daemon_resolved_server_url(target, server_url)
|
||||
else:
|
||||
server_url = target
|
||||
_echo_host_field("server", _cli_style(server_url, fg="cyan"))
|
||||
if record.log_path is not None:
|
||||
_echo_host_field("log", _display_path(Path(record.log_path)))
|
||||
@@ -8162,7 +8268,7 @@ def _selected_daemon_records(
|
||||
# Databricks CLI). Within a single CLI invocation the token is valid, so
|
||||
# resolving once and reusing it is safe. The lock serialises concurrent
|
||||
# resolution for the same URL (two threads must not both pay the cost).
|
||||
_host_http_headers_cache: dict[str, dict[str, str]] = {}
|
||||
_host_http_headers_cache: dict[tuple[str, str | None], dict[str, str]] = {}
|
||||
_host_http_headers_lock = threading.Lock()
|
||||
|
||||
|
||||
@@ -8189,6 +8295,7 @@ def _host_http_json(
|
||||
params: dict[str, str | int] | None = None,
|
||||
json_body: _HostJsonObject | None = None,
|
||||
timeout_s: float = 10.0,
|
||||
host_id: str | None = None,
|
||||
) -> _HostHttpResult:
|
||||
"""
|
||||
Send one management request to an Omnigent server.
|
||||
@@ -8203,6 +8310,10 @@ def _host_http_json(
|
||||
``{"type": "stop_session", "data": {}}``.
|
||||
:param timeout_s: Request timeout in seconds, e.g. ``2.0`` for a
|
||||
quick liveness probe. Defaults to ``10.0`` for management calls.
|
||||
:param host_id: The host this request is scoped to (host-control, or a
|
||||
host-backed session event like stop_session), so it reaches the replica
|
||||
holding that host's tunnel. ``None`` for non-host-scoped calls; the
|
||||
builder emits the routing header only on the workspace-hosted server.
|
||||
:returns: Decoded HTTP result.
|
||||
"""
|
||||
import httpx
|
||||
@@ -8210,11 +8321,17 @@ def _host_http_json(
|
||||
from omnigent.chat import _remote_headers
|
||||
|
||||
try:
|
||||
if base_url not in _host_http_headers_cache:
|
||||
# Cache the resolved headers per (base_url, host_id): the auth resolution
|
||||
# is the expensive part (token mint / CLI shell-out), and the slice-key
|
||||
# varies by the host a call is scoped to, so both belong in the key.
|
||||
cache_key = (base_url, host_id)
|
||||
if cache_key not in _host_http_headers_cache:
|
||||
with _host_http_headers_lock:
|
||||
if base_url not in _host_http_headers_cache:
|
||||
_host_http_headers_cache[base_url] = _remote_headers(server_url=base_url)
|
||||
headers = _host_http_headers_cache[base_url]
|
||||
if cache_key not in _host_http_headers_cache:
|
||||
_host_http_headers_cache[cache_key] = _remote_headers(
|
||||
server_url=base_url, host_id=host_id
|
||||
)
|
||||
headers = _host_http_headers_cache[cache_key]
|
||||
with httpx.Client(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
@@ -8429,12 +8546,22 @@ def _runner_online_map(
|
||||
if isinstance((runner_id := session.get("runner_id")), str) and runner_id
|
||||
}
|
||||
)
|
||||
# A runner is spawned on exactly one host; its status endpoint reads the
|
||||
# in-memory tunnel registry, so the check must reach that host's replica or
|
||||
# it reports the runner offline. The session rows carry each runner's host.
|
||||
runner_host: dict[str, str] = {}
|
||||
for session in sessions:
|
||||
rid = session.get("runner_id")
|
||||
host = session.get("host_id")
|
||||
if isinstance(rid, str) and rid and isinstance(host, str) and host:
|
||||
runner_host.setdefault(rid, host)
|
||||
statuses: dict[str, bool | None] = {}
|
||||
for runner_id in runner_ids:
|
||||
result = _host_http_json(
|
||||
base_url=base_url,
|
||||
method="GET",
|
||||
path=f"/v1/runners/{url_component(runner_id)}/status",
|
||||
host_id=runner_host.get(runner_id),
|
||||
)
|
||||
if result.status_code == 200 and isinstance(result.body, dict):
|
||||
online = result.body.get("online")
|
||||
@@ -8515,6 +8642,7 @@ def _add_daemon_host_status(
|
||||
base_url=base_url,
|
||||
method="GET",
|
||||
path=f"/v1/hosts/{url_component(host_id)}",
|
||||
host_id=host_id,
|
||||
)
|
||||
if host_result.status_code == 200 and isinstance(host_result.body, dict):
|
||||
status = host_result.body.get("status")
|
||||
@@ -8995,11 +9123,27 @@ def _stop_session_on_server(
|
||||
"""
|
||||
from omnigent.claude_native_bridge import url_component
|
||||
|
||||
# This is a standalone CLI process with an empty session→host map, so read
|
||||
# the session's host from its record first: the stop_session event is a
|
||||
# server→runner forward and must reach the replica holding the runner's
|
||||
# tunnel. The metadata GET itself is host-agnostic (served from any replica).
|
||||
host_id: str | None = None
|
||||
info = _host_http_json(
|
||||
base_url=base_url,
|
||||
method="GET",
|
||||
path=f"/v1/sessions/{url_component(session_id)}",
|
||||
)
|
||||
if info.status_code == 200 and isinstance(info.body, dict):
|
||||
host_value = info.body.get("host_id")
|
||||
if isinstance(host_value, str) and host_value:
|
||||
host_id = host_value
|
||||
|
||||
result = _host_http_json(
|
||||
base_url=base_url,
|
||||
method="POST",
|
||||
path=f"/v1/sessions/{url_component(session_id)}/events",
|
||||
json_body={"type": "stop_session", "data": {}},
|
||||
host_id=host_id,
|
||||
)
|
||||
if result.status_code == 0:
|
||||
raise click.ClickException(
|
||||
@@ -10828,7 +10972,7 @@ def _databricks_workspace_token(workspace_host: str) -> str | None:
|
||||
try:
|
||||
auth, _host = _resolve_databricks_auth(host=workspace_host)
|
||||
return auth.current_token()
|
||||
except (DatabricksAuthError, ValueError):
|
||||
except (DatabricksAuthError, ImportError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
|
||||
+169
-4
@@ -25,7 +25,12 @@ import os
|
||||
import stat
|
||||
import tempfile
|
||||
import time
|
||||
import urllib.parse
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import httpx
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
_TOKEN_FILE_NAME = "auth_tokens.json"
|
||||
@@ -34,9 +39,10 @@ _TOKEN_FILE_NAME = "auth_tokens.json"
|
||||
def _token_file_path() -> Path:
|
||||
"""Return the path to the auth token storage file.
|
||||
|
||||
Uses the shared ``~/.omnigent`` state directory.
|
||||
Uses the shared Omnigent state directory, honoring
|
||||
``OMNIGENT_DATA_DIR``.
|
||||
|
||||
:returns: Path to ``~/.omnigent/auth_tokens.json``.
|
||||
:returns: Path to ``<data-dir>/auth_tokens.json``.
|
||||
"""
|
||||
from omnigent_ui_sdk.terminal._config import state_dir
|
||||
|
||||
@@ -277,6 +283,34 @@ def load_databricks_org_id(server_url: str) -> str | None:
|
||||
# workspace request by this header (equivalently to the ``?o=`` query param).
|
||||
DATABRICKS_ORG_ID_HEADER = "X-Databricks-Org-Id"
|
||||
|
||||
# Replica-routing header for a host-sharded deployment. The sharding layer
|
||||
# routes requests by this value — else the default fallback — so a host's
|
||||
# control tunnel, its runners' tunnels, and their session traffic all land on
|
||||
# one replica when they carry the same key (the host_id). Omitted for an
|
||||
# unsharded / single-replica deployment, which needs no sticky routing.
|
||||
OMNIGENT_SLICE_KEY_HEADER = "X-Databricks-Omnigent-Slice-Key"
|
||||
|
||||
# A host-sharded deployment mounts the API at this path; an unsharded /
|
||||
# single-replica server mounts elsewhere (usually the root). This is the
|
||||
# routing-relevant shape of a server URL, so it lives here next to the
|
||||
# request-header builder that keys off it (rather than in the browser-link
|
||||
# helpers, which only borrow it).
|
||||
WORKSPACE_API_PATH = "/api/2.0/omnigent"
|
||||
|
||||
|
||||
def is_workspace_hosted_url(base_url: str) -> bool:
|
||||
"""Whether *base_url* is a host-sharded deployment mount.
|
||||
|
||||
True for the host-sharded mount (``https://<host>/api/2.0/omnigent``), which
|
||||
is the only deployment fronted by the sharding layer. Used to gate behavior
|
||||
that only applies there (see :func:`databricks_request_headers`).
|
||||
|
||||
:param base_url: Omnigent server base URL, e.g.
|
||||
``"https://example.databricks.com/api/2.0/omnigent"``.
|
||||
:returns: ``True`` when the URL path is the workspace API mount.
|
||||
"""
|
||||
return urllib.parse.urlsplit(base_url.rstrip("/")).path == WORKSPACE_API_PATH
|
||||
|
||||
|
||||
# Opaque extra request headers for dev/test: a JSON object of header name→value
|
||||
# in :data:`DATABRICKS_EXTRA_HEADERS_ENV_VAR`. Databricks deployments use it to
|
||||
@@ -310,7 +344,10 @@ def _databricks_extra_headers() -> dict[str, str]:
|
||||
|
||||
|
||||
def databricks_request_headers(
|
||||
server_url: str, *, bearer_token: str | None = None
|
||||
server_url: str,
|
||||
*,
|
||||
bearer_token: str | None = None,
|
||||
host_id: str | None = None,
|
||||
) -> dict[str, str]:
|
||||
"""Build the headers for a request to a Databricks-fronted server.
|
||||
|
||||
@@ -335,8 +372,17 @@ def databricks_request_headers(
|
||||
``"https://example.databricks.com/api/2.0/omnigent"``.
|
||||
:param bearer_token: The workspace bearer token, or ``None`` when the
|
||||
credential is supplied by a separate mechanism (or there is none).
|
||||
:param host_id: The host a request is scoped to (its control tunnel, its
|
||||
runners, and their session traffic all name it so they co-locate on one
|
||||
replica). Pass it unconditionally: it is emitted (as the
|
||||
:data:`OMNIGENT_SLICE_KEY_HEADER` routing header) only when *server_url*
|
||||
is a host-sharded mount, since that is the only deployment with the
|
||||
sharding layer that reads it. ``None`` defaults to the runner's own
|
||||
host_id inside a runner process (via ``OMNIGENT_RUNNER_SLICE_KEY``) and
|
||||
otherwise leaves routing to the default.
|
||||
:returns: A header dict carrying ``Authorization``, ``X-Databricks-Org-Id``,
|
||||
and/or the configured extra headers as available, possibly empty.
|
||||
``X-Databricks-Omnigent-Slice-Key``, and/or the configured extra headers
|
||||
as available, possibly empty.
|
||||
"""
|
||||
headers: dict[str, str] = {}
|
||||
if bearer_token:
|
||||
@@ -344,12 +390,131 @@ def databricks_request_headers(
|
||||
org_id = load_databricks_org_id(server_url)
|
||||
if org_id:
|
||||
headers[DATABRICKS_ORG_ID_HEADER] = org_id
|
||||
# Resolve the slice-key host_id when the caller names none, so every
|
||||
# request still carries a key (routed by the sharding layer rather than
|
||||
# depending on its default). Two ordered fallbacks, both host_ids:
|
||||
# 1. In a runner process, the runner's own host_id (exported at launch as
|
||||
# OMNIGENT_RUNNER_SLICE_KEY) — keys the runner's server traffic
|
||||
# (transcript posts, uploads, policy checks) onto its host's replica,
|
||||
# co-located with its tunnel.
|
||||
# 2. Otherwise, on the CLI, this machine's OWN host_id if it already has a
|
||||
# host identity — a host-less CLI request (session list, /me-adjacent
|
||||
# reads, export) then keys to the replica holding this CLI's own hosts'
|
||||
# tunnels. Read-only (never mints an identity), so a non-host machine
|
||||
# stays unkeyed (→ default). Gated on the host-sharded mount below so
|
||||
# the file read only happens for requests that could use it.
|
||||
# Only a host-sharded deployment runs the sharding layer that reads the
|
||||
# header; an unsharded server is single-replica and would just log a header
|
||||
# it ignores — so gate emission (and the CLI identity lookup) on the mount.
|
||||
# Callers never reason about the deployment; a new RPC routed through this
|
||||
# builder is keyed automatically.
|
||||
on_workspace_mount = is_workspace_hosted_url(server_url)
|
||||
if host_id is None:
|
||||
from omnigent.runner.identity import RUNNER_SLICE_KEY_ENV_VAR
|
||||
|
||||
host_id = os.environ.get(RUNNER_SLICE_KEY_ENV_VAR)
|
||||
if host_id is None and on_workspace_mount:
|
||||
from omnigent.host.identity import load_host_identity_if_present
|
||||
|
||||
identity = load_host_identity_if_present()
|
||||
if identity is not None:
|
||||
host_id = identity.host_id
|
||||
# Kill switch: slice-key emission is ON by default; export
|
||||
# ``OMNIGENT_HOST_SLICE_KEY_ENABLED=0`` to turn it off and fall back to the
|
||||
# server's default (workspace-id) routing with no redeploy — a per-process
|
||||
# escape hatch for a bad rollout, since this emits from sidecar-less
|
||||
# processes (laptop CLI, managed sandbox host, spawned runner) that can't
|
||||
# evaluate a server-side flag. Only the exact value "0" disables it; unset,
|
||||
# "1", or anything else leaves emission on.
|
||||
slice_key_enabled = os.environ.get("OMNIGENT_HOST_SLICE_KEY_ENABLED", "1") != "0"
|
||||
if host_id and on_workspace_mount and slice_key_enabled:
|
||||
headers[OMNIGENT_SLICE_KEY_HEADER] = host_id
|
||||
# Opaque dev/test extra headers (request-routing selectors); no-op in prod
|
||||
# (env unset).
|
||||
headers.update(_databricks_extra_headers())
|
||||
return headers
|
||||
|
||||
|
||||
# Sentinel for the ``timeout`` argument of :func:`open_server_client`. ``None``
|
||||
# is a meaningful httpx value ("disable timeout"), so it can't stand in for
|
||||
# "unset". When the caller passes nothing we omit ``timeout`` entirely and let
|
||||
# httpx apply its own default, rather than silently changing it.
|
||||
_TIMEOUT_UNSET: Any = object()
|
||||
|
||||
|
||||
def open_server_client(
|
||||
server_url: str,
|
||||
*,
|
||||
auth: httpx.Auth | None = None,
|
||||
bearer_token: str | None = None,
|
||||
headers: dict[str, str] | None = None,
|
||||
timeout: Any = _TIMEOUT_UNSET,
|
||||
follow_redirects: bool = False,
|
||||
transport: httpx.AsyncBaseTransport | None = None,
|
||||
host_id: str | None = None,
|
||||
) -> httpx.AsyncClient:
|
||||
"""Open an :class:`httpx.AsyncClient` to an Omnigent server, keyed for routing.
|
||||
|
||||
The one way to open a client to the server. It folds
|
||||
:func:`databricks_request_headers` in for you, so a request to a host-sharded
|
||||
mount automatically carries the org-id and slice-key routing headers (and any
|
||||
dev/test selectors) — and a request to an unsharded server carries none.
|
||||
Callers never reason about the deployment; a new server RPC opened through
|
||||
this factory is routed correctly by construction, which is why it exists
|
||||
rather than each site building headers by hand.
|
||||
|
||||
:param server_url: The server base URL, e.g.
|
||||
``"https://example.databricks.com/api/2.0/omnigent"``. Both the client's
|
||||
``base_url`` and the input to the routing-header builder.
|
||||
:param auth: An httpx ``Auth`` when the credential is minted per request
|
||||
(e.g. :class:`_RunnerDatabricksAuth`, which re-injects a fresh bearer and
|
||||
the routing headers on the OAuth-redirect retry). Mutually exclusive with
|
||||
*bearer_token* in practice: pass one or the other, not both.
|
||||
:param bearer_token: A static workspace bearer, folded in as
|
||||
``Authorization``. Leave ``None`` when *auth* supplies the credential or
|
||||
there is none.
|
||||
:param headers: Extra request headers (e.g. the runner ``Origin`` sentinel).
|
||||
Merged *under* the routing headers, so routing always wins over a
|
||||
caller-supplied collision.
|
||||
:param timeout: An httpx timeout (``httpx.Timeout``, ``float``, or ``None``
|
||||
to disable). Omitted by default so httpx applies its own default rather
|
||||
than this factory silently overriding it.
|
||||
:param follow_redirects: Passed through to httpx. Defaults to ``False``
|
||||
(httpx's own default); callers relying on seeing a 3xx — e.g. an auth
|
||||
flow that re-mints on the Databricks Apps OAuth login redirect — keep it
|
||||
``False``.
|
||||
:param transport: An httpx ``AsyncBaseTransport`` to substitute for the
|
||||
default network transport. Its one production-adjacent use is injecting a
|
||||
test transport (e.g. ``httpx.MockTransport``); ``None`` uses the default.
|
||||
:param host_id: The host a request is scoped to, forwarded to
|
||||
:func:`databricks_request_headers` (which emits it as the slice-key only
|
||||
on a host-sharded mount and otherwise falls back to the runner's own
|
||||
host_id). Pass it unconditionally when known.
|
||||
:returns: A configured :class:`httpx.AsyncClient`.
|
||||
"""
|
||||
import httpx
|
||||
from omnigent_client._http import is_loopback_url
|
||||
|
||||
pinned = {
|
||||
**(headers or {}),
|
||||
**databricks_request_headers(server_url, bearer_token=bearer_token, host_id=host_id),
|
||||
}
|
||||
kwargs: dict[str, Any] = {}
|
||||
if timeout is not _TIMEOUT_UNSET:
|
||||
kwargs["timeout"] = timeout
|
||||
if transport is not None:
|
||||
kwargs["transport"] = transport
|
||||
return httpx.AsyncClient(
|
||||
base_url=server_url,
|
||||
headers=pinned,
|
||||
auth=auth,
|
||||
follow_redirects=follow_redirects,
|
||||
# A proxy cannot reach a loopback server, so local targets bypass it.
|
||||
trust_env=not is_loopback_url(server_url),
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
|
||||
def clear_token(server_url: str) -> None:
|
||||
"""Remove a stored token for a server.
|
||||
|
||||
|
||||
@@ -3794,8 +3794,19 @@ def _run_configure_harnesses_interactive() -> None:
|
||||
# discoverable than a user's own `acp:` entry.
|
||||
from omnigent._platform import resolve_cli_binary
|
||||
from omnigent.acp_cli_harnesses import ACP_CLI_HARNESSES
|
||||
from omnigent.onboarding.acp_auth import acp_agents, shadowed_builtin_acp_rows
|
||||
|
||||
# Skip a row a configured `acp:` agent already claims, so the list shows
|
||||
# one "Devin" (the user's, with its command) rather than two identically
|
||||
# labeled rows. A config error is reported by the custom-ACP block below.
|
||||
try:
|
||||
_shadowed_acp_rows: frozenset[str] = shadowed_builtin_acp_rows(acp_agents(config))
|
||||
except ValueError:
|
||||
_shadowed_acp_rows = frozenset()
|
||||
|
||||
for _acp_cli_name, _acp_cli_row in sorted(ACP_CLI_HARNESSES.items()):
|
||||
if _acp_cli_name in _shadowed_acp_rows:
|
||||
continue
|
||||
_acp_cli_key = _ACP_CLI_PREFIX + _acp_cli_name
|
||||
if resolve_cli_binary(_acp_cli_row.binary) is None:
|
||||
rows.append(
|
||||
|
||||
+24
-26
@@ -22,7 +22,6 @@ from tempfile import TemporaryDirectory
|
||||
import click
|
||||
import httpx
|
||||
import yaml
|
||||
from omnigent_client._http import is_loopback_url
|
||||
|
||||
from omnigent._native_resume_hint import echo_native_resume_hint
|
||||
from omnigent._runner_startup import RunnerStartupProgress, runner_startup_progress
|
||||
@@ -72,6 +71,7 @@ from omnigent.harness_availability import (
|
||||
from omnigent.host.daemon_launch import (
|
||||
error_text,
|
||||
launch_or_reuse_daemon_runner,
|
||||
open_daemon_client,
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
@@ -713,8 +713,12 @@ def _run_with_remote_server(
|
||||
from omnigent.cli import _ensure_host_daemon
|
||||
from omnigent.host.identity import load_or_create_host_identity
|
||||
|
||||
headers = _remote_headers(server_url=base_url)
|
||||
attach_auth = _server_auth(server_url=base_url)
|
||||
# This machine's host id keys the WebSocket attach handshake (and its
|
||||
# reconnects) to the replica holding the runner's tunnel; the CLI can set WS
|
||||
# headers, so it rides the header (emitted only on a host-sharded deployment).
|
||||
host_id = load_or_create_host_identity().host_id
|
||||
headers = _remote_headers(server_url=base_url, host_id=host_id)
|
||||
attach_auth = _server_auth(server_url=base_url, session_id=None)
|
||||
try:
|
||||
resolved_session_id = _resolve_session_id_for_resume(
|
||||
base_url=base_url,
|
||||
@@ -736,7 +740,6 @@ def _run_with_remote_server(
|
||||
with runner_startup_progress(initial_message="Preparing Codex...") as progress:
|
||||
_update_startup_progress(progress, "Connecting to local daemon...")
|
||||
_ensure_host_daemon(base_url)
|
||||
host_id = load_or_create_host_identity().host_id
|
||||
bundle = None if resolved_session_id is not None else _bundle_agent(spec_path)
|
||||
prepared = await _prepare_codex_terminal_via_daemon(
|
||||
base_url=base_url,
|
||||
@@ -765,7 +768,7 @@ def _run_with_remote_server(
|
||||
|
||||
:returns: None.
|
||||
"""
|
||||
new_headers = _remote_headers(server_url=base_url)
|
||||
new_headers = _remote_headers(server_url=base_url, host_id=host_id)
|
||||
headers.clear()
|
||||
headers.update(new_headers)
|
||||
|
||||
@@ -843,12 +846,7 @@ async def _prepare_codex_terminal_via_daemon(
|
||||
"""
|
||||
persist_args = list(codex_args)
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
) as client:
|
||||
async with open_daemon_client(base_url, headers, host_id, timeout=timeout) as client:
|
||||
reattached = session_id is not None
|
||||
fresh_session = session_id is None
|
||||
if session_id is None:
|
||||
@@ -1021,9 +1019,10 @@ async def _post_initial_prompt(
|
||||
:returns: None.
|
||||
:raises click.ClickException: If Omnigent rejects the prompt.
|
||||
"""
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(
|
||||
base_url,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
timeout=httpx.Timeout(30.0),
|
||||
@@ -1072,12 +1071,9 @@ async def _prepare_codex_terminal(
|
||||
:returns: Prepared terminal details.
|
||||
"""
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
) as client:
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(base_url, headers=headers, timeout=timeout) as client:
|
||||
bridge_id: str
|
||||
thread_id: str | None = None
|
||||
if session_id is None:
|
||||
@@ -1406,9 +1402,10 @@ async def _initialize_fresh_terminal_thread(
|
||||
raise click.ClickException("Codex event listener was not initialized.")
|
||||
app_server_url = _require_codex_app_server_url(prepared)
|
||||
thread_id = await _wait_for_thread_started(prepared.event_client)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(
|
||||
base_url,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(30.0),
|
||||
) as client:
|
||||
@@ -2714,10 +2711,11 @@ async def _close_codex_terminal(
|
||||
:param terminal_id: Terminal resource id.
|
||||
:returns: None.
|
||||
"""
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
async with open_server_client(
|
||||
base_url,
|
||||
headers=headers,
|
||||
timeout=httpx.Timeout(10.0),
|
||||
) as client:
|
||||
|
||||
@@ -1722,6 +1722,73 @@ def _trust_codex_project(codex_home: Path, cwd: Path) -> None:
|
||||
config_path.write_text(tomlkit.dumps(document), encoding="utf-8")
|
||||
|
||||
|
||||
# DATABRICKS-PATCH(codex-live-model-discovery)
|
||||
def _resolve_databricks_codex_model(host: str, profile: str, requested: str | None) -> str:
|
||||
"""Resolve the codex launch model against what the workspace serves.
|
||||
|
||||
Codex used to take its model from the bundled MLflow catalog — a
|
||||
third-party listing whose Databricks ids carry the legacy
|
||||
``databricks-`` spelling the gateway now answers with ``501
|
||||
NOT_IMPLEMENTED ... Use Unity Catalog model services (v3)`` — so a launch
|
||||
could pin a model the workspace will not serve. Resolve from the workspace
|
||||
instead, as claude-native already does: the live Unity Catalog listing,
|
||||
then ucode's cached copy of it, then the bundled catalog as the documented
|
||||
last resort.
|
||||
|
||||
An explicit model is matched against the servable ids, so a legacy
|
||||
``model_override`` persisted before this change still launches; one the
|
||||
workspace does not serve passes through untouched, because the gateway's
|
||||
error beats a silent substitution.
|
||||
|
||||
:param host: Workspace origin, e.g. ``"https://example.com"``.
|
||||
:param profile: Databricks CLI profile backing the launch.
|
||||
:param requested: Explicit model id, or ``None`` to take the newest
|
||||
servable one.
|
||||
:returns: The model id to pin on the codex launch.
|
||||
"""
|
||||
from omnigent.databricks_model_discovery import (
|
||||
discover_databricks_codex_models,
|
||||
select_servable_model,
|
||||
)
|
||||
|
||||
servable: tuple[str, ...] = ()
|
||||
try:
|
||||
from omnigent.runtime.credentials.databricks import resolve_databricks_workspace
|
||||
|
||||
creds = resolve_databricks_workspace(profile)
|
||||
# Discover against the host the launch actually posts to. This resolver
|
||||
# honors ``DATABRICKS_HOST`` while the launch host comes from the
|
||||
# profile section alone (``_databricks_gateway_host``), so using
|
||||
# ``creds.host`` here can pin a model discovered on workspace A onto a
|
||||
# launch targeting workspace B. A token that does not match ``host``
|
||||
# simply fails the listing and drops to the ucode-state fallback below,
|
||||
# which is already keyed by ``host``.
|
||||
servable = discover_databricks_codex_models(host, creds.token)
|
||||
except Exception: # noqa: BLE001 — cached ucode state is the launch fallback
|
||||
_logger.warning(
|
||||
"native-codex: live Databricks model discovery failed for profile %r; "
|
||||
"falling back to ucode state",
|
||||
profile,
|
||||
exc_info=True,
|
||||
)
|
||||
try:
|
||||
from omnigent.onboarding.ucode_state import read_ucode_state
|
||||
|
||||
workspace_state = read_ucode_state(host)
|
||||
if workspace_state is not None:
|
||||
servable = tuple(workspace_state.codex_models)
|
||||
except Exception: # noqa: BLE001 — the bundled catalog is the last resort
|
||||
_logger.warning(
|
||||
"native-codex: could not read ucode state for %r", profile, exc_info=True
|
||||
)
|
||||
|
||||
if requested:
|
||||
return select_servable_model(requested, servable) or requested
|
||||
if servable:
|
||||
return servable[0]
|
||||
return model_catalog.resolve_catalog_model("databricks", family="openai").model_id
|
||||
|
||||
|
||||
def build_codex_native_server(
|
||||
*,
|
||||
socket_path: Path,
|
||||
@@ -1803,8 +1870,7 @@ def build_codex_native_server(
|
||||
host = host.rstrip("/")
|
||||
config_overrides.extend(
|
||||
_databricks_codex_config_overrides(
|
||||
model=model
|
||||
or model_catalog.resolve_catalog_model("databricks", family="openai").model_id,
|
||||
model=_resolve_databricks_codex_model(host, profile, model),
|
||||
base_url=_databricks_codex_base_url(host),
|
||||
auth_command=_databricks_codex_auth_command(host, profile),
|
||||
)
|
||||
@@ -2283,8 +2349,9 @@ def resolve_native_codex_launch(
|
||||
config (issue #2744 — parity with the in-process codex harness).
|
||||
:returns: The resolved :class:`NativeCodexLaunch`.
|
||||
"""
|
||||
from omnigent.onboarding.ambient import codex_config_detection
|
||||
from omnigent.onboarding.detected import (
|
||||
codex_config_provider_dismissed,
|
||||
dismissed_detection_names,
|
||||
effective_config_with_detected,
|
||||
)
|
||||
from omnigent.onboarding.provider_config import (
|
||||
@@ -2296,14 +2363,17 @@ def resolve_native_codex_launch(
|
||||
from omnigent.spec.types import DatabricksAuth
|
||||
|
||||
explicit = load_config()
|
||||
config_detection = codex_config_detection()
|
||||
config_provider_dismissed = (
|
||||
config_detection is not None
|
||||
and config_detection.name in dismissed_detection_names(explicit)
|
||||
)
|
||||
# When the launch ends up on codex's own login with NO provider routing,
|
||||
# the bridged config.toml's custom default model_provider would still
|
||||
# apply — including one the user explicitly Removed (dismissed). Pin
|
||||
# codex's built-in provider in that case so the dismissal holds at run
|
||||
# time. An undetectable/undismissed custom provider keeps its routing.
|
||||
no_provider_overrides = (
|
||||
['model_provider="openai"'] if codex_config_provider_dismissed(explicit) else []
|
||||
)
|
||||
no_provider_overrides = ['model_provider="openai"'] if config_provider_dismissed else []
|
||||
if spec is not None and (
|
||||
spec.executor.auth is not None
|
||||
or spec.executor.profile
|
||||
@@ -2384,6 +2454,31 @@ def resolve_native_codex_launch(
|
||||
)
|
||||
entry = default_provider_for_harness(effective_config_with_detected(explicit), "codex")
|
||||
|
||||
if (
|
||||
entry is None
|
||||
and config_detection is not None
|
||||
and config_detection.model_provider is not None
|
||||
and not config_provider_dismissed
|
||||
):
|
||||
# An adopted cli-config entry can explicitly shadow the same ambient
|
||||
# detection without being marked the Omnigent default. Codex still
|
||||
# selects that provider from config.toml, so pin the already-resolved
|
||||
# detection instead of describing this as an OpenAI-login launch.
|
||||
# This keeps rollout metadata, app-server, and remote TUI routing on
|
||||
# one immutable provider selection during cold resume.
|
||||
provider_id = config_detection.model_provider
|
||||
_logger.info(
|
||||
"native-codex routing: config.toml provider %r (ambient fallback, model=%s)",
|
||||
provider_id,
|
||||
model,
|
||||
)
|
||||
return NativeCodexLaunch(
|
||||
config_overrides=[f"model_provider={json.dumps(provider_id)}"],
|
||||
model=model,
|
||||
profile=None,
|
||||
summary=f"Codex config.toml provider {provider_id!r} (ambient fallback)",
|
||||
)
|
||||
|
||||
if entry is None:
|
||||
_logger.info(
|
||||
"native-codex routing: Codex CLI login (no provider configured for the Codex "
|
||||
|
||||
@@ -1804,8 +1804,10 @@ async def supervise_forwarder(
|
||||
if client is None:
|
||||
client = client_for_transport(app_server_url, client_name="omnigent-codex-forwarder")
|
||||
await client.connect()
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(
|
||||
base_url,
|
||||
headers=headers,
|
||||
auth=auth,
|
||||
timeout=httpx.Timeout(30.0),
|
||||
|
||||
@@ -20,6 +20,8 @@ import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from omnigent.process_logging import data_dir
|
||||
|
||||
_STATE_ROOT_ENV_VAR = "OMNIGENT_CODEX_NATIVE_STATE_DIR"
|
||||
_logger = logging.getLogger(__name__)
|
||||
_LAUNCH_FILE = "launch.json"
|
||||
@@ -44,14 +46,15 @@ def _codex_native_state_root() -> Path:
|
||||
Return the root directory for persistent codex-native state.
|
||||
|
||||
Honors :data:`_STATE_ROOT_ENV_VAR` for tests and advanced local
|
||||
setups. Production defaults to ``~/.omnigent/codex-native``.
|
||||
setups. Otherwise follows ``OMNIGENT_DATA_DIR``, falling back to
|
||||
``~/.omnigent/codex-native``.
|
||||
|
||||
:returns: Absolute path to the state root.
|
||||
"""
|
||||
override = os.environ.get(_STATE_ROOT_ENV_VAR)
|
||||
if override:
|
||||
return Path(override)
|
||||
return Path.home() / ".omnigent" / "codex-native"
|
||||
return data_dir() / "codex-native"
|
||||
|
||||
|
||||
def _state_dir_for_conversation_id(conversation_id: str) -> Path:
|
||||
|
||||
@@ -9,11 +9,12 @@ import urllib.parse
|
||||
import webbrowser
|
||||
from collections.abc import Callable
|
||||
|
||||
# Databricks workspace-hosted omnigent: the API proxy and the web UI are
|
||||
# mounted on different workspace paths. ``conversation_url`` maps the
|
||||
# server (API) base onto the UI mount so browser links land on the SPA
|
||||
# instead of the JSON API.
|
||||
WORKSPACE_API_PATH = "/api/2.0/omnigent"
|
||||
# The workspace-hosted API mount (routing-relevant shape) lives in cli_auth
|
||||
# next to the header builder that keys off it; the browser-link helpers below
|
||||
# only need it to map the API base onto the UI mount. The UI mount is a
|
||||
# browser-link concern, so it stays here.
|
||||
from omnigent.cli_auth import WORKSPACE_API_PATH
|
||||
|
||||
WORKSPACE_UI_PATH = "/omnigent"
|
||||
|
||||
# Client-side SPA route for one conversation (see web/src/App.tsx's
|
||||
@@ -49,22 +50,6 @@ def strip_conversation_path(url: str) -> str:
|
||||
)
|
||||
|
||||
|
||||
def is_workspace_hosted_url(base_url: str) -> bool:
|
||||
"""
|
||||
Whether *base_url* is a Databricks workspace-hosted Omnigent mount.
|
||||
|
||||
True for the API proxy mount (``https://<ws>/api/2.0/omnigent``) the
|
||||
CLI connects to on a workspace. Used to suppress UI a workspace
|
||||
deployment shouldn't surface (e.g. the startup banner's server-version
|
||||
row, since a workspace build reports no meaningful version string).
|
||||
|
||||
:param base_url: Omnigent server base URL, e.g.
|
||||
``"https://example.databricks.com/api/2.0/omnigent"``.
|
||||
:returns: ``True`` when the URL path is the workspace API mount.
|
||||
"""
|
||||
return urllib.parse.urlsplit(base_url.rstrip("/")).path == WORKSPACE_API_PATH
|
||||
|
||||
|
||||
def display_server_url(base_url: str) -> str:
|
||||
"""
|
||||
Map an Omnigent server base URL to the user-facing form to show.
|
||||
|
||||
@@ -30,7 +30,6 @@ from typing import TypedDict, cast
|
||||
import click
|
||||
import httpx
|
||||
import yaml
|
||||
from omnigent_client._http import is_loopback_url
|
||||
|
||||
from omnigent._native_resume_hint import echo_native_cold_resume_hint, echo_native_resume_hint
|
||||
from omnigent._platform import resolve_cli_binary
|
||||
@@ -42,6 +41,7 @@ from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.host.daemon_launch import (
|
||||
error_text,
|
||||
launch_or_reuse_daemon_runner,
|
||||
open_daemon_client,
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
@@ -415,7 +415,7 @@ def _run_with_remote_server(
|
||||
from omnigent.cli import _ensure_host_daemon
|
||||
from omnigent.host.identity import load_or_create_host_identity
|
||||
|
||||
headers = _remote_headers(server_url=base_url)
|
||||
headers = _remote_headers(server_url=base_url, host_id=None)
|
||||
try:
|
||||
resolved_session_id = _resolve_session_id_for_resume(
|
||||
base_url=base_url,
|
||||
@@ -498,12 +498,7 @@ async def _prepare_cursor_terminal_via_daemon(
|
||||
"""
|
||||
persist_args = list(cursor_args)
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
) as client:
|
||||
async with open_daemon_client(base_url, headers, host_id, timeout=timeout) as client:
|
||||
# Resuming an existing session can either reattach to a live
|
||||
# terminal (prior chat intact) or, if that terminal has exited,
|
||||
# cold-start a fresh TUI. We only know which after probing for a
|
||||
|
||||
@@ -862,9 +862,9 @@ async def supervise_cursor_transcript_elicitations(
|
||||
store_path: Path | None = None
|
||||
loop = asyncio.get_running_loop()
|
||||
timeout = httpx.Timeout(_POST_TIMEOUT_S, connect=10.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url, headers=headers, auth=auth, timeout=timeout
|
||||
) as client:
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(base_url, headers=headers, auth=auth, timeout=timeout) as client:
|
||||
while True:
|
||||
try:
|
||||
if store_path is None or not store_path.exists():
|
||||
|
||||
@@ -311,9 +311,9 @@ async def forward_cursor_usage_to_session(
|
||||
# — the safe direction. Seeding from persisted usage would permanently skip a
|
||||
# wake whose idle POST crashed after the usage flush persisted.
|
||||
idle_posted_turns = 0
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url, headers=headers, auth=auth, timeout=timeout
|
||||
) as client:
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(base_url, headers=headers, auth=auth, timeout=timeout) as client:
|
||||
while True:
|
||||
try:
|
||||
lines = await asyncio.to_thread(_read_usage_lines, bridge_dir)
|
||||
|
||||
@@ -31,6 +31,11 @@ _HTTP_TIMEOUT_S = 10.0
|
||||
#: a model the same way no matter which listing answered.
|
||||
_CATALOG_SPELLINGS: tuple[str, ...] = ("databricks-", _SYSTEM_MODEL_PREFIX)
|
||||
|
||||
# DATABRICKS-PATCH(codex-live-model-discovery)
|
||||
#: ``gpt-5-6-sol`` → ``("gpt", "5", "6", "sol")``. Mirrors
|
||||
#: ``codex_model_vocabulary._GPT_ID_RE`` without reaching into its privates.
|
||||
_GPT_VERSIONED_ID_RE = re.compile(r"^(gpt|codex)-(\d+)-(\d+)(?:-([a-z0-9]+))?$")
|
||||
|
||||
|
||||
def _bare_model_id(model_id: str) -> str:
|
||||
"""Strip the catalog spelling so ids compare across vocabularies."""
|
||||
@@ -310,3 +315,86 @@ def discover_databricks_claude_models(
|
||||
token,
|
||||
transport=transport,
|
||||
).families
|
||||
|
||||
|
||||
# DATABRICKS-PATCH(codex-live-model-discovery)
|
||||
def discover_databricks_codex_models(
|
||||
workspace_url: str,
|
||||
token: str,
|
||||
*,
|
||||
transport: httpx.BaseTransport | None = None,
|
||||
) -> tuple[str, ...]:
|
||||
"""Discover every codex-compatible model a Databricks workspace serves.
|
||||
|
||||
Unity Catalog model services is the only listing that reports what the
|
||||
codex Responses route will serve, so ids are ``system.ai.`` by
|
||||
construction — unlike the Claude catalog above, which also has a legacy
|
||||
gateway listing to merge in.
|
||||
|
||||
:param workspace_url: Workspace origin, e.g. ``"https://example.com"``.
|
||||
:param token: Workspace bearer token.
|
||||
:param transport: Optional HTTP transport used by tests.
|
||||
:returns: Codex-servable model ids, best default first, e.g.
|
||||
``("system.ai.gpt-5-6-sol", "system.ai.gpt-5-5")``. An empty tuple is
|
||||
authoritative: the listing answered and exposes no codex model.
|
||||
:raises httpx.HTTPError: When the listing cannot be read.
|
||||
:raises ValueError: When the listing is malformed.
|
||||
"""
|
||||
from omnigent.model_override import is_codex_compatible_model
|
||||
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
with httpx.Client(transport=transport, timeout=_HTTP_TIMEOUT_S) as client:
|
||||
model_ids = _list_model_service_ids(client, workspace_url, headers)
|
||||
codex_ids = [model_id for model_id in model_ids if is_codex_compatible_model(model_id)]
|
||||
return tuple(sorted(codex_ids, key=_codex_preference_rank, reverse=True))
|
||||
|
||||
|
||||
def _codex_preference_rank(model_id: str) -> tuple[int, int, int, int, str]:
|
||||
"""Order codex-servable ids so the best launch default sorts first.
|
||||
|
||||
The listing says what a workspace *can* serve, not which to start on, and a
|
||||
name sort has no opinion either (it ranks ``kimi-k3`` over every GPT).
|
||||
Tiers, highest first: the owned curated codex catalog in its declared
|
||||
cheapest-safe-first order; then versioned ``gpt``/``codex`` ids, newest
|
||||
generation first and untiered ahead of a same-generation tier; then the
|
||||
rest by name, for determinism.
|
||||
|
||||
:param model_id: A servable id, e.g. ``"system.ai.gpt-5-6-sol"``.
|
||||
:returns: A sort key; compare descending.
|
||||
"""
|
||||
from omnigent.codex_model_vocabulary import comparable_model_id
|
||||
from omnigent.model_fallbacks import static_model_fallback
|
||||
from omnigent.onboarding.provider_config import SUBSCRIPTION_KIND
|
||||
|
||||
bare = comparable_model_id(model_id)
|
||||
curated = static_model_fallback(SUBSCRIPTION_KIND, "codex")
|
||||
order = [comparable_model_id(m) for m in (curated.model_ids if curated else ())]
|
||||
if bare in order:
|
||||
# Negated so the earliest curated entry sorts highest under reverse=True.
|
||||
return (2, -order.index(bare), 0, 0, "")
|
||||
match = _GPT_VERSIONED_ID_RE.match(bare)
|
||||
if match is None:
|
||||
return (0, 0, 0, 0, bare)
|
||||
_family, major, minor, tier = match.groups()
|
||||
return (1, int(major), int(minor), 0 if tier else 1, tier or "")
|
||||
|
||||
|
||||
def select_servable_model(requested: str, servable: Iterable[str]) -> str | None:
|
||||
"""Resolve *requested* against the ids a workspace actually serves.
|
||||
|
||||
Compared on the bare id, so a request naming the legacy ``databricks-``
|
||||
spelling resolves to the ``system.ai.`` id serving that same model — only
|
||||
the served spelling is routable.
|
||||
|
||||
:param requested: Model id in either vocabulary, e.g.
|
||||
``"databricks-gpt-5-6-luna"``.
|
||||
:param servable: Ids the workspace serves, e.g. the result of
|
||||
:func:`discover_databricks_codex_models`.
|
||||
:returns: The servable id for *requested*, or ``None`` when the workspace
|
||||
serves no such model.
|
||||
"""
|
||||
wanted = _bare_model_id(requested)
|
||||
for model_id in servable:
|
||||
if _bare_model_id(model_id) == wanted:
|
||||
return model_id
|
||||
return None
|
||||
|
||||
@@ -624,6 +624,7 @@ class SqlConversationMetadata(OmnigentBase):
|
||||
# No FK: host records are managed outside this table.
|
||||
host_id: Mapped[str | None] = mapped_column(Uuid16(), nullable=True)
|
||||
sub_agent_name: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
task_summary: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
external_session_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
session_state: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
session_usage: Mapped[str | None] = mapped_column(CompressedText, nullable=True)
|
||||
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
"""Add task_summary to conversation metadata.
|
||||
|
||||
Revision ID: za2b3c4d5e6f
|
||||
Revises: d5e9f1a2b3c4
|
||||
Create Date: 2026-08-10 00:00:00.000000
|
||||
|
||||
Adds a nullable ``task_summary`` column to ``omnigent_conversation_metadata``.
|
||||
Sub-agent sessions use this column to store a human-readable, task-derived label
|
||||
(e.g. "Investigate auth token refresh") generated asynchronously by the
|
||||
background title coordinator. The structured title
|
||||
(``"{agent_type}:{agent_type}-{ordinal}"``) stays in ``conversations.title`` as
|
||||
the stable spawn-or-continue key; ``task_summary`` is purely presentational.
|
||||
|
||||
Additive. No existing data needs backfill.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision: str = "za2b3c4d5e6f"
|
||||
down_revision: str | None = "d5e9f1a2b3c4"
|
||||
branch_labels: str | Sequence[str] | None = None
|
||||
depends_on: str | Sequence[str] | None = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Add ``task_summary`` to ``omnigent_conversation_metadata``."""
|
||||
op.add_column(
|
||||
"omnigent_conversation_metadata",
|
||||
sa.Column("task_summary", sa.String(128), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Remove ``task_summary`` from ``omnigent_conversation_metadata``."""
|
||||
with op.batch_alter_table("omnigent_conversation_metadata") as batch_op:
|
||||
batch_op.drop_column("task_summary")
|
||||
@@ -92,7 +92,7 @@ def _fetch_server_info(server_url: str, *, timeout: float) -> dict[str, Any] | N
|
||||
base = server_url.rstrip("/")
|
||||
resp = httpx.get(
|
||||
f"{base}/v1/info",
|
||||
headers=_remote_headers(server_url=base),
|
||||
headers=_remote_headers(server_url=base, host_id=None),
|
||||
timeout=timeout,
|
||||
trust_env=False,
|
||||
)
|
||||
|
||||
@@ -225,6 +225,7 @@ class Conversation:
|
||||
subagent_routing_override: str | None = None
|
||||
harness_override: str | None = None
|
||||
sub_agent_name: str | None = None
|
||||
task_summary: str | None = None
|
||||
external_session_id: str | None = None
|
||||
terminal_launch_args: list[str] | None = None
|
||||
workspace: str | None = None
|
||||
|
||||
@@ -34,6 +34,15 @@ class ErrorCode:
|
||||
§Elicitation completion invariant.
|
||||
:cvar RUNNER_UNAVAILABLE: No online runner can serve the
|
||||
requested dispatch (HTTP 503).
|
||||
:cvar WRONG_REPLICA: The session's bound runner exists but its
|
||||
tunnel is not registered on the replica that served this request
|
||||
(HTTP 400). When replicas are sharded by host, a request keyed for
|
||||
one host can reach a replica that doesn't hold its tunnel — the key
|
||||
doesn't match where the tunnel lives. The request itself is valid
|
||||
(the same bytes succeed on the right replica), so the fix is to
|
||||
re-address it: reissue WITHOUT the key and reach the host via the
|
||||
default route. Distinct from ``RUNNER_UNAVAILABLE`` (no runner
|
||||
bound anywhere), which no re-addressing can fix.
|
||||
:cvar UNAUTHORIZED: No valid authentication credentials (HTTP 401).
|
||||
:cvar FORBIDDEN: Authenticated but insufficient permissions (HTTP 403).
|
||||
:cvar RUNNER_CAPABILITY_MISMATCH: The selected runner cannot
|
||||
@@ -45,6 +54,10 @@ class ErrorCode:
|
||||
rather than 400 (the request is valid against a configured
|
||||
host) or 503 (retrying cannot succeed without user action —
|
||||
running ``omnigent setup`` on the host machine).
|
||||
:cvar WORKSPACE_MISSING: The session's bound workspace no longer
|
||||
exists on the selected host (HTTP 410). Retrying cannot recreate
|
||||
deleted workspace state; the user must start a session in a valid
|
||||
workspace.
|
||||
"""
|
||||
|
||||
UNAUTHORIZED = "unauthorized"
|
||||
@@ -56,10 +69,12 @@ class ErrorCode:
|
||||
INTERNAL_ERROR = "internal_error"
|
||||
HARNESS_PROTOCOL_VIOLATION = "harness_protocol_violation"
|
||||
RUNNER_UNAVAILABLE = "runner_unavailable"
|
||||
WRONG_REPLICA = "wrong_replica"
|
||||
RUNNER_CAPABILITY_MISMATCH = "runner_capability_mismatch"
|
||||
# Keep the string equal to frames.HARNESS_NOT_CONFIGURED_ERROR_CODE —
|
||||
# the host's wire error code passes through as the API error code.
|
||||
HARNESS_NOT_CONFIGURED = "harness_not_configured"
|
||||
WORKSPACE_MISSING = "workspace_missing"
|
||||
|
||||
|
||||
# Single source of truth for error code → HTTP status.
|
||||
@@ -76,11 +91,20 @@ _CODE_TO_HTTP_STATUS: dict[str, int] = {
|
||||
# can fix them; investigation needed in the harness wrap).
|
||||
ErrorCode.HARNESS_PROTOCOL_VIOLATION: 500,
|
||||
ErrorCode.RUNNER_UNAVAILABLE: 503,
|
||||
# 400, not 503: the request reached a replica that can't serve it, but the
|
||||
# request is valid — the fix is to re-address it (reissue without the key),
|
||||
# not to wait and retry. A 4xx also keeps this expected routing event out of
|
||||
# 5xx error-rate signals and clear of infra retry policies (which would
|
||||
# resend to the same wrong replica). The distinct code string is what a
|
||||
# key-aware client keys the re-address off; a client that doesn't know the
|
||||
# code just sees a clean client-error, not a phantom outage.
|
||||
ErrorCode.WRONG_REPLICA: 400,
|
||||
ErrorCode.RUNNER_CAPABILITY_MISMATCH: 503,
|
||||
# 412 Precondition Failed: the request is well-formed but the host
|
||||
# can't satisfy it until the user runs `omnigent setup` there —
|
||||
# neither a 400 (input is fine) nor a 503 (a retry won't help).
|
||||
ErrorCode.HARNESS_NOT_CONFIGURED: 412,
|
||||
ErrorCode.WORKSPACE_MISSING: 410,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -29,7 +29,6 @@ from tempfile import TemporaryDirectory
|
||||
import click
|
||||
import httpx
|
||||
import yaml
|
||||
from omnigent_client._http import is_loopback_url
|
||||
|
||||
from omnigent._native_resume_hint import echo_native_cold_resume_hint, echo_native_resume_hint
|
||||
from omnigent._platform import resolve_cli_binary
|
||||
@@ -41,6 +40,7 @@ from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.host.daemon_launch import (
|
||||
error_text,
|
||||
launch_or_reuse_daemon_runner,
|
||||
open_daemon_client,
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
@@ -247,7 +247,7 @@ def _run_with_remote_server(
|
||||
from omnigent.cli import _ensure_host_daemon
|
||||
from omnigent.host.identity import load_or_create_host_identity
|
||||
|
||||
headers = _remote_headers(server_url=base_url)
|
||||
headers = _remote_headers(server_url=base_url, host_id=None)
|
||||
try:
|
||||
resolved_session_id = _resolve_session_id_for_resume(
|
||||
base_url=base_url,
|
||||
@@ -318,12 +318,7 @@ async def _prepare_goose_terminal_via_daemon(
|
||||
"""
|
||||
persist_args = list(goose_args)
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
) as client:
|
||||
async with open_daemon_client(base_url, headers, host_id, timeout=timeout) as client:
|
||||
reattached = False
|
||||
cold_resumed = False
|
||||
fresh_session = session_id is None
|
||||
|
||||
@@ -648,9 +648,9 @@ async def forward_goose_store_to_session(
|
||||
# supervisor) if the replay's idle post hits a transient server error.
|
||||
needs_replay = goose_session_id is not None and last_id > 0
|
||||
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url, headers=headers, auth=auth, timeout=timeout
|
||||
) as client:
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(base_url, headers=headers, auth=auth, timeout=timeout) as client:
|
||||
while True:
|
||||
try:
|
||||
if needs_replay and goose_session_id is not None:
|
||||
|
||||
@@ -170,9 +170,9 @@ async def supervise_goose_approval_mirror(
|
||||
active: _PendingApproval | None = None
|
||||
episode = 0
|
||||
timeout = httpx.Timeout(_POST_TIMEOUT_S, connect=10.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url, headers=headers, auth=auth, timeout=timeout
|
||||
) as client:
|
||||
from omnigent.cli_auth import open_server_client
|
||||
|
||||
async with open_server_client(base_url, headers=headers, auth=auth, timeout=timeout) as client:
|
||||
while True:
|
||||
try:
|
||||
pane = await asyncio.to_thread(capture_goose_pane, bridge_dir)
|
||||
|
||||
@@ -28,7 +28,6 @@ from tempfile import TemporaryDirectory
|
||||
import click
|
||||
import httpx
|
||||
import yaml
|
||||
from omnigent_client._http import is_loopback_url
|
||||
|
||||
from omnigent._native_resume_hint import echo_native_cold_resume_hint, echo_native_resume_hint
|
||||
from omnigent._platform import resolve_cli_binary
|
||||
@@ -40,6 +39,7 @@ from omnigent.entities.session_resources import terminal_resource_id
|
||||
from omnigent.host.daemon_launch import (
|
||||
error_text,
|
||||
launch_or_reuse_daemon_runner,
|
||||
open_daemon_client,
|
||||
wait_for_host_online,
|
||||
wait_for_runner_online,
|
||||
)
|
||||
@@ -245,7 +245,7 @@ def _run_with_remote_server(
|
||||
from omnigent.cli import _ensure_host_daemon
|
||||
from omnigent.host.identity import load_or_create_host_identity
|
||||
|
||||
headers = _remote_headers(server_url=base_url)
|
||||
headers = _remote_headers(server_url=base_url, host_id=None)
|
||||
try:
|
||||
resolved_session_id = _resolve_session_id_for_resume(
|
||||
base_url=base_url,
|
||||
@@ -316,12 +316,7 @@ async def _prepare_hermes_terminal_via_daemon(
|
||||
"""
|
||||
persist_args = list(hermes_args)
|
||||
timeout = httpx.Timeout(30.0, read=120.0)
|
||||
async with httpx.AsyncClient(
|
||||
base_url=base_url,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
trust_env=not is_loopback_url(base_url),
|
||||
) as client:
|
||||
async with open_daemon_client(base_url, headers, host_id, timeout=timeout) as client:
|
||||
reattached = False
|
||||
cold_resumed = False
|
||||
fresh_session = session_id is None
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user