Compare commits
216 Commits
dev/shaqay
...
dev/imalch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
031288c855 | ||
|
|
f2088975f5 | ||
|
|
9c80abeeec | ||
|
|
d0f1dd22a0 | ||
|
|
ff61d54770 | ||
|
|
702b3cd9eb | ||
|
|
9281c550fa | ||
|
|
bba222cf93 | ||
|
|
96a1813297 | ||
|
|
ab8e305980 | ||
|
|
9ecc183d04 | ||
|
|
c8eebbda3e | ||
|
|
ac4bd61bac | ||
|
|
638b328ea2 | ||
|
|
44c2e67573 | ||
|
|
1a95151fd4 | ||
|
|
bf11074248 | ||
|
|
9b0d7a5271 | ||
|
|
7ad5e4eff3 | ||
|
|
f45c857bec | ||
|
|
f26827ba25 | ||
|
|
2a6b4021f6 | ||
|
|
5953e415f7 | ||
|
|
663b74babe | ||
|
|
2ea5cb7139 | ||
|
|
040b55c91b | ||
|
|
efac1c5920 | ||
|
|
313e9c86af | ||
|
|
838441b245 | ||
|
|
09f3dffa93 | ||
|
|
b6ba266b5a | ||
|
|
d1f783fa3d | ||
|
|
f37ac204e3 | ||
|
|
57b32d5a22 | ||
|
|
ebf0d2994c | ||
|
|
8bdf5963f8 | ||
|
|
2f61c54da3 | ||
|
|
a9be8e3cfd | ||
|
|
58dca77b08 | ||
|
|
17b38ee8dc | ||
|
|
e1ed6d0159 | ||
|
|
c9bd92342e | ||
|
|
808ed32923 | ||
|
|
cad0e1e3bd | ||
|
|
faa0756a6b | ||
|
|
7a19620340 | ||
|
|
4b8c2098c9 | ||
|
|
13770329f9 | ||
|
|
2d906945ac | ||
|
|
1d8d508a6f | ||
|
|
3503db0c34 | ||
|
|
087c63cebb | ||
|
|
58abe546ec | ||
|
|
2765aff40c | ||
|
|
ef5894affc | ||
|
|
11b681883f | ||
|
|
b1682fb4eb | ||
|
|
e174cc2e77 | ||
|
|
446c119f1b | ||
|
|
1e902a99cf | ||
|
|
4086f04ddb | ||
|
|
7e30a81550 | ||
|
|
ca5e880242 | ||
|
|
0a251f0dac | ||
|
|
d0ae5e2da2 | ||
|
|
7dd456dc22 | ||
|
|
fdacd6d8e2 | ||
|
|
b6d1bc62f0 | ||
|
|
85f88c85c9 | ||
|
|
b124bf5170 | ||
|
|
7d3e5f6d0c | ||
|
|
2647a6de84 | ||
|
|
5206fac8af | ||
|
|
ac85481fee | ||
|
|
7b8096e6ef | ||
|
|
c2c663a6e5 | ||
|
|
be817e3421 | ||
|
|
186368fe88 | ||
|
|
902c40d71f | ||
|
|
a3c4a9e957 | ||
|
|
4f6b0de73f | ||
|
|
f1a739e7eb | ||
|
|
c438fc2fb2 | ||
|
|
89c42926fc | ||
|
|
f212204fa4 | ||
|
|
3a5ab674f0 | ||
|
|
fa0c22441c | ||
|
|
30ae7c28e1 | ||
|
|
1340af08aa | ||
|
|
6acd1f7473 | ||
|
|
87b9057d63 | ||
|
|
cf1f537879 | ||
|
|
9de3f3ba6a | ||
|
|
3410562a58 | ||
|
|
a5987218b8 | ||
|
|
d5679d7c06 | ||
|
|
e03e28b38d | ||
|
|
3d4274962c | ||
|
|
6450c8ccbc | ||
|
|
535c3f8d03 | ||
|
|
2c2587538f | ||
|
|
b5abe6f74d | ||
|
|
b7a14a519c | ||
|
|
59972a08fc | ||
|
|
e69c9e6a4d | ||
|
|
7d8fd2ad49 | ||
|
|
19c3cd9a5a | ||
|
|
ffe51911a0 | ||
|
|
5bbcc400d3 | ||
|
|
931facc7f7 | ||
|
|
bef6d4445b | ||
|
|
6f4dcf3378 | ||
|
|
b349563da7 | ||
|
|
828cf5c867 | ||
|
|
b46e9bcc9d | ||
|
|
e08fe765e5 | ||
|
|
e6deb33032 | ||
|
|
2fb44353a3 | ||
|
|
5c0a62a8a4 | ||
|
|
c02f1f41fe | ||
|
|
6f9687546f | ||
|
|
74b8713a41 | ||
|
|
3df5293010 | ||
|
|
0d5c78103d | ||
|
|
e1b1fff769 | ||
|
|
c969d32f4e | ||
|
|
22586ef4d8 | ||
|
|
4a5635b5a0 | ||
|
|
b00a05c785 | ||
|
|
7ef3cfe63e | ||
|
|
937cb5081d | ||
|
|
6d0525ae70 | ||
|
|
1ff39b6fa8 | ||
|
|
b565f05d79 | ||
|
|
4b50446ffa | ||
|
|
c4d9887f9a | ||
|
|
78799c1bcf | ||
|
|
d7e35e56cf | ||
|
|
2794e27849 | ||
|
|
8fa88fa8ca | ||
|
|
f24c55f0d5 | ||
|
|
eee692e351 | ||
|
|
b6524514c1 | ||
|
|
2c67a27a71 | ||
|
|
9dbe098349 | ||
|
|
e9996ec62a | ||
|
|
6124564297 | ||
|
|
91337399fe | ||
|
|
79359fb5e7 | ||
|
|
6566ab7e02 | ||
|
|
d273efc0f3 | ||
|
|
2bb1027e37 | ||
|
|
ad74543a6f | ||
|
|
6b10e186c4 | ||
|
|
fba3c79885 | ||
|
|
303d0190c5 | ||
|
|
14c35a16a8 | ||
|
|
c6ffe9abab | ||
|
|
f190a95a4f | ||
|
|
504aeb0e09 | ||
|
|
178c3b15b4 | ||
|
|
32c4993c8a | ||
|
|
047ea642d2 | ||
|
|
f5dccab5cf | ||
|
|
e590fad50b | ||
|
|
c0ffd000dd | ||
|
|
95ba762620 | ||
|
|
8c62829a2b | ||
|
|
0bff38c54a | ||
|
|
fece9ce745 | ||
|
|
2250508c2e | ||
|
|
0b08d89304 | ||
|
|
d72fa2a209 | ||
|
|
2e03d8b4d2 | ||
|
|
ea3f3467e2 | ||
|
|
38b638d89d | ||
|
|
05b967c79a | ||
|
|
4a210faf33 | ||
|
|
24c4ecaaac | ||
|
|
6323f0104d | ||
|
|
301b17c2a1 | ||
|
|
062fa7a2bb | ||
|
|
0b619afc87 | ||
|
|
b32d921cd9 | ||
|
|
4b91a7b391 | ||
|
|
b364faf4ec | ||
|
|
c023e9d959 | ||
|
|
1b86377635 | ||
|
|
989e513969 | ||
|
|
3ba0e85edd | ||
|
|
0f957a93cd | ||
|
|
fc97092f75 | ||
|
|
e89e5136bd | ||
|
|
363b373979 | ||
|
|
2d61357c76 | ||
|
|
88694e8417 | ||
|
|
7dc2cd2ebe | ||
|
|
621862a7d1 | ||
|
|
773fbf56a4 | ||
|
|
d61c03ca08 | ||
|
|
daf5e584c2 | ||
|
|
bb7e9a8171 | ||
|
|
66edc347ae | ||
|
|
f1658ab642 | ||
|
|
1ababa7016 | ||
|
|
85a17a70f7 | ||
|
|
48ba256cbd | ||
|
|
4cbc4894f9 | ||
|
|
b76630f2af | ||
|
|
074b06929d | ||
|
|
3c0c571012 | ||
|
|
4b8425b64b | ||
|
|
910cf49269 | ||
|
|
b51d5f18c7 | ||
|
|
0f90a34676 | ||
|
|
2d5a3bfe76 |
23
.github/dotslash-zsh-config.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"outputs": {
|
||||
"codex-zsh": {
|
||||
"platforms": {
|
||||
"macos-aarch64": {
|
||||
"name": "codex-zsh-aarch64-apple-darwin.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"path": "codex-zsh/bin/zsh"
|
||||
},
|
||||
"linux-x86_64": {
|
||||
"name": "codex-zsh-x86_64-unknown-linux-musl.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"path": "codex-zsh/bin/zsh"
|
||||
},
|
||||
"linux-aarch64": {
|
||||
"name": "codex-zsh-aarch64-unknown-linux-musl.tar.gz",
|
||||
"format": "tar.gz",
|
||||
"path": "codex-zsh/bin/zsh"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
61
.github/scripts/build-zsh-release-artifact.sh
vendored
Executable file
@@ -0,0 +1,61 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
if [[ "$#" -ne 1 ]]; then
|
||||
echo "usage: $0 <archive-path>" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
archive_path="$1"
|
||||
workspace="${GITHUB_WORKSPACE:?missing GITHUB_WORKSPACE}"
|
||||
zsh_commit="${ZSH_COMMIT:?missing ZSH_COMMIT}"
|
||||
zsh_patch="${ZSH_PATCH:?missing ZSH_PATCH}"
|
||||
temp_root="${RUNNER_TEMP:-/tmp}"
|
||||
work_root="$(mktemp -d "${temp_root%/}/codex-zsh-release.XXXXXX")"
|
||||
trap 'rm -rf "$work_root"' EXIT
|
||||
|
||||
source_root="${work_root}/zsh"
|
||||
package_root="${work_root}/codex-zsh"
|
||||
wrapper_path="${work_root}/exec-wrapper"
|
||||
stdout_path="${work_root}/stdout.txt"
|
||||
wrapper_log_path="${work_root}/wrapper.log"
|
||||
|
||||
git clone https://git.code.sf.net/p/zsh/code "$source_root"
|
||||
cd "$source_root"
|
||||
git checkout "$zsh_commit"
|
||||
git apply "${workspace}/${zsh_patch}"
|
||||
./Util/preconfig
|
||||
./configure
|
||||
|
||||
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
|
||||
make -j"${cores}"
|
||||
|
||||
cat > "$wrapper_path" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
|
||||
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
|
||||
file="$1"
|
||||
shift
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
exec "$file"
|
||||
fi
|
||||
arg0="$1"
|
||||
shift
|
||||
exec -a "$arg0" "$file" "$@"
|
||||
EOF
|
||||
chmod +x "$wrapper_path"
|
||||
|
||||
CODEX_WRAPPER_LOG="$wrapper_log_path" \
|
||||
EXEC_WRAPPER="$wrapper_path" \
|
||||
"${source_root}/Src/zsh" -fc '/bin/echo smoke-zsh' > "$stdout_path"
|
||||
|
||||
grep -Fx "smoke-zsh" "$stdout_path"
|
||||
grep -Fx "/bin/echo" "$wrapper_log_path"
|
||||
|
||||
mkdir -p "$package_root/bin" "$(dirname "${workspace}/${archive_path}")"
|
||||
cp "${source_root}/Src/zsh" "$package_root/bin/zsh"
|
||||
chmod +x "$package_root/bin/zsh"
|
||||
|
||||
(cd "$work_root" && tar -czf "${workspace}/${archive_path}" codex-zsh)
|
||||
95
.github/workflows/rust-release-zsh.yml
vendored
Normal file
@@ -0,0 +1,95 @@
|
||||
name: rust-release-zsh
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
env:
|
||||
ZSH_COMMIT: 77045ef899e53b9598bebc5a41db93a548a40ca6
|
||||
ZSH_PATCH: codex-rs/shell-escalation/patches/zsh-exec-wrapper.patch
|
||||
|
||||
jobs:
|
||||
linux:
|
||||
name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: ubuntu-24.04
|
||||
image: ubuntu:24.04
|
||||
archive_name: codex-zsh-x86_64-unknown-linux-musl.tar.gz
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: ubuntu-24.04
|
||||
image: arm64v8/ubuntu:24.04
|
||||
archive_name: codex-zsh-aarch64-unknown-linux-musl.tar.gz
|
||||
|
||||
steps:
|
||||
- name: Install build prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
autoconf \
|
||||
bison \
|
||||
build-essential \
|
||||
ca-certificates \
|
||||
gettext \
|
||||
git \
|
||||
libncursesw5-dev
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build, smoke-test, and stage zsh artifact
|
||||
shell: bash
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \
|
||||
"dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: codex-zsh-${{ matrix.target }}
|
||||
path: dist/zsh/${{ matrix.target }}/*
|
||||
|
||||
darwin:
|
||||
name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15-xlarge
|
||||
target: aarch64-apple-darwin
|
||||
variant: macos-15
|
||||
archive_name: codex-zsh-aarch64-apple-darwin.tar.gz
|
||||
|
||||
steps:
|
||||
- name: Install build prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v autoconf >/dev/null 2>&1; then
|
||||
brew install autoconf
|
||||
fi
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Build, smoke-test, and stage zsh artifact
|
||||
shell: bash
|
||||
run: |
|
||||
"${GITHUB_WORKSPACE}/.github/scripts/build-zsh-release-artifact.sh" \
|
||||
"dist/zsh/${{ matrix.target }}/${{ matrix.archive_name }}"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: codex-zsh-${{ matrix.target }}
|
||||
path: dist/zsh/${{ matrix.target }}/*
|
||||
26
.github/workflows/rust-release.yml
vendored
@@ -389,15 +389,6 @@ jobs:
|
||||
release-lto: ${{ contains(github.ref_name, '-alpha') && 'thin' || 'fat' }}
|
||||
secrets: inherit
|
||||
|
||||
shell-tool-mcp:
|
||||
name: shell-tool-mcp
|
||||
needs: tag-check
|
||||
uses: ./.github/workflows/shell-tool-mcp.yml
|
||||
with:
|
||||
release-tag: ${{ github.ref_name }}
|
||||
publish: true
|
||||
secrets: inherit
|
||||
|
||||
argument-comment-lint-release-assets:
|
||||
name: argument-comment-lint release assets
|
||||
needs: tag-check
|
||||
@@ -405,12 +396,17 @@ jobs:
|
||||
with:
|
||||
publish: true
|
||||
|
||||
zsh-release-assets:
|
||||
name: zsh release assets
|
||||
needs: tag-check
|
||||
uses: ./.github/workflows/rust-release-zsh.yml
|
||||
|
||||
release:
|
||||
needs:
|
||||
- build
|
||||
- build-windows
|
||||
- shell-tool-mcp
|
||||
- argument-comment-lint-release-assets
|
||||
- zsh-release-assets
|
||||
name: release
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
@@ -453,11 +449,8 @@ jobs:
|
||||
- name: List
|
||||
run: ls -R dist/
|
||||
|
||||
# This is a temporary fix: we should modify shell-tool-mcp.yml so these
|
||||
# files do not end up in dist/ in the first place.
|
||||
- name: Delete entries from dist/ that should not go in the release
|
||||
run: |
|
||||
rm -rf dist/shell-tool-mcp*
|
||||
rm -rf dist/windows-binaries*
|
||||
# cargo-timing.html appears under multiple target-specific directories.
|
||||
# If included in files: dist/**, release upload races on duplicate
|
||||
@@ -547,6 +540,13 @@ jobs:
|
||||
tag: ${{ github.ref_name }}
|
||||
config: .github/dotslash-config.json
|
||||
|
||||
- uses: facebook/dotslash-publish-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
tag: ${{ github.ref_name }}
|
||||
config: .github/dotslash-zsh-config.json
|
||||
|
||||
- uses: facebook/dotslash-publish-release@v2
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
48
.github/workflows/shell-tool-mcp-ci.yml
vendored
@@ -1,48 +0,0 @@
|
||||
name: shell-tool-mcp CI
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- "shell-tool-mcp/**"
|
||||
- ".github/workflows/shell-tool-mcp-ci.yml"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
pull_request:
|
||||
paths:
|
||||
- "shell-tool-mcp/**"
|
||||
- ".github/workflows/shell-tool-mcp-ci.yml"
|
||||
- "pnpm-lock.yaml"
|
||||
- "pnpm-workspace.yaml"
|
||||
|
||||
env:
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
cache: "pnpm"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Format check
|
||||
run: pnpm --filter @openai/codex-shell-tool-mcp run format
|
||||
|
||||
- name: Run tests
|
||||
run: pnpm --filter @openai/codex-shell-tool-mcp test
|
||||
|
||||
- name: Build
|
||||
run: pnpm --filter @openai/codex-shell-tool-mcp run build
|
||||
553
.github/workflows/shell-tool-mcp.yml
vendored
@@ -1,553 +0,0 @@
|
||||
name: shell-tool-mcp
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
release-version:
|
||||
description: Version to publish (x.y.z or x.y.z-alpha.N). Defaults to GITHUB_REF_NAME when it starts with rust-v.
|
||||
required: false
|
||||
type: string
|
||||
release-tag:
|
||||
description: Tag name to use when downloading release artifacts (defaults to rust-v<version>).
|
||||
required: false
|
||||
type: string
|
||||
publish:
|
||||
description: Whether to publish to npm when the version is releasable.
|
||||
required: false
|
||||
default: true
|
||||
type: boolean
|
||||
|
||||
env:
|
||||
NODE_VERSION: 22
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.compute.outputs.version }}
|
||||
release_tag: ${{ steps.compute.outputs.release_tag }}
|
||||
should_publish: ${{ steps.compute.outputs.should_publish }}
|
||||
npm_tag: ${{ steps.compute.outputs.npm_tag }}
|
||||
steps:
|
||||
- name: Compute version and tags
|
||||
id: compute
|
||||
env:
|
||||
RELEASE_TAG_INPUT: ${{ inputs.release-tag }}
|
||||
RELEASE_VERSION_INPUT: ${{ inputs.release-version }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
version="$RELEASE_VERSION_INPUT"
|
||||
release_tag="$RELEASE_TAG_INPUT"
|
||||
|
||||
if [[ -z "$version" ]]; then
|
||||
if [[ -n "$release_tag" && "$release_tag" =~ ^rust-v.+ ]]; then
|
||||
version="${release_tag#rust-v}"
|
||||
elif [[ "${GITHUB_REF_NAME:-}" =~ ^rust-v.+ ]]; then
|
||||
version="${GITHUB_REF_NAME#rust-v}"
|
||||
release_tag="${GITHUB_REF_NAME}"
|
||||
else
|
||||
echo "release-version is required when GITHUB_REF_NAME is not a rust-v tag."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [[ -z "$release_tag" ]]; then
|
||||
release_tag="rust-v${version}"
|
||||
fi
|
||||
|
||||
npm_tag=""
|
||||
should_publish="false"
|
||||
if [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
|
||||
should_publish="true"
|
||||
elif [[ "$version" =~ ^[0-9]+\.[0-9]+\.[0-9]+-alpha\.[0-9]+$ ]]; then
|
||||
should_publish="true"
|
||||
npm_tag="alpha"
|
||||
fi
|
||||
|
||||
echo "version=${version}" >> "$GITHUB_OUTPUT"
|
||||
echo "release_tag=${release_tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "npm_tag=${npm_tag}" >> "$GITHUB_OUTPUT"
|
||||
echo "should_publish=${should_publish}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
bash-linux:
|
||||
name: Build Bash (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
|
||||
needs: metadata
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: ubuntu-24.04
|
||||
image: ubuntu:24.04
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: ubuntu-22.04
|
||||
image: ubuntu:22.04
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: debian-12
|
||||
image: debian:12
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: debian-11
|
||||
image: debian:11
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: centos-9
|
||||
image: quay.io/centos/centos:stream9
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: ubuntu-24.04
|
||||
image: arm64v8/ubuntu:24.04
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: ubuntu-22.04
|
||||
image: arm64v8/ubuntu:22.04
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: ubuntu-20.04
|
||||
image: arm64v8/ubuntu:20.04
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: debian-12
|
||||
image: arm64v8/debian:12
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: debian-11
|
||||
image: arm64v8/debian:11
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: centos-9
|
||||
image: quay.io/centos/centos:stream9
|
||||
steps:
|
||||
- name: Install build prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
|
||||
else
|
||||
echo "Unsupported package manager in container"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build patched Bash
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
|
||||
cd /tmp/bash
|
||||
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
|
||||
./configure --without-bash-malloc
|
||||
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
|
||||
make -j"${cores}"
|
||||
|
||||
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}"
|
||||
mkdir -p "$dest"
|
||||
cp bash "$dest/bash"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
|
||||
path: artifacts/**
|
||||
if-no-files-found: error
|
||||
|
||||
bash-darwin:
|
||||
name: Build Bash (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
|
||||
needs: metadata
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15-xlarge
|
||||
target: aarch64-apple-darwin
|
||||
variant: macos-15
|
||||
- runner: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
variant: macos-14
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build patched Bash
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone https://git.savannah.gnu.org/git/bash /tmp/bash
|
||||
cd /tmp/bash
|
||||
git checkout a8a1c2fac029404d3f42cd39f5a20f24b6e4fe4b
|
||||
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/bash-exec-wrapper.patch"
|
||||
./configure --without-bash-malloc
|
||||
cores="$(getconf _NPROCESSORS_ONLN)"
|
||||
make -j"${cores}"
|
||||
|
||||
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/bash/${{ matrix.variant }}"
|
||||
mkdir -p "$dest"
|
||||
cp bash "$dest/bash"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: shell-tool-mcp-bash-${{ matrix.target }}-${{ matrix.variant }}
|
||||
path: artifacts/**
|
||||
if-no-files-found: error
|
||||
|
||||
zsh-linux:
|
||||
name: Build zsh (Linux) - ${{ matrix.variant }} - ${{ matrix.target }}
|
||||
needs: metadata
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
container:
|
||||
image: ${{ matrix.image }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: ubuntu-24.04
|
||||
image: ubuntu:24.04
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: ubuntu-22.04
|
||||
image: ubuntu:22.04
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: debian-12
|
||||
image: debian:12
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: debian-11
|
||||
image: debian:11
|
||||
- runner: ubuntu-24.04
|
||||
target: x86_64-unknown-linux-musl
|
||||
variant: centos-9
|
||||
image: quay.io/centos/centos:stream9
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: ubuntu-24.04
|
||||
image: arm64v8/ubuntu:24.04
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: ubuntu-22.04
|
||||
image: arm64v8/ubuntu:22.04
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: ubuntu-20.04
|
||||
image: arm64v8/ubuntu:20.04
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: debian-12
|
||||
image: arm64v8/debian:12
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: debian-11
|
||||
image: arm64v8/debian:11
|
||||
- runner: ubuntu-24.04-arm
|
||||
target: aarch64-unknown-linux-musl
|
||||
variant: centos-9
|
||||
image: quay.io/centos/centos:stream9
|
||||
steps:
|
||||
- name: Install build prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y git build-essential bison autoconf gettext libncursesw5-dev
|
||||
elif command -v dnf >/dev/null 2>&1; then
|
||||
dnf install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
|
||||
elif command -v yum >/dev/null 2>&1; then
|
||||
yum install -y git gcc gcc-c++ make bison autoconf gettext ncurses-devel
|
||||
else
|
||||
echo "Unsupported package manager in container"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build patched zsh
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone https://git.code.sf.net/p/zsh/code /tmp/zsh
|
||||
cd /tmp/zsh
|
||||
git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6
|
||||
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch"
|
||||
./Util/preconfig
|
||||
./configure
|
||||
cores="$(command -v nproc >/dev/null 2>&1 && nproc || getconf _NPROCESSORS_ONLN)"
|
||||
make -j"${cores}"
|
||||
|
||||
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}"
|
||||
mkdir -p "$dest"
|
||||
cp Src/zsh "$dest/zsh"
|
||||
|
||||
- name: Smoke test zsh exec wrapper
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tmpdir="$(mktemp -d)"
|
||||
cat > "$tmpdir/exec-wrapper" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
|
||||
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
|
||||
file="$1"
|
||||
shift
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
exec "$file"
|
||||
fi
|
||||
arg0="$1"
|
||||
shift
|
||||
exec -a "$arg0" "$file" "$@"
|
||||
EOF
|
||||
chmod +x "$tmpdir/exec-wrapper"
|
||||
|
||||
CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \
|
||||
EXEC_WRAPPER="$tmpdir/exec-wrapper" \
|
||||
/tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt"
|
||||
|
||||
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
|
||||
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
|
||||
path: artifacts/**
|
||||
if-no-files-found: error
|
||||
|
||||
zsh-darwin:
|
||||
name: Build zsh (macOS) - ${{ matrix.variant }} - ${{ matrix.target }}
|
||||
needs: metadata
|
||||
runs-on: ${{ matrix.runner }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- runner: macos-15-xlarge
|
||||
target: aarch64-apple-darwin
|
||||
variant: macos-15
|
||||
- runner: macos-14
|
||||
target: aarch64-apple-darwin
|
||||
variant: macos-14
|
||||
steps:
|
||||
- name: Install build prerequisites
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if ! command -v autoconf >/dev/null 2>&1; then
|
||||
brew install autoconf
|
||||
fi
|
||||
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Build patched zsh
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git clone https://git.code.sf.net/p/zsh/code /tmp/zsh
|
||||
cd /tmp/zsh
|
||||
git checkout 77045ef899e53b9598bebc5a41db93a548a40ca6
|
||||
git apply "${GITHUB_WORKSPACE}/shell-tool-mcp/patches/zsh-exec-wrapper.patch"
|
||||
./Util/preconfig
|
||||
./configure
|
||||
cores="$(getconf _NPROCESSORS_ONLN)"
|
||||
make -j"${cores}"
|
||||
|
||||
dest="${GITHUB_WORKSPACE}/artifacts/vendor/${{ matrix.target }}/zsh/${{ matrix.variant }}"
|
||||
mkdir -p "$dest"
|
||||
cp Src/zsh "$dest/zsh"
|
||||
|
||||
- name: Smoke test zsh exec wrapper
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tmpdir="$(mktemp -d)"
|
||||
cat > "$tmpdir/exec-wrapper" <<'EOF'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
: "${CODEX_WRAPPER_LOG:?missing CODEX_WRAPPER_LOG}"
|
||||
printf '%s\n' "$@" > "$CODEX_WRAPPER_LOG"
|
||||
file="$1"
|
||||
shift
|
||||
if [[ "$#" -eq 0 ]]; then
|
||||
exec "$file"
|
||||
fi
|
||||
arg0="$1"
|
||||
shift
|
||||
exec -a "$arg0" "$file" "$@"
|
||||
EOF
|
||||
chmod +x "$tmpdir/exec-wrapper"
|
||||
|
||||
CODEX_WRAPPER_LOG="$tmpdir/wrapper.log" \
|
||||
EXEC_WRAPPER="$tmpdir/exec-wrapper" \
|
||||
/tmp/zsh/Src/zsh -fc '/bin/echo smoke-zsh' > "$tmpdir/stdout.txt"
|
||||
|
||||
grep -Fx "smoke-zsh" "$tmpdir/stdout.txt"
|
||||
grep -Fx "/bin/echo" "$tmpdir/wrapper.log"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: shell-tool-mcp-zsh-${{ matrix.target }}-${{ matrix.variant }}
|
||||
path: artifacts/**
|
||||
if-no-files-found: error
|
||||
|
||||
package:
|
||||
name: Package npm module
|
||||
needs:
|
||||
- metadata
|
||||
- bash-linux
|
||||
- bash-darwin
|
||||
- zsh-linux
|
||||
- zsh-darwin
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
PACKAGE_VERSION: ${{ needs.metadata.outputs.version }}
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v5
|
||||
with:
|
||||
run_install: false
|
||||
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
|
||||
- name: Install JavaScript dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
- name: Build (shell-tool-mcp)
|
||||
run: pnpm --filter @openai/codex-shell-tool-mcp run build
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Assemble staging directory
|
||||
id: staging
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
staging="${STAGING_DIR}"
|
||||
mkdir -p "$staging" "$staging/vendor"
|
||||
cp shell-tool-mcp/README.md "$staging/"
|
||||
cp shell-tool-mcp/package.json "$staging/"
|
||||
|
||||
found_vendor="false"
|
||||
shopt -s nullglob
|
||||
for vendor_dir in artifacts/*/vendor; do
|
||||
rsync -av "$vendor_dir/" "$staging/vendor/"
|
||||
found_vendor="true"
|
||||
done
|
||||
if [[ "$found_vendor" == "false" ]]; then
|
||||
echo "No vendor payloads were downloaded."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
node - <<'NODE'
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const stagingDir = process.env.STAGING_DIR;
|
||||
const version = process.env.PACKAGE_VERSION;
|
||||
const pkgPath = path.join(stagingDir, "package.json");
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||
pkg.version = version;
|
||||
fs.writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\n");
|
||||
NODE
|
||||
|
||||
echo "dir=$staging" >> "$GITHUB_OUTPUT"
|
||||
env:
|
||||
STAGING_DIR: ${{ runner.temp }}/shell-tool-mcp
|
||||
|
||||
- name: Ensure binaries are executable
|
||||
env:
|
||||
STAGING_DIR: ${{ steps.staging.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
chmod +x \
|
||||
"$STAGING_DIR"/vendor/*/bash/*/bash \
|
||||
"$STAGING_DIR"/vendor/*/zsh/*/zsh
|
||||
|
||||
- name: Create npm tarball
|
||||
shell: bash
|
||||
env:
|
||||
STAGING_DIR: ${{ steps.staging.outputs.dir }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p dist/npm
|
||||
pack_info=$(cd "$STAGING_DIR" && npm pack --ignore-scripts --json --pack-destination "${GITHUB_WORKSPACE}/dist/npm")
|
||||
filename=$(PACK_INFO="$pack_info" node -e 'const data = JSON.parse(process.env.PACK_INFO); console.log(data[0].filename);')
|
||||
mv "dist/npm/${filename}" "dist/npm/codex-shell-tool-mcp-npm-${PACKAGE_VERSION}.tgz"
|
||||
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: codex-shell-tool-mcp-npm
|
||||
path: dist/npm/codex-shell-tool-mcp-npm-${{ env.PACKAGE_VERSION }}.tgz
|
||||
if-no-files-found: error
|
||||
|
||||
publish:
|
||||
name: Publish npm package
|
||||
needs:
|
||||
- metadata
|
||||
- package
|
||||
if: ${{ inputs.publish && needs.metadata.outputs.should_publish == 'true' }}
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
id-token: write
|
||||
contents: read
|
||||
steps:
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: ${{ env.NODE_VERSION }}
|
||||
registry-url: https://registry.npmjs.org
|
||||
scope: "@openai"
|
||||
|
||||
# Trusted publishing requires npm CLI version 11.5.1 or later.
|
||||
- name: Update npm
|
||||
run: npm install -g npm@latest
|
||||
|
||||
- name: Download npm tarball
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
name: codex-shell-tool-mcp-npm
|
||||
path: dist/npm
|
||||
|
||||
- name: Publish to npm
|
||||
env:
|
||||
NPM_TAG: ${{ needs.metadata.outputs.npm_tag }}
|
||||
VERSION: ${{ needs.metadata.outputs.version }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
tag_args=()
|
||||
if [[ -n "${NPM_TAG}" ]]; then
|
||||
tag_args+=(--tag "${NPM_TAG}")
|
||||
fi
|
||||
npm publish "dist/npm/codex-shell-tool-mcp-npm-${VERSION}.tgz" "${tag_args[@]}"
|
||||
15
MODULE.bazel.lock
generated
4
android/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.gradle/
|
||||
local.properties
|
||||
**/build/
|
||||
*.iml
|
||||
26
android/OAI_Codex-Blossom_Primary.svg
Normal file
|
After Width: | Height: | Size: 791 KiB |
122
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,122 @@
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.tasks.Sync
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
val minAndroidJavaVersion = 17
|
||||
val maxAndroidJavaVersion = 21
|
||||
val hostJavaMajorVersion = JavaVersion.current().majorVersion.toIntOrNull()
|
||||
?: throw GradleException("Unable to determine Java version from ${JavaVersion.current()}.")
|
||||
if (hostJavaMajorVersion < minAndroidJavaVersion) {
|
||||
throw GradleException(
|
||||
"Android service build requires Java ${minAndroidJavaVersion}+ (tested through Java ${maxAndroidJavaVersion}). Found Java ${hostJavaMajorVersion}."
|
||||
)
|
||||
}
|
||||
val androidJavaTargetVersion = hostJavaMajorVersion.coerceAtMost(maxAndroidJavaVersion)
|
||||
val androidJavaVersion = JavaVersion.toVersion(androidJavaTargetVersion)
|
||||
|
||||
android {
|
||||
namespace = "com.openai.codex.agent"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.openai.codex.agent"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = androidJavaVersion
|
||||
targetCompatibility = androidJavaVersion
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs.useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
val repoRoot = rootProject.projectDir.parentFile
|
||||
val skipAndroidLto = providers
|
||||
.gradleProperty("codexAndroidSkipLto")
|
||||
.orElse(providers.environmentVariable("CODEX_ANDROID_SKIP_LTO"))
|
||||
.orNull
|
||||
?.let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||
?: false
|
||||
val codexCargoProfileDir = if (skipAndroidLto) "android-release-no-lto" else "release"
|
||||
val agentPlatformStubSdkZip = providers
|
||||
.gradleProperty("agentPlatformStubSdkZip")
|
||||
.orElse(providers.environmentVariable("ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP"))
|
||||
val extractedAgentPlatformJar = layout.buildDirectory.file(
|
||||
"generated/agent-platform/android-agent-platform-stub-sdk.jar"
|
||||
)
|
||||
val codexTargets = mapOf(
|
||||
"arm64-v8a" to "aarch64-linux-android",
|
||||
"x86_64" to "x86_64-linux-android",
|
||||
)
|
||||
val codexJniDir = layout.buildDirectory.dir("generated/codex-jni")
|
||||
val extractAgentPlatformStubSdk = tasks.register<Sync>("extractAgentPlatformStubSdk") {
|
||||
val sdkZip = agentPlatformStubSdkZip.orNull
|
||||
?: throw GradleException(
|
||||
"Set ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP or -PagentPlatformStubSdkZip to the Android Agent Platform stub SDK zip."
|
||||
)
|
||||
val outputDir = extractedAgentPlatformJar.get().asFile.parentFile
|
||||
from(zipTree(sdkZip)) {
|
||||
include("payloads/compile_only/android-agent-platform-stub-sdk.jar")
|
||||
eachFile { path = name }
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
into(outputDir)
|
||||
}
|
||||
|
||||
val syncCodexCliJniLibs = tasks.register<Sync>("syncCodexCliJniLibs") {
|
||||
val outputDir = codexJniDir
|
||||
into(outputDir)
|
||||
dependsOn(rootProject.tasks.named("buildCodexCliNative"))
|
||||
|
||||
codexTargets.forEach { (abi, triple) ->
|
||||
val binary = file("${repoRoot}/codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
|
||||
from(binary) {
|
||||
into(abi)
|
||||
rename { "libcodex.so" }
|
||||
}
|
||||
}
|
||||
|
||||
doFirst {
|
||||
codexTargets.forEach { (abi, triple) ->
|
||||
val binary = file("${repoRoot}/codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
|
||||
if (!binary.exists()) {
|
||||
throw GradleException(
|
||||
"Missing codex binary for ${abi} at ${binary}. The Gradle native build task should have produced it."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android.sourceSets["main"].jniLibs.srcDir(codexJniDir.get().asFile)
|
||||
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn(syncCodexCliJniLibs)
|
||||
dependsOn(extractAgentPlatformStubSdk)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":bridge"))
|
||||
compileOnly(files(extractedAgentPlatformJar))
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.json:json:20240303")
|
||||
}
|
||||
1
android/app/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# Keep empty for now.
|
||||
67
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,67 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.DUMP" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
|
||||
<uses-permission android:name="android.permission.MANAGE_AGENTS" />
|
||||
<uses-permission android:name="android.permission.START_AGENT_REQUESTS" />
|
||||
<uses-permission android:name="android.permission.START_GENIE_EXECUTION" />
|
||||
<uses-permission android:name="android.permission.OBSERVE_AGENT_SESSIONS" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent>
|
||||
</queries>
|
||||
|
||||
<application
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="false"
|
||||
android:extractNativeLibs="true"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:roundIcon="@mipmap/ic_launcher_round">
|
||||
|
||||
<service
|
||||
android:name=".CodexAgentService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_AGENT_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.app.agent.AgentService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:launchMode="singleTop">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".CreateSessionActivity"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="singleTop"
|
||||
android:taskAffinity="com.openai.codex.agent.create"
|
||||
android:theme="@style/CodexCreateSessionTheme">
|
||||
<intent-filter>
|
||||
<action android:name="com.openai.codex.agent.action.CREATE_SESSION" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.app.agent.action.HANDLE_SESSION" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".SessionDetailActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,781 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.HostedCodexConfig
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import java.io.BufferedWriter
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CopyOnWriteArraySet
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.concurrent.thread
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object AgentCodexAppServerClient {
|
||||
private const val TAG = "AgentCodexClient"
|
||||
private const val REQUEST_TIMEOUT_MS = 30_000L
|
||||
private const val DEFAULT_AGENT_MODEL = "gpt-5.3-codex"
|
||||
private const val AGENT_APP_SERVER_RUST_LOG = "warn"
|
||||
|
||||
data class RuntimeStatus(
|
||||
val authenticated: Boolean,
|
||||
val accountEmail: String?,
|
||||
val clientCount: Int,
|
||||
val modelProviderId: String,
|
||||
val configuredModel: String?,
|
||||
val effectiveModel: String?,
|
||||
val upstreamBaseUrl: String,
|
||||
val frameworkResponsesPath: String,
|
||||
)
|
||||
|
||||
data class ChatGptLoginSession(
|
||||
val loginId: String,
|
||||
val authUrl: String,
|
||||
)
|
||||
|
||||
fun interface RuntimeStatusListener {
|
||||
fun onRuntimeStatusChanged(status: RuntimeStatus?)
|
||||
}
|
||||
|
||||
private val lifecycleLock = Any()
|
||||
private val requestIdSequence = AtomicInteger(1)
|
||||
private val activeRequests = AtomicInteger(0)
|
||||
private val pendingResponses = ConcurrentHashMap<String, LinkedBlockingQueue<JSONObject>>()
|
||||
private val notifications = LinkedBlockingQueue<JSONObject>()
|
||||
private val runtimeStatusListeners = CopyOnWriteArraySet<RuntimeStatusListener>()
|
||||
|
||||
private var process: Process? = null
|
||||
private var writer: BufferedWriter? = null
|
||||
private var stdoutThread: Thread? = null
|
||||
private var stderrThread: Thread? = null
|
||||
private var localProxy: AgentLocalCodexProxy? = null
|
||||
private var initialized = false
|
||||
@Volatile
|
||||
private var cachedRuntimeStatus: RuntimeStatus? = null
|
||||
@Volatile
|
||||
private var applicationContext: Context? = null
|
||||
@Volatile
|
||||
private var activeFrameworkSessionId: String? = null
|
||||
private val runtimeStatusRefreshInFlight = AtomicBoolean(false)
|
||||
|
||||
fun currentRuntimeStatus(): RuntimeStatus? = cachedRuntimeStatus
|
||||
|
||||
fun registerRuntimeStatusListener(listener: RuntimeStatusListener) {
|
||||
runtimeStatusListeners += listener
|
||||
listener.onRuntimeStatusChanged(cachedRuntimeStatus)
|
||||
}
|
||||
|
||||
fun unregisterRuntimeStatusListener(listener: RuntimeStatusListener) {
|
||||
runtimeStatusListeners -= listener
|
||||
}
|
||||
|
||||
fun refreshRuntimeStatusAsync(
|
||||
context: Context,
|
||||
refreshToken: Boolean = false,
|
||||
) {
|
||||
if (!runtimeStatusRefreshInFlight.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
thread(name = "AgentRuntimeStatusRefresh") {
|
||||
try {
|
||||
runCatching {
|
||||
readRuntimeStatus(context, refreshToken)
|
||||
}.onFailure {
|
||||
updateCachedRuntimeStatus(null)
|
||||
}
|
||||
} finally {
|
||||
runtimeStatusRefreshInFlight.set(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestText(
|
||||
context: Context,
|
||||
instructions: String,
|
||||
prompt: String,
|
||||
outputSchema: JSONObject? = null,
|
||||
dynamicTools: JSONArray? = null,
|
||||
toolCallHandler: ((String, JSONObject) -> JSONObject)? = null,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
requestTimeoutMs: Long = REQUEST_TIMEOUT_MS,
|
||||
frameworkSessionId: String? = null,
|
||||
): String = synchronized(lifecycleLock) {
|
||||
ensureStarted(context.applicationContext)
|
||||
val previousFrameworkSessionId = activeFrameworkSessionId
|
||||
activeFrameworkSessionId = frameworkSessionId?.trim()?.ifEmpty { null }
|
||||
activeRequests.incrementAndGet()
|
||||
updateClientCount()
|
||||
try {
|
||||
Log.i(
|
||||
TAG,
|
||||
"requestText start tools=${dynamicTools?.length() ?: 0} prompt=${prompt.take(160)}",
|
||||
)
|
||||
notifications.clear()
|
||||
val threadId = startThread(
|
||||
context = context.applicationContext,
|
||||
instructions = instructions,
|
||||
dynamicTools = dynamicTools,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
startTurn(
|
||||
threadId = threadId,
|
||||
prompt = prompt,
|
||||
outputSchema = outputSchema,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
waitForTurnCompletion(toolCallHandler, requestUserInputHandler, requestTimeoutMs).also { response ->
|
||||
Log.i(TAG, "requestText completed response=${response.take(160)}")
|
||||
}
|
||||
} finally {
|
||||
activeRequests.decrementAndGet()
|
||||
updateClientCount()
|
||||
activeFrameworkSessionId = previousFrameworkSessionId
|
||||
}
|
||||
}
|
||||
|
||||
fun readRuntimeStatus(
|
||||
context: Context,
|
||||
refreshToken: Boolean = false,
|
||||
): RuntimeStatus = synchronized(lifecycleLock) {
|
||||
ensureStarted(context.applicationContext)
|
||||
activeRequests.incrementAndGet()
|
||||
updateClientCount()
|
||||
try {
|
||||
val accountResponse = request(
|
||||
method = "account/read",
|
||||
params = JSONObject().put("refreshToken", refreshToken),
|
||||
)
|
||||
val configResponse = request(
|
||||
method = "config/read",
|
||||
params = JSONObject().put("includeLayers", false),
|
||||
)
|
||||
parseRuntimeStatus(context.applicationContext, accountResponse, configResponse)
|
||||
.also(::updateCachedRuntimeStatus)
|
||||
} finally {
|
||||
activeRequests.decrementAndGet()
|
||||
updateClientCount()
|
||||
}
|
||||
}
|
||||
|
||||
fun startChatGptLogin(context: Context): ChatGptLoginSession = synchronized(lifecycleLock) {
|
||||
ensureStarted(context.applicationContext)
|
||||
val response = request(
|
||||
method = "account/login/start",
|
||||
params = JSONObject().put("type", "chatgpt"),
|
||||
)
|
||||
if (response.optString("type") != "chatgpt") {
|
||||
throw IOException("Unexpected login response type: ${response.optString("type")}")
|
||||
}
|
||||
return ChatGptLoginSession(
|
||||
loginId = response.optString("loginId"),
|
||||
authUrl = response.optString("authUrl"),
|
||||
)
|
||||
}
|
||||
|
||||
fun logoutAccount(context: Context) = synchronized(lifecycleLock) {
|
||||
ensureStarted(context.applicationContext)
|
||||
request(
|
||||
method = "account/logout",
|
||||
params = null,
|
||||
)
|
||||
refreshRuntimeStatusAsync(context.applicationContext)
|
||||
}
|
||||
|
||||
fun listModels(context: Context): List<AgentModelOption> = synchronized(lifecycleLock) {
|
||||
ensureStarted(context.applicationContext)
|
||||
val models = mutableListOf<AgentModelOption>()
|
||||
var cursor: String? = null
|
||||
do {
|
||||
val result = request(
|
||||
method = "model/list",
|
||||
params = JSONObject().apply {
|
||||
put("includeHidden", false)
|
||||
cursor?.let { put("cursor", it) }
|
||||
},
|
||||
)
|
||||
val data = result.optJSONArray("data") ?: JSONArray()
|
||||
for (index in 0 until data.length()) {
|
||||
val item = data.optJSONObject(index) ?: continue
|
||||
models += AgentModelOption(
|
||||
id = item.optString("id"),
|
||||
model = item.optString("model"),
|
||||
displayName = item.optString("displayName").ifBlank { item.optString("model") },
|
||||
description = item.optString("description"),
|
||||
supportedReasoningEfforts = buildList {
|
||||
val efforts = item.optJSONArray("supportedReasoningEfforts") ?: JSONArray()
|
||||
for (effortIndex in 0 until efforts.length()) {
|
||||
val effort = efforts.optJSONObject(effortIndex) ?: continue
|
||||
add(
|
||||
AgentReasoningEffortOption(
|
||||
reasoningEffort = effort.optString("reasoningEffort"),
|
||||
description = effort.optString("description"),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
defaultReasoningEffort = item.optString("defaultReasoningEffort"),
|
||||
isDefault = item.optBoolean("isDefault"),
|
||||
)
|
||||
}
|
||||
cursor = result.optNullableString("nextCursor")
|
||||
} while (cursor != null)
|
||||
models
|
||||
}
|
||||
|
||||
private fun ensureStarted(context: Context) {
|
||||
if (process?.isAlive == true && writer != null && initialized) {
|
||||
return
|
||||
}
|
||||
closeProcess()
|
||||
applicationContext = context
|
||||
notifications.clear()
|
||||
pendingResponses.clear()
|
||||
val codexHome = File(context.filesDir, "codex-home").apply(File::mkdirs)
|
||||
localProxy = AgentLocalCodexProxy { requestBody ->
|
||||
forwardResponsesRequest(context, requestBody)
|
||||
}.also(AgentLocalCodexProxy::start)
|
||||
val proxyBaseUrl = localProxy?.baseUrl
|
||||
?: throw IOException("local Agent proxy did not start")
|
||||
HostedCodexConfig.write(context, codexHome, proxyBaseUrl)
|
||||
val startedProcess = ProcessBuilder(
|
||||
listOf(
|
||||
CodexCliBinaryLocator.resolve(context).absolutePath,
|
||||
"-c",
|
||||
"enable_request_compression=false",
|
||||
"app-server",
|
||||
"--listen",
|
||||
"stdio://",
|
||||
),
|
||||
).apply {
|
||||
environment()["CODEX_HOME"] = codexHome.absolutePath
|
||||
environment()["RUST_LOG"] = AGENT_APP_SERVER_RUST_LOG
|
||||
}.start()
|
||||
process = startedProcess
|
||||
writer = startedProcess.outputStream.bufferedWriter()
|
||||
startStdoutPump(startedProcess)
|
||||
startStderrPump(startedProcess)
|
||||
initialize()
|
||||
initialized = true
|
||||
}
|
||||
|
||||
private fun closeProcess() {
|
||||
stdoutThread?.interrupt()
|
||||
stderrThread?.interrupt()
|
||||
runCatching { writer?.close() }
|
||||
writer = null
|
||||
localProxy?.close()
|
||||
localProxy = null
|
||||
process?.destroy()
|
||||
process = null
|
||||
initialized = false
|
||||
updateCachedRuntimeStatus(null)
|
||||
}
|
||||
|
||||
private fun forwardResponsesRequest(
|
||||
context: Context,
|
||||
requestBody: String,
|
||||
): AgentResponsesProxy.HttpResponse {
|
||||
val frameworkSessionId = activeFrameworkSessionId
|
||||
if (frameworkSessionId.isNullOrBlank()) {
|
||||
return AgentResponsesProxy.sendResponsesRequest(context, requestBody)
|
||||
}
|
||||
val agentManager = context.getSystemService(AgentManager::class.java)
|
||||
?: throw IOException("AgentManager unavailable for framework session transport")
|
||||
return AgentResponsesProxy.sendResponsesRequestThroughFramework(
|
||||
agentManager = agentManager,
|
||||
sessionId = frameworkSessionId,
|
||||
context = context,
|
||||
requestBody = requestBody,
|
||||
)
|
||||
}
|
||||
|
||||
private fun initialize() {
|
||||
request(
|
||||
method = "initialize",
|
||||
params = JSONObject()
|
||||
.put(
|
||||
"clientInfo",
|
||||
JSONObject()
|
||||
.put("name", "android_agent")
|
||||
.put("title", "Android Agent")
|
||||
.put("version", "0.1.0"),
|
||||
)
|
||||
.put("capabilities", JSONObject().put("experimentalApi", true)),
|
||||
)
|
||||
notify("initialized", JSONObject())
|
||||
}
|
||||
|
||||
private fun startThread(
|
||||
context: Context,
|
||||
instructions: String,
|
||||
dynamicTools: JSONArray?,
|
||||
executionSettings: SessionExecutionSettings,
|
||||
): String {
|
||||
val params = JSONObject()
|
||||
.put("approvalPolicy", "never")
|
||||
.put("sandbox", "read-only")
|
||||
.put("ephemeral", true)
|
||||
.put("cwd", context.filesDir.absolutePath)
|
||||
.put("serviceName", "android_agent")
|
||||
.put("baseInstructions", instructions)
|
||||
executionSettings.model
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { params.put("model", it) }
|
||||
if (dynamicTools != null) {
|
||||
params.put("dynamicTools", dynamicTools)
|
||||
}
|
||||
val result = request(
|
||||
method = "thread/start",
|
||||
params = params,
|
||||
)
|
||||
return result.getJSONObject("thread").getString("id")
|
||||
}
|
||||
|
||||
private fun startTurn(
|
||||
threadId: String,
|
||||
prompt: String,
|
||||
outputSchema: JSONObject?,
|
||||
executionSettings: SessionExecutionSettings,
|
||||
) {
|
||||
val turnParams = JSONObject()
|
||||
.put("threadId", threadId)
|
||||
.put(
|
||||
"input",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("type", "text")
|
||||
.put("text", prompt),
|
||||
),
|
||||
)
|
||||
executionSettings.model
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { turnParams.put("model", it) }
|
||||
executionSettings.reasoningEffort
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { turnParams.put("effort", it) }
|
||||
if (outputSchema != null) {
|
||||
turnParams.put("outputSchema", outputSchema)
|
||||
}
|
||||
request(
|
||||
method = "turn/start",
|
||||
params = turnParams,
|
||||
)
|
||||
}
|
||||
|
||||
private fun waitForTurnCompletion(
|
||||
toolCallHandler: ((String, JSONObject) -> JSONObject)?,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
|
||||
requestTimeoutMs: Long,
|
||||
): String {
|
||||
val streamedAgentMessages = mutableMapOf<String, StringBuilder>()
|
||||
var finalAgentMessage: String? = null
|
||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(requestTimeoutMs)
|
||||
while (true) {
|
||||
val remainingNanos = deadline - System.nanoTime()
|
||||
if (remainingNanos <= 0L) {
|
||||
throw IOException("Timed out waiting for Agent turn completion")
|
||||
}
|
||||
val notification = notifications.poll(remainingNanos, TimeUnit.NANOSECONDS)
|
||||
if (notification == null) {
|
||||
checkProcessAlive()
|
||||
continue
|
||||
}
|
||||
if (notification.has("id") && notification.has("method")) {
|
||||
handleServerRequest(notification, toolCallHandler, requestUserInputHandler)
|
||||
continue
|
||||
}
|
||||
val params = notification.optJSONObject("params") ?: JSONObject()
|
||||
when (notification.optString("method")) {
|
||||
"item/agentMessage/delta" -> {
|
||||
val itemId = params.optString("itemId")
|
||||
if (itemId.isNotBlank()) {
|
||||
streamedAgentMessages.getOrPut(itemId, ::StringBuilder)
|
||||
.append(params.optString("delta"))
|
||||
}
|
||||
}
|
||||
"item/commandExecution/outputDelta" -> {
|
||||
val itemId = params.optString("itemId")
|
||||
val delta = params.optString("delta")
|
||||
if (delta.isNotBlank()) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"commandExecution/outputDelta itemId=$itemId delta=${delta.take(400)}",
|
||||
)
|
||||
}
|
||||
}
|
||||
"item/started" -> {
|
||||
val item = params.optJSONObject("item")
|
||||
Log.i(
|
||||
TAG,
|
||||
"item/started type=${item?.optString("type")} tool=${item?.optString("tool")}",
|
||||
)
|
||||
}
|
||||
"item/completed" -> {
|
||||
val item = params.optJSONObject("item") ?: continue
|
||||
Log.i(
|
||||
TAG,
|
||||
"item/completed type=${item.optString("type")} status=${item.optString("status")} tool=${item.optString("tool")}",
|
||||
)
|
||||
if (item.optString("type") == "commandExecution") {
|
||||
Log.i(TAG, "commandExecution/completed item=$item")
|
||||
}
|
||||
if (item.optString("type") == "agentMessage") {
|
||||
val itemId = item.optString("id")
|
||||
val text = item.optString("text").ifBlank {
|
||||
streamedAgentMessages[itemId]?.toString().orEmpty()
|
||||
}
|
||||
if (text.isNotBlank()) {
|
||||
finalAgentMessage = text
|
||||
}
|
||||
}
|
||||
}
|
||||
"turn/completed" -> {
|
||||
val turn = params.optJSONObject("turn") ?: JSONObject()
|
||||
Log.i(
|
||||
TAG,
|
||||
"turn/completed status=${turn.optString("status")} error=${turn.opt("error")} finalMessage=${finalAgentMessage?.take(160)}",
|
||||
)
|
||||
return when (turn.optString("status")) {
|
||||
"completed" -> finalAgentMessage?.takeIf(String::isNotBlank)
|
||||
?: throw IOException("Agent turn completed without an assistant message")
|
||||
"interrupted" -> throw IOException("Agent turn interrupted")
|
||||
else -> throw IOException(
|
||||
turn.opt("error")?.toString()
|
||||
?: "Agent turn failed with status ${turn.optString("status", "unknown")}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleServerRequest(
|
||||
message: JSONObject,
|
||||
toolCallHandler: ((String, JSONObject) -> JSONObject)?,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
|
||||
) {
|
||||
val requestId = message.opt("id") ?: return
|
||||
val method = message.optString("method", "unknown")
|
||||
val params = message.optJSONObject("params") ?: JSONObject()
|
||||
Log.i(TAG, "handleServerRequest method=$method")
|
||||
when (method) {
|
||||
"item/tool/call" -> {
|
||||
if (toolCallHandler == null) {
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32601,
|
||||
message = "No Agent tool handler registered for $method",
|
||||
)
|
||||
return
|
||||
}
|
||||
val toolName = params.optString("tool").trim()
|
||||
val arguments = params.optJSONObject("arguments") ?: JSONObject()
|
||||
Log.i(TAG, "tool/call tool=$toolName arguments=$arguments")
|
||||
val result = runCatching { toolCallHandler(toolName, arguments) }
|
||||
.getOrElse { err ->
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32000,
|
||||
message = err.message ?: "Agent tool call failed",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "tool/call completed tool=$toolName result=$result")
|
||||
sendResult(requestId, result)
|
||||
}
|
||||
"item/tool/requestUserInput" -> {
|
||||
if (requestUserInputHandler == null) {
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32601,
|
||||
message = "No Agent user-input handler registered for $method",
|
||||
)
|
||||
return
|
||||
}
|
||||
val questions = params.optJSONArray("questions") ?: JSONArray()
|
||||
Log.i(TAG, "requestUserInput questions=$questions")
|
||||
val result = runCatching { requestUserInputHandler(questions) }
|
||||
.getOrElse { err ->
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32000,
|
||||
message = err.message ?: "Agent user input request failed",
|
||||
)
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "requestUserInput completed result=$result")
|
||||
sendResult(requestId, result)
|
||||
}
|
||||
else -> {
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32601,
|
||||
message = "Unsupported Agent app-server request: $method",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendResult(
|
||||
requestId: Any,
|
||||
result: JSONObject,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("id", requestId)
|
||||
.put("result", result),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendError(
|
||||
requestId: Any,
|
||||
code: Int,
|
||||
message: String,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("id", requestId)
|
||||
.put(
|
||||
"error",
|
||||
JSONObject()
|
||||
.put("code", code)
|
||||
.put("message", message),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(
|
||||
method: String,
|
||||
params: JSONObject?,
|
||||
): JSONObject {
|
||||
val requestId = requestIdSequence.getAndIncrement().toString()
|
||||
val responseQueue = LinkedBlockingQueue<JSONObject>(1)
|
||||
pendingResponses[requestId] = responseQueue
|
||||
try {
|
||||
val message = JSONObject()
|
||||
.put("id", requestId)
|
||||
.put("method", method)
|
||||
if (params != null) {
|
||||
message.put("params", params)
|
||||
}
|
||||
sendMessage(message)
|
||||
val response = responseQueue.poll(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
?: throw IOException("Timed out waiting for $method response")
|
||||
val error = response.optJSONObject("error")
|
||||
if (error != null) {
|
||||
throw IOException("$method failed: ${error.optString("message", error.toString())}")
|
||||
}
|
||||
return response.optJSONObject("result") ?: JSONObject()
|
||||
} finally {
|
||||
pendingResponses.remove(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
method: String,
|
||||
params: JSONObject,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("method", method)
|
||||
.put("params", params),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendMessage(message: JSONObject) {
|
||||
val activeWriter = writer ?: throw IOException("Agent app-server writer unavailable")
|
||||
activeWriter.write(message.toString())
|
||||
activeWriter.newLine()
|
||||
activeWriter.flush()
|
||||
}
|
||||
|
||||
private fun startStdoutPump(process: Process) {
|
||||
stdoutThread = Thread {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
if (line.isBlank()) {
|
||||
return@forEach
|
||||
}
|
||||
val message = runCatching { JSONObject(line) }
|
||||
.getOrElse { err ->
|
||||
Log.w(TAG, "Failed to parse Agent app-server stdout line", err)
|
||||
return@forEach
|
||||
}
|
||||
routeInbound(message)
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
it.name = "AgentCodexStdout"
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startStderrPump(process: Process) {
|
||||
stderrThread = Thread {
|
||||
process.errorStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
logAgentStderrLine(line)
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
it.name = "AgentCodexStderr"
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeInbound(message: JSONObject) {
|
||||
if (message.has("id") && !message.has("method")) {
|
||||
pendingResponses[message.get("id").toString()]?.offer(message)
|
||||
return
|
||||
}
|
||||
handleInboundSideEffects(message)
|
||||
notifications.offer(message)
|
||||
}
|
||||
|
||||
private fun handleInboundSideEffects(message: JSONObject) {
|
||||
when (message.optString("method")) {
|
||||
"account/updated" -> {
|
||||
applicationContext?.let { context ->
|
||||
refreshRuntimeStatusAsync(context)
|
||||
}
|
||||
}
|
||||
"account/login/completed" -> {
|
||||
applicationContext?.let { context ->
|
||||
refreshRuntimeStatusAsync(context, refreshToken = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkProcessAlive() {
|
||||
val activeProcess = process ?: throw IOException("Agent app-server unavailable")
|
||||
if (!activeProcess.isAlive) {
|
||||
initialized = false
|
||||
updateCachedRuntimeStatus(null)
|
||||
throw IOException("Agent app-server exited with code ${activeProcess.exitValue()}")
|
||||
}
|
||||
}
|
||||
|
||||
private fun logAgentStderrLine(line: String) {
|
||||
if (line.isBlank()) {
|
||||
return
|
||||
}
|
||||
when {
|
||||
line.contains(" ERROR ") || line.startsWith("ERROR") -> Log.e(TAG, line)
|
||||
line.contains(" WARN ") || line.startsWith("WARN") -> Log.w(TAG, line)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateClientCount() {
|
||||
val currentStatus = cachedRuntimeStatus ?: return
|
||||
val updatedStatus = currentStatus.copy(clientCount = activeRequests.get())
|
||||
updateCachedRuntimeStatus(updatedStatus)
|
||||
}
|
||||
|
||||
private fun updateCachedRuntimeStatus(status: RuntimeStatus?) {
|
||||
if (cachedRuntimeStatus == status) {
|
||||
return
|
||||
}
|
||||
cachedRuntimeStatus = status
|
||||
runtimeStatusListeners.forEach { listener ->
|
||||
runCatching {
|
||||
listener.onRuntimeStatusChanged(status)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Runtime status listener failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseRuntimeStatus(
|
||||
context: Context,
|
||||
accountResponse: JSONObject,
|
||||
configResponse: JSONObject,
|
||||
): RuntimeStatus {
|
||||
val account = accountResponse.optJSONObject("account")
|
||||
val config = configResponse.optJSONObject("config") ?: JSONObject()
|
||||
val configuredModel = config.optNullableString("model")
|
||||
val effectiveModel = configuredModel ?: DEFAULT_AGENT_MODEL
|
||||
val configuredProvider = config.optNullableString("model_provider")
|
||||
val accountType = account?.optNullableString("type").orEmpty()
|
||||
val authMode = runCatching {
|
||||
AgentResponsesProxy.loadAuthSnapshot(File(context.filesDir, "codex-home/auth.json")).authMode
|
||||
}.getOrElse {
|
||||
if (accountType == "apiKey") {
|
||||
"apiKey"
|
||||
} else {
|
||||
"chatgpt"
|
||||
}
|
||||
}
|
||||
val upstreamBaseUrl = AgentResponsesProxy.buildResponsesBaseUrl(
|
||||
upstreamBaseUrl = resolveUpstreamBaseUrl(
|
||||
config = config,
|
||||
accountType = accountType,
|
||||
configuredProvider = configuredProvider,
|
||||
),
|
||||
authMode = authMode,
|
||||
)
|
||||
return RuntimeStatus(
|
||||
authenticated = account != null,
|
||||
accountEmail = account?.optNullableString("email"),
|
||||
clientCount = activeRequests.get(),
|
||||
modelProviderId = configuredProvider ?: inferModelProviderId(accountType),
|
||||
configuredModel = configuredModel,
|
||||
effectiveModel = effectiveModel,
|
||||
upstreamBaseUrl = upstreamBaseUrl,
|
||||
frameworkResponsesPath = AgentResponsesProxy.buildFrameworkResponsesPath(upstreamBaseUrl),
|
||||
)
|
||||
}
|
||||
|
||||
private fun inferModelProviderId(accountType: String): String {
|
||||
return when (accountType) {
|
||||
"chatgpt" -> "chatgpt"
|
||||
"apiKey" -> "openai"
|
||||
else -> "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject.optNullableString(name: String): String? = when {
|
||||
isNull(name) -> null
|
||||
else -> optString(name).ifBlank { null }
|
||||
}
|
||||
|
||||
private fun resolveUpstreamBaseUrl(
|
||||
config: JSONObject,
|
||||
accountType: String,
|
||||
configuredProvider: String?,
|
||||
): String {
|
||||
val modelProviders = config.optJSONObject("model_providers")
|
||||
val configuredProviderBaseUrl = configuredProvider?.let { providerId ->
|
||||
modelProviders
|
||||
?.optJSONObject(providerId)
|
||||
?.optString("base_url")
|
||||
?.ifBlank { null }
|
||||
}
|
||||
if (
|
||||
configuredProviderBaseUrl != null &&
|
||||
configuredProvider != HostedCodexConfig.ANDROID_HTTP_PROVIDER_ID
|
||||
) {
|
||||
return configuredProviderBaseUrl
|
||||
}
|
||||
return when (accountType) {
|
||||
"chatgpt" -> config.optString("chatgpt_base_url")
|
||||
.ifBlank { "https://chatgpt.com/backend-api/codex" }
|
||||
"apiKey" -> config.optString("openai_base_url")
|
||||
.ifBlank { "https://api.openai.com/v1" }
|
||||
else -> config.optString("openai_base_url")
|
||||
.ifBlank {
|
||||
config.optString("chatgpt_base_url")
|
||||
.ifBlank { "provider-default" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class AgentFrameworkToolBridge(
|
||||
private val context: Context,
|
||||
private val sessionController: AgentSessionController,
|
||||
) {
|
||||
companion object {
|
||||
private const val TAG = "AgentFrameworkTool"
|
||||
private val DISALLOWED_TARGET_PACKAGES = setOf(
|
||||
"com.android.shell",
|
||||
"com.android.systemui",
|
||||
"com.openai.codex.agent",
|
||||
"com.openai.codex.genie",
|
||||
)
|
||||
const val START_DIRECT_SESSION_TOOL = "android_framework_sessions_start_direct"
|
||||
const val LIST_SESSIONS_TOOL = "android_framework_sessions_list"
|
||||
const val ANSWER_QUESTION_TOOL = "android_framework_sessions_answer_question"
|
||||
const val ATTACH_TARGET_TOOL = "android_framework_sessions_attach_target"
|
||||
const val CANCEL_SESSION_TOOL = "android_framework_sessions_cancel"
|
||||
|
||||
internal fun parseStartDirectSessionArguments(
|
||||
arguments: JSONObject,
|
||||
userObjective: String,
|
||||
isEligibleTargetPackage: (String) -> Boolean,
|
||||
): StartDirectSessionRequest {
|
||||
val targetsJson = arguments.optJSONArray("targets")
|
||||
?: throw IOException("Framework session tool arguments missing targets")
|
||||
val rejectedPackages = mutableListOf<String>()
|
||||
val targets = buildList {
|
||||
for (index in 0 until targetsJson.length()) {
|
||||
val target = targetsJson.optJSONObject(index) ?: continue
|
||||
val packageName = target.optString("packageName").trim()
|
||||
if (packageName.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
if (!isEligibleTargetPackage(packageName)) {
|
||||
rejectedPackages += packageName
|
||||
continue
|
||||
}
|
||||
val objective = target.optString("objective").trim().ifEmpty { userObjective }
|
||||
val finalPresentationPolicy = target.optString("finalPresentationPolicy").trim()
|
||||
val defaultFinalPresentationPolicy = arguments.optString("finalPresentationPolicy").trim()
|
||||
add(
|
||||
AgentDelegationTarget(
|
||||
packageName = packageName,
|
||||
objective = objective,
|
||||
finalPresentationPolicy =
|
||||
SessionFinalPresentationPolicy.fromWireValue(finalPresentationPolicy)
|
||||
?: SessionFinalPresentationPolicy.fromWireValue(defaultFinalPresentationPolicy)
|
||||
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
),
|
||||
)
|
||||
}
|
||||
}.distinctBy(AgentDelegationTarget::packageName)
|
||||
if (targets.isEmpty()) {
|
||||
if (rejectedPackages.isNotEmpty()) {
|
||||
throw IOException(
|
||||
"Framework session tool selected missing or disallowed package(s): ${rejectedPackages.joinToString(", ")}",
|
||||
)
|
||||
}
|
||||
throw IOException("Framework session tool did not select an eligible target package")
|
||||
}
|
||||
val allowDetachedMode = arguments.optBoolean("allowDetachedMode", true)
|
||||
val detachedPolicyTargets = targets.filter { it.finalPresentationPolicy.requiresDetachedMode() }
|
||||
if (!allowDetachedMode && detachedPolicyTargets.isNotEmpty()) {
|
||||
throw IOException(
|
||||
"Framework session tool selected detached final presentation without allowDetachedMode: ${detachedPolicyTargets.joinToString(", ") { it.packageName }}",
|
||||
)
|
||||
}
|
||||
return StartDirectSessionRequest(
|
||||
plan = AgentDelegationPlan(
|
||||
originalObjective = userObjective,
|
||||
targets = targets,
|
||||
rationale = arguments.optString("reason").trim().ifEmpty { null },
|
||||
usedOverride = false,
|
||||
),
|
||||
allowDetachedMode = allowDetachedMode,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class StartDirectSessionRequest(
|
||||
val plan: AgentDelegationPlan,
|
||||
val allowDetachedMode: Boolean,
|
||||
)
|
||||
|
||||
fun buildPlanningToolSpecs(): JSONArray {
|
||||
return JSONArray().put(buildStartDirectSessionToolSpec())
|
||||
}
|
||||
|
||||
fun buildQuestionResolutionToolSpecs(): JSONArray {
|
||||
return JSONArray()
|
||||
.put(buildListSessionsToolSpec())
|
||||
.put(buildAnswerQuestionToolSpec())
|
||||
}
|
||||
|
||||
fun buildSessionManagementToolSpecs(): JSONArray {
|
||||
return buildQuestionResolutionToolSpecs()
|
||||
.put(buildAttachTargetToolSpec())
|
||||
.put(buildCancelSessionToolSpec())
|
||||
}
|
||||
|
||||
fun handleToolCall(
|
||||
toolName: String,
|
||||
arguments: JSONObject,
|
||||
userObjective: String,
|
||||
onSessionStarted: ((SessionStartResult) -> Unit)? = null,
|
||||
focusedSessionId: String? = null,
|
||||
): JSONObject {
|
||||
Log.i(TAG, "handleToolCall tool=$toolName arguments=$arguments")
|
||||
return when (toolName) {
|
||||
START_DIRECT_SESSION_TOOL -> {
|
||||
val request = parseStartDirectSessionArguments(
|
||||
arguments = arguments,
|
||||
userObjective = userObjective,
|
||||
isEligibleTargetPackage = ::isEligibleTargetPackage,
|
||||
)
|
||||
val startedSession = sessionController.startDirectSession(
|
||||
plan = request.plan,
|
||||
allowDetachedMode = request.allowDetachedMode,
|
||||
)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Started framework sessions parent=${startedSession.parentSessionId} children=${startedSession.childSessionIds}",
|
||||
)
|
||||
onSessionStarted?.invoke(startedSession)
|
||||
successText(
|
||||
JSONObject()
|
||||
.put("parentSessionId", startedSession.parentSessionId)
|
||||
.put("childSessionIds", JSONArray(startedSession.childSessionIds))
|
||||
.put("plannedTargets", JSONArray(startedSession.plannedTargets))
|
||||
.put("geniePackage", startedSession.geniePackage)
|
||||
.toString(),
|
||||
)
|
||||
}
|
||||
LIST_SESSIONS_TOOL -> {
|
||||
val snapshot = sessionController.loadSnapshot(focusedSessionId)
|
||||
successText(renderSessionSnapshot(snapshot).toString())
|
||||
}
|
||||
ANSWER_QUESTION_TOOL -> {
|
||||
val sessionId = requireString(arguments, "sessionId")
|
||||
val answer = requireString(arguments, "answer")
|
||||
val parentSessionId = arguments.optString("parentSessionId").trim().ifEmpty { null }
|
||||
sessionController.answerQuestion(sessionId, answer, parentSessionId)
|
||||
successText("Answered framework session $sessionId.")
|
||||
}
|
||||
ATTACH_TARGET_TOOL -> {
|
||||
val sessionId = requireString(arguments, "sessionId")
|
||||
sessionController.attachTarget(sessionId)
|
||||
successText("Requested target attach for framework session $sessionId.")
|
||||
}
|
||||
CANCEL_SESSION_TOOL -> {
|
||||
val sessionId = requireString(arguments, "sessionId")
|
||||
sessionController.cancelSession(sessionId)
|
||||
successText("Cancelled framework session $sessionId.")
|
||||
}
|
||||
else -> throw IOException("Unsupported framework session tool: $toolName")
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildStartDirectSessionToolSpec(): JSONObject {
|
||||
return JSONObject()
|
||||
.put("name", START_DIRECT_SESSION_TOOL)
|
||||
.put(
|
||||
"description",
|
||||
"Start direct parent and child framework sessions for one or more target Android packages.",
|
||||
)
|
||||
.put(
|
||||
"inputSchema",
|
||||
JSONObject()
|
||||
.put("type", "object")
|
||||
.put(
|
||||
"properties",
|
||||
JSONObject()
|
||||
.put(
|
||||
"targets",
|
||||
JSONObject()
|
||||
.put("type", "array")
|
||||
.put(
|
||||
"items",
|
||||
JSONObject()
|
||||
.put("type", "object")
|
||||
.put(
|
||||
"properties",
|
||||
JSONObject()
|
||||
.put("packageName", stringSchema("Installed target Android package name."))
|
||||
.put("objective", stringSchema("Delegated free-form objective for the child Genie."))
|
||||
.put(
|
||||
"finalPresentationPolicy",
|
||||
stringSchema(
|
||||
"Required final target presentation: ATTACHED, DETACHED_HIDDEN, DETACHED_SHOWN, or AGENT_CHOICE.",
|
||||
),
|
||||
),
|
||||
)
|
||||
.put(
|
||||
"required",
|
||||
JSONArray()
|
||||
.put("packageName")
|
||||
.put("finalPresentationPolicy"),
|
||||
)
|
||||
.put("additionalProperties", false),
|
||||
),
|
||||
)
|
||||
.put("reason", stringSchema("Short explanation for why these target packages were selected."))
|
||||
.put(
|
||||
"allowDetachedMode",
|
||||
JSONObject()
|
||||
.put("type", "boolean")
|
||||
.put("description", "Whether Genie child sessions may use detached target mode."),
|
||||
),
|
||||
)
|
||||
.put("required", JSONArray().put("targets"))
|
||||
.put("additionalProperties", false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildListSessionsToolSpec(): JSONObject {
|
||||
return JSONObject()
|
||||
.put("name", LIST_SESSIONS_TOOL)
|
||||
.put("description", "List the current Android framework sessions visible to the Agent.")
|
||||
.put(
|
||||
"inputSchema",
|
||||
JSONObject()
|
||||
.put("type", "object")
|
||||
.put("properties", JSONObject())
|
||||
.put("additionalProperties", false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAnswerQuestionToolSpec(): JSONObject {
|
||||
return JSONObject()
|
||||
.put("name", ANSWER_QUESTION_TOOL)
|
||||
.put("description", "Answer a waiting Android framework session question.")
|
||||
.put(
|
||||
"inputSchema",
|
||||
JSONObject()
|
||||
.put("type", "object")
|
||||
.put(
|
||||
"properties",
|
||||
JSONObject()
|
||||
.put("sessionId", stringSchema("Framework session id to answer."))
|
||||
.put("answer", stringSchema("Free-form answer text."))
|
||||
.put("parentSessionId", stringSchema("Optional parent framework session id for trace publication.")),
|
||||
)
|
||||
.put("required", JSONArray().put("sessionId").put("answer"))
|
||||
.put("additionalProperties", false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildAttachTargetToolSpec(): JSONObject {
|
||||
return JSONObject()
|
||||
.put("name", ATTACH_TARGET_TOOL)
|
||||
.put("description", "Request the framework to attach the detached target back to the current display.")
|
||||
.put(
|
||||
"inputSchema",
|
||||
JSONObject()
|
||||
.put("type", "object")
|
||||
.put(
|
||||
"properties",
|
||||
JSONObject().put("sessionId", stringSchema("Framework session id whose target should be attached.")),
|
||||
)
|
||||
.put("required", JSONArray().put("sessionId"))
|
||||
.put("additionalProperties", false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun buildCancelSessionToolSpec(): JSONObject {
|
||||
return JSONObject()
|
||||
.put("name", CANCEL_SESSION_TOOL)
|
||||
.put("description", "Cancel an Android framework session.")
|
||||
.put(
|
||||
"inputSchema",
|
||||
JSONObject()
|
||||
.put("type", "object")
|
||||
.put(
|
||||
"properties",
|
||||
JSONObject().put("sessionId", stringSchema("Framework session id to cancel.")),
|
||||
)
|
||||
.put("required", JSONArray().put("sessionId"))
|
||||
.put("additionalProperties", false),
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderSessionSnapshot(snapshot: AgentSnapshot): JSONObject {
|
||||
val sessions = JSONArray()
|
||||
snapshot.sessions.forEach { session ->
|
||||
sessions.put(
|
||||
JSONObject()
|
||||
.put("sessionId", session.sessionId)
|
||||
.put("parentSessionId", session.parentSessionId)
|
||||
.put("targetPackage", session.targetPackage)
|
||||
.put("state", session.stateLabel)
|
||||
.put("targetDetached", session.targetDetached)
|
||||
.put("targetPresentation", session.targetPresentationLabel)
|
||||
.put("targetRuntime", session.targetRuntimeLabel)
|
||||
.put(
|
||||
"requiredFinalPresentation",
|
||||
session.requiredFinalPresentationPolicy?.wireValue,
|
||||
),
|
||||
)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("available", snapshot.available)
|
||||
.put("selectedGeniePackage", snapshot.selectedGeniePackage)
|
||||
.put("selectedSessionId", snapshot.selectedSession?.sessionId)
|
||||
.put("parentSessionId", snapshot.parentSession?.sessionId)
|
||||
.put("sessions", sessions)
|
||||
}
|
||||
|
||||
private fun isEligibleTargetPackage(packageName: String): Boolean {
|
||||
if (packageName in DISALLOWED_TARGET_PACKAGES) {
|
||||
return false
|
||||
}
|
||||
return sessionController.canStartSessionForTarget(packageName)
|
||||
}
|
||||
|
||||
private fun requireString(arguments: JSONObject, fieldName: String): String {
|
||||
return arguments.optString(fieldName).trim().ifEmpty {
|
||||
throw IOException("Framework session tool requires non-empty $fieldName")
|
||||
}
|
||||
}
|
||||
|
||||
private fun successText(text: String): JSONObject {
|
||||
return JSONObject()
|
||||
.put("success", true)
|
||||
.put(
|
||||
"contentItems",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("type", "inputText")
|
||||
.put("text", text),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun stringSchema(description: String): JSONObject {
|
||||
return JSONObject()
|
||||
.put("type", "string")
|
||||
.put("description", description)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,247 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.util.Log
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.Closeable
|
||||
import java.io.EOFException
|
||||
import java.io.IOException
|
||||
import java.net.InetAddress
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.Collections
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
class AgentLocalCodexProxy(
|
||||
private val requestForwarder: (String) -> AgentResponsesProxy.HttpResponse,
|
||||
) : Closeable {
|
||||
companion object {
|
||||
private const val TAG = "AgentLocalProxy"
|
||||
}
|
||||
|
||||
private val pathSecret = UUID.randomUUID().toString().replace("-", "")
|
||||
private val loopbackAddress = InetAddress.getByName("127.0.0.1")
|
||||
private val serverSocket = ServerSocket(0, 50, loopbackAddress)
|
||||
private val closed = AtomicBoolean(false)
|
||||
private val clientSockets = Collections.synchronizedSet(mutableSetOf<Socket>())
|
||||
private val acceptThread = Thread(::acceptLoop, "AgentLocalProxy")
|
||||
|
||||
val baseUrl: String = "http://${loopbackAddress.hostAddress}:${serverSocket.localPort}/${pathSecret}/v1"
|
||||
|
||||
fun start() {
|
||||
acceptThread.start()
|
||||
logInfo("Listening on $baseUrl")
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
runCatching { serverSocket.close() }
|
||||
synchronized(clientSockets) {
|
||||
clientSockets.forEach { socket -> runCatching { socket.close() } }
|
||||
clientSockets.clear()
|
||||
}
|
||||
acceptThread.interrupt()
|
||||
}
|
||||
|
||||
private fun acceptLoop() {
|
||||
while (!closed.get()) {
|
||||
val socket = try {
|
||||
serverSocket.accept()
|
||||
} catch (err: IOException) {
|
||||
if (!closed.get()) {
|
||||
logWarn("Failed to accept local proxy connection", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
clientSockets += socket
|
||||
Thread(
|
||||
{ handleClient(socket) },
|
||||
"AgentLocalProxyClient",
|
||||
).start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleClient(socket: Socket) {
|
||||
socket.use { client ->
|
||||
try {
|
||||
val request = readRequest(client)
|
||||
logInfo("Forwarding ${request.method} ${request.forwardPath}")
|
||||
val response = forwardResponsesRequest(request)
|
||||
writeResponse(
|
||||
socket = client,
|
||||
statusCode = response.statusCode,
|
||||
body = response.body,
|
||||
path = request.forwardPath,
|
||||
)
|
||||
} catch (err: Exception) {
|
||||
if (!closed.get()) {
|
||||
logWarn("Local proxy request failed", err)
|
||||
runCatching {
|
||||
writeResponse(
|
||||
socket = client,
|
||||
statusCode = 502,
|
||||
body = err.message ?: err::class.java.simpleName,
|
||||
path = "/error",
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clientSockets -= client
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun forwardResponsesRequest(request: ParsedRequest): AgentResponsesProxy.HttpResponse {
|
||||
if (request.method != "POST") {
|
||||
return AgentResponsesProxy.HttpResponse(
|
||||
statusCode = 405,
|
||||
body = "Unsupported local proxy method: ${request.method}",
|
||||
)
|
||||
}
|
||||
if (request.forwardPath != "/v1/responses") {
|
||||
return AgentResponsesProxy.HttpResponse(
|
||||
statusCode = 404,
|
||||
body = "Unsupported local proxy path: ${request.forwardPath}",
|
||||
)
|
||||
}
|
||||
return requestForwarder(request.body.orEmpty())
|
||||
}
|
||||
|
||||
private fun readRequest(socket: Socket): ParsedRequest {
|
||||
val input = socket.getInputStream()
|
||||
val headerBuffer = ByteArrayOutputStream()
|
||||
var matched = 0
|
||||
while (matched < 4) {
|
||||
val next = input.read()
|
||||
if (next == -1) {
|
||||
throw EOFException("unexpected EOF while reading local proxy request headers")
|
||||
}
|
||||
headerBuffer.write(next)
|
||||
matched = when {
|
||||
matched == 0 && next == '\r'.code -> 1
|
||||
matched == 1 && next == '\n'.code -> 2
|
||||
matched == 2 && next == '\r'.code -> 3
|
||||
matched == 3 && next == '\n'.code -> 4
|
||||
next == '\r'.code -> 1
|
||||
else -> 0
|
||||
}
|
||||
}
|
||||
|
||||
val headerBytes = headerBuffer.toByteArray()
|
||||
val headerText = headerBytes
|
||||
.copyOfRange(0, headerBytes.size - 4)
|
||||
.toString(StandardCharsets.US_ASCII)
|
||||
val lines = headerText.split("\r\n")
|
||||
val requestLine = lines.firstOrNull()
|
||||
?: throw IOException("local proxy request line missing")
|
||||
val requestParts = requestLine.split(" ", limit = 3)
|
||||
if (requestParts.size < 2) {
|
||||
throw IOException("invalid local proxy request line: $requestLine")
|
||||
}
|
||||
|
||||
val headers = mutableMapOf<String, String>()
|
||||
lines.drop(1).forEach { line ->
|
||||
val separatorIndex = line.indexOf(':')
|
||||
if (separatorIndex <= 0) {
|
||||
return@forEach
|
||||
}
|
||||
val name = line.substring(0, separatorIndex).trim().lowercase()
|
||||
val value = line.substring(separatorIndex + 1).trim()
|
||||
headers[name] = value
|
||||
}
|
||||
|
||||
if (headers["transfer-encoding"]?.contains("chunked", ignoreCase = true) == true) {
|
||||
throw IOException("chunked local proxy requests are unsupported")
|
||||
}
|
||||
|
||||
val contentLength = headers["content-length"]?.toIntOrNull() ?: 0
|
||||
val bodyBytes = ByteArray(contentLength)
|
||||
var offset = 0
|
||||
while (offset < bodyBytes.size) {
|
||||
val read = input.read(bodyBytes, offset, bodyBytes.size - offset)
|
||||
if (read == -1) {
|
||||
throw EOFException("unexpected EOF while reading local proxy request body")
|
||||
}
|
||||
offset += read
|
||||
}
|
||||
|
||||
val rawPath = requestParts[1]
|
||||
val forwardPath = normalizeForwardPath(rawPath)
|
||||
return ParsedRequest(
|
||||
method = requestParts[0],
|
||||
forwardPath = forwardPath,
|
||||
body = if (bodyBytes.isEmpty()) null else bodyBytes.toString(StandardCharsets.UTF_8),
|
||||
)
|
||||
}
|
||||
|
||||
private fun normalizeForwardPath(rawPath: String): String {
|
||||
val expectedPrefix = "/$pathSecret"
|
||||
if (!rawPath.startsWith(expectedPrefix)) {
|
||||
throw IOException("unexpected local proxy path: $rawPath")
|
||||
}
|
||||
val strippedPath = rawPath.removePrefix(expectedPrefix)
|
||||
return if (strippedPath.isBlank()) "/" else strippedPath
|
||||
}
|
||||
|
||||
private fun writeResponse(
|
||||
socket: Socket,
|
||||
statusCode: Int,
|
||||
body: String,
|
||||
path: String,
|
||||
) {
|
||||
val bodyBytes = body.toByteArray(StandardCharsets.UTF_8)
|
||||
val contentType = when {
|
||||
path.startsWith("/v1/responses") -> "text/event-stream; charset=utf-8"
|
||||
body.trimStart().startsWith("{") || body.trimStart().startsWith("[") -> {
|
||||
"application/json; charset=utf-8"
|
||||
}
|
||||
else -> "text/plain; charset=utf-8"
|
||||
}
|
||||
val responseHeaders = buildString {
|
||||
append("HTTP/1.1 $statusCode ${reasonPhrase(statusCode)}\r\n")
|
||||
append("Content-Type: $contentType\r\n")
|
||||
append("Content-Length: ${bodyBytes.size}\r\n")
|
||||
append("Connection: close\r\n")
|
||||
append("\r\n")
|
||||
}
|
||||
|
||||
val output = socket.getOutputStream()
|
||||
output.write(responseHeaders.toByteArray(StandardCharsets.US_ASCII))
|
||||
output.write(bodyBytes)
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private fun reasonPhrase(statusCode: Int): String {
|
||||
return when (statusCode) {
|
||||
200 -> "OK"
|
||||
400 -> "Bad Request"
|
||||
401 -> "Unauthorized"
|
||||
403 -> "Forbidden"
|
||||
404 -> "Not Found"
|
||||
500 -> "Internal Server Error"
|
||||
502 -> "Bad Gateway"
|
||||
503 -> "Service Unavailable"
|
||||
else -> "Response"
|
||||
}
|
||||
}
|
||||
|
||||
private fun logInfo(message: String) {
|
||||
runCatching { Log.i(TAG, message) }
|
||||
}
|
||||
|
||||
private fun logWarn(
|
||||
message: String,
|
||||
err: Throwable,
|
||||
) {
|
||||
runCatching { Log.w(TAG, message, err) }
|
||||
}
|
||||
|
||||
private data class ParsedRequest(
|
||||
val method: String,
|
||||
val forwardPath: String,
|
||||
val body: String?,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
data class AgentModelOption(
|
||||
val id: String,
|
||||
val model: String,
|
||||
val displayName: String,
|
||||
val description: String,
|
||||
val supportedReasoningEfforts: List<AgentReasoningEffortOption>,
|
||||
val defaultReasoningEffort: String,
|
||||
val isDefault: Boolean,
|
||||
)
|
||||
|
||||
data class AgentReasoningEffortOption(
|
||||
val reasoningEffort: String,
|
||||
val description: String,
|
||||
)
|
||||
@@ -0,0 +1,198 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentSessionInfo
|
||||
|
||||
object AgentSessionStateValues {
|
||||
const val CREATED = AgentSessionInfo.STATE_CREATED
|
||||
const val RUNNING = AgentSessionInfo.STATE_RUNNING
|
||||
const val WAITING_FOR_USER = AgentSessionInfo.STATE_WAITING_FOR_USER
|
||||
const val COMPLETED = AgentSessionInfo.STATE_COMPLETED
|
||||
const val CANCELLED = AgentSessionInfo.STATE_CANCELLED
|
||||
const val FAILED = AgentSessionInfo.STATE_FAILED
|
||||
const val QUEUED = AgentSessionInfo.STATE_QUEUED
|
||||
}
|
||||
|
||||
data class ParentSessionChildSummary(
|
||||
val sessionId: String,
|
||||
val targetPackage: String?,
|
||||
val state: Int,
|
||||
val targetPresentation: Int,
|
||||
val requiredFinalPresentationPolicy: SessionFinalPresentationPolicy?,
|
||||
val latestResult: String?,
|
||||
val latestError: String?,
|
||||
)
|
||||
|
||||
data class ParentSessionRollup(
|
||||
val state: Int,
|
||||
val resultMessage: String?,
|
||||
val errorMessage: String?,
|
||||
val sessionsToAttach: List<String>,
|
||||
)
|
||||
|
||||
object AgentParentSessionAggregator {
|
||||
fun rollup(childSessions: List<ParentSessionChildSummary>): ParentSessionRollup {
|
||||
val baseState = computeParentState(childSessions.map(ParentSessionChildSummary::state))
|
||||
if (
|
||||
baseState == AgentSessionInfo.STATE_CREATED ||
|
||||
baseState == AgentSessionInfo.STATE_RUNNING ||
|
||||
baseState == AgentSessionInfo.STATE_WAITING_FOR_USER ||
|
||||
baseState == AgentSessionInfo.STATE_QUEUED
|
||||
) {
|
||||
return ParentSessionRollup(
|
||||
state = baseState,
|
||||
resultMessage = null,
|
||||
errorMessage = null,
|
||||
sessionsToAttach = emptyList(),
|
||||
)
|
||||
}
|
||||
val terminalPresentationMismatches = childSessions.mapNotNull { childSession ->
|
||||
childSession.presentationMismatch()
|
||||
}
|
||||
val sessionsToAttach = terminalPresentationMismatches
|
||||
.filter { it.requiredPolicy == SessionFinalPresentationPolicy.ATTACHED }
|
||||
.map(PresentationMismatch::sessionId)
|
||||
val blockingMismatches = terminalPresentationMismatches
|
||||
.filterNot { it.requiredPolicy == SessionFinalPresentationPolicy.ATTACHED }
|
||||
if (sessionsToAttach.isNotEmpty() && baseState == AgentSessionInfo.STATE_COMPLETED) {
|
||||
return ParentSessionRollup(
|
||||
state = AgentSessionInfo.STATE_RUNNING,
|
||||
resultMessage = null,
|
||||
errorMessage = null,
|
||||
sessionsToAttach = sessionsToAttach,
|
||||
)
|
||||
}
|
||||
if (blockingMismatches.isNotEmpty()) {
|
||||
return ParentSessionRollup(
|
||||
state = AgentSessionInfo.STATE_FAILED,
|
||||
resultMessage = null,
|
||||
errorMessage = buildPresentationMismatchError(blockingMismatches),
|
||||
sessionsToAttach = emptyList(),
|
||||
)
|
||||
}
|
||||
return when (baseState) {
|
||||
AgentSessionInfo.STATE_COMPLETED -> ParentSessionRollup(
|
||||
state = baseState,
|
||||
resultMessage = buildParentResult(childSessions),
|
||||
errorMessage = null,
|
||||
sessionsToAttach = emptyList(),
|
||||
)
|
||||
AgentSessionInfo.STATE_FAILED -> ParentSessionRollup(
|
||||
state = baseState,
|
||||
resultMessage = null,
|
||||
errorMessage = buildParentError(childSessions),
|
||||
sessionsToAttach = emptyList(),
|
||||
)
|
||||
else -> ParentSessionRollup(
|
||||
state = baseState,
|
||||
resultMessage = null,
|
||||
errorMessage = null,
|
||||
sessionsToAttach = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun computeParentState(childStates: List<Int>): Int {
|
||||
var anyWaiting = false
|
||||
var anyRunning = false
|
||||
var anyQueued = false
|
||||
var anyFailed = false
|
||||
var anyCancelled = false
|
||||
var anyCompleted = false
|
||||
childStates.forEach { state ->
|
||||
when (state) {
|
||||
AgentSessionInfo.STATE_WAITING_FOR_USER -> anyWaiting = true
|
||||
AgentSessionInfo.STATE_RUNNING -> anyRunning = true
|
||||
AgentSessionInfo.STATE_QUEUED -> anyQueued = true
|
||||
AgentSessionInfo.STATE_FAILED -> anyFailed = true
|
||||
AgentSessionInfo.STATE_CANCELLED -> anyCancelled = true
|
||||
AgentSessionInfo.STATE_COMPLETED -> anyCompleted = true
|
||||
}
|
||||
}
|
||||
return when {
|
||||
anyWaiting -> AgentSessionInfo.STATE_WAITING_FOR_USER
|
||||
anyRunning || anyQueued -> AgentSessionInfo.STATE_RUNNING
|
||||
anyFailed -> AgentSessionInfo.STATE_FAILED
|
||||
anyCompleted -> AgentSessionInfo.STATE_COMPLETED
|
||||
anyCancelled -> AgentSessionInfo.STATE_CANCELLED
|
||||
else -> AgentSessionInfo.STATE_CREATED
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildParentResult(childSessions: List<ParentSessionChildSummary>): String {
|
||||
return buildString {
|
||||
append("Completed delegated session")
|
||||
childSessions.forEach { childSession ->
|
||||
append("; ")
|
||||
append(childSession.targetPackage ?: childSession.sessionId)
|
||||
append(": ")
|
||||
append(
|
||||
childSession.latestResult
|
||||
?: childSession.latestError
|
||||
?: stateToString(childSession.state),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildParentError(childSessions: List<ParentSessionChildSummary>): String {
|
||||
return buildString {
|
||||
append("Delegated session failed")
|
||||
childSessions.forEach { childSession ->
|
||||
if (childSession.state != AgentSessionInfo.STATE_FAILED) {
|
||||
return@forEach
|
||||
}
|
||||
append("; ")
|
||||
append(childSession.targetPackage ?: childSession.sessionId)
|
||||
append(": ")
|
||||
append(childSession.latestError ?: stateToString(childSession.state))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildPresentationMismatchError(mismatches: List<PresentationMismatch>): String {
|
||||
return buildString {
|
||||
append("Delegated session completed without the required final presentation")
|
||||
mismatches.forEach { mismatch ->
|
||||
append("; ")
|
||||
append(mismatch.targetPackage ?: mismatch.sessionId)
|
||||
append(": required ")
|
||||
append(mismatch.requiredPolicy.wireValue)
|
||||
append(", actual ")
|
||||
append(targetPresentationToString(mismatch.actualPresentation))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stateToString(state: Int): String {
|
||||
return when (state) {
|
||||
AgentSessionInfo.STATE_CREATED -> "CREATED"
|
||||
AgentSessionInfo.STATE_RUNNING -> "RUNNING"
|
||||
AgentSessionInfo.STATE_WAITING_FOR_USER -> "WAITING_FOR_USER"
|
||||
AgentSessionInfo.STATE_QUEUED -> "QUEUED"
|
||||
AgentSessionInfo.STATE_COMPLETED -> "COMPLETED"
|
||||
AgentSessionInfo.STATE_CANCELLED -> "CANCELLED"
|
||||
AgentSessionInfo.STATE_FAILED -> "FAILED"
|
||||
else -> state.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun ParentSessionChildSummary.presentationMismatch(): PresentationMismatch? {
|
||||
val requiredPolicy = requiredFinalPresentationPolicy ?: return null
|
||||
if (state != AgentSessionInfo.STATE_COMPLETED || requiredPolicy.matches(targetPresentation)) {
|
||||
return null
|
||||
}
|
||||
return PresentationMismatch(
|
||||
sessionId = sessionId,
|
||||
targetPackage = targetPackage,
|
||||
requiredPolicy = requiredPolicy,
|
||||
actualPresentation = targetPresentation,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private data class PresentationMismatch(
|
||||
val sessionId: String,
|
||||
val targetPackage: String?,
|
||||
val requiredPolicy: SessionFinalPresentationPolicy,
|
||||
val actualPresentation: Int,
|
||||
)
|
||||
@@ -0,0 +1,471 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.HostedCodexConfig
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import java.io.BufferedWriter
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object AgentPlannerRuntimeManager {
|
||||
private const val TAG = "AgentPlannerRuntime"
|
||||
private val activePlannerSessions = ConcurrentHashMap<String, Boolean>()
|
||||
|
||||
fun requestText(
|
||||
context: Context,
|
||||
instructions: String,
|
||||
prompt: String,
|
||||
outputSchema: JSONObject? = null,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
requestTimeoutMs: Long = 90_000L,
|
||||
frameworkSessionId: String? = null,
|
||||
): String {
|
||||
val applicationContext = context.applicationContext
|
||||
val plannerSessionId = frameworkSessionId?.trim()?.ifEmpty { null }
|
||||
?: throw IOException("Planner runtime requires a parent session id")
|
||||
check(activePlannerSessions.putIfAbsent(plannerSessionId, true) == null) {
|
||||
"Planner runtime already active for parent session $plannerSessionId"
|
||||
}
|
||||
try {
|
||||
AgentPlannerRuntime(
|
||||
context = applicationContext,
|
||||
frameworkSessionId = plannerSessionId,
|
||||
).use { runtime ->
|
||||
return runtime.requestText(
|
||||
instructions = instructions,
|
||||
prompt = prompt,
|
||||
outputSchema = outputSchema,
|
||||
requestUserInputHandler = requestUserInputHandler,
|
||||
executionSettings = executionSettings,
|
||||
requestTimeoutMs = requestTimeoutMs,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
activePlannerSessions.remove(plannerSessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private class AgentPlannerRuntime(
|
||||
private val context: Context,
|
||||
private val frameworkSessionId: String?,
|
||||
) : Closeable {
|
||||
companion object {
|
||||
private const val REQUEST_TIMEOUT_MS = 30_000L
|
||||
private const val AGENT_APP_SERVER_RUST_LOG = "warn"
|
||||
}
|
||||
|
||||
private val requestIdSequence = AtomicInteger(1)
|
||||
private val pendingResponses = ConcurrentHashMap<String, LinkedBlockingQueue<JSONObject>>()
|
||||
private val notifications = LinkedBlockingQueue<JSONObject>()
|
||||
|
||||
private lateinit var process: Process
|
||||
private lateinit var writer: BufferedWriter
|
||||
private lateinit var codexHome: File
|
||||
private val closing = AtomicBoolean(false)
|
||||
private var stdoutThread: Thread? = null
|
||||
private var stderrThread: Thread? = null
|
||||
private var localProxy: AgentLocalCodexProxy? = null
|
||||
|
||||
fun requestText(
|
||||
instructions: String,
|
||||
prompt: String,
|
||||
outputSchema: JSONObject?,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
|
||||
executionSettings: SessionExecutionSettings,
|
||||
requestTimeoutMs: Long,
|
||||
): String {
|
||||
startProcess()
|
||||
initialize()
|
||||
val threadId = startThread(
|
||||
instructions = instructions,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
startTurn(
|
||||
threadId = threadId,
|
||||
prompt = prompt,
|
||||
outputSchema = outputSchema,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
return waitForTurnCompletion(requestUserInputHandler, requestTimeoutMs)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
closing.set(true)
|
||||
stdoutThread?.interrupt()
|
||||
stderrThread?.interrupt()
|
||||
if (::writer.isInitialized) {
|
||||
runCatching { writer.close() }
|
||||
}
|
||||
localProxy?.close()
|
||||
if (::codexHome.isInitialized) {
|
||||
runCatching { codexHome.deleteRecursively() }
|
||||
}
|
||||
if (::process.isInitialized) {
|
||||
runCatching { process.destroy() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun startProcess() {
|
||||
codexHome = File(context.cacheDir, "planner-codex-home/$frameworkSessionId").apply {
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
localProxy = AgentLocalCodexProxy { requestBody ->
|
||||
forwardResponsesRequest(requestBody)
|
||||
}.also(AgentLocalCodexProxy::start)
|
||||
HostedCodexConfig.write(
|
||||
context,
|
||||
codexHome,
|
||||
localProxy?.baseUrl
|
||||
?: throw IOException("planner local proxy did not start"),
|
||||
)
|
||||
process = ProcessBuilder(
|
||||
listOf(
|
||||
CodexCliBinaryLocator.resolve(context).absolutePath,
|
||||
"-c",
|
||||
"enable_request_compression=false",
|
||||
"app-server",
|
||||
"--listen",
|
||||
"stdio://",
|
||||
),
|
||||
).apply {
|
||||
environment()["CODEX_HOME"] = codexHome.absolutePath
|
||||
environment()["RUST_LOG"] = AGENT_APP_SERVER_RUST_LOG
|
||||
}.start()
|
||||
writer = process.outputStream.bufferedWriter()
|
||||
startStdoutPump()
|
||||
startStderrPump()
|
||||
}
|
||||
|
||||
private fun initialize() {
|
||||
request(
|
||||
method = "initialize",
|
||||
params = JSONObject()
|
||||
.put(
|
||||
"clientInfo",
|
||||
JSONObject()
|
||||
.put("name", "android_agent_planner")
|
||||
.put("title", "Android Agent Planner")
|
||||
.put("version", "0.1.0"),
|
||||
)
|
||||
.put("capabilities", JSONObject().put("experimentalApi", true)),
|
||||
)
|
||||
notify("initialized", JSONObject())
|
||||
}
|
||||
|
||||
private fun startThread(
|
||||
instructions: String,
|
||||
executionSettings: SessionExecutionSettings,
|
||||
): String {
|
||||
val params = JSONObject()
|
||||
.put("approvalPolicy", "never")
|
||||
.put("sandbox", "read-only")
|
||||
.put("ephemeral", true)
|
||||
.put("cwd", context.filesDir.absolutePath)
|
||||
.put("serviceName", "android_agent_planner")
|
||||
.put("baseInstructions", instructions)
|
||||
executionSettings.model
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { params.put("model", it) }
|
||||
val result = request(
|
||||
method = "thread/start",
|
||||
params = params,
|
||||
)
|
||||
return result.getJSONObject("thread").getString("id")
|
||||
}
|
||||
|
||||
private fun startTurn(
|
||||
threadId: String,
|
||||
prompt: String,
|
||||
outputSchema: JSONObject?,
|
||||
executionSettings: SessionExecutionSettings,
|
||||
) {
|
||||
val turnParams = JSONObject()
|
||||
.put("threadId", threadId)
|
||||
.put(
|
||||
"input",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("type", "text")
|
||||
.put("text", prompt),
|
||||
),
|
||||
)
|
||||
executionSettings.model
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { turnParams.put("model", it) }
|
||||
executionSettings.reasoningEffort
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { turnParams.put("effort", it) }
|
||||
if (outputSchema != null) {
|
||||
turnParams.put("outputSchema", outputSchema)
|
||||
}
|
||||
request(
|
||||
method = "turn/start",
|
||||
params = turnParams,
|
||||
)
|
||||
}
|
||||
|
||||
private fun waitForTurnCompletion(
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
|
||||
requestTimeoutMs: Long,
|
||||
): String {
|
||||
val streamedAgentMessages = mutableMapOf<String, StringBuilder>()
|
||||
var finalAgentMessage: String? = null
|
||||
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(requestTimeoutMs)
|
||||
while (true) {
|
||||
val remainingNanos = deadline - System.nanoTime()
|
||||
if (remainingNanos <= 0L) {
|
||||
throw IOException("Timed out waiting for planner turn completion")
|
||||
}
|
||||
val notification = notifications.poll(remainingNanos, TimeUnit.NANOSECONDS)
|
||||
if (notification == null) {
|
||||
checkProcessAlive()
|
||||
continue
|
||||
}
|
||||
if (notification.has("id") && notification.has("method")) {
|
||||
handleServerRequest(notification, requestUserInputHandler)
|
||||
continue
|
||||
}
|
||||
val params = notification.optJSONObject("params") ?: JSONObject()
|
||||
when (notification.optString("method")) {
|
||||
"item/agentMessage/delta" -> {
|
||||
val itemId = params.optString("itemId")
|
||||
if (itemId.isNotBlank()) {
|
||||
streamedAgentMessages.getOrPut(itemId, ::StringBuilder)
|
||||
.append(params.optString("delta"))
|
||||
}
|
||||
}
|
||||
|
||||
"item/completed" -> {
|
||||
val item = params.optJSONObject("item") ?: continue
|
||||
if (item.optString("type") == "agentMessage") {
|
||||
val itemId = item.optString("id")
|
||||
val text = item.optString("text").ifBlank {
|
||||
streamedAgentMessages[itemId]?.toString().orEmpty()
|
||||
}
|
||||
if (text.isNotBlank()) {
|
||||
finalAgentMessage = text
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
"turn/completed" -> {
|
||||
val turn = params.optJSONObject("turn") ?: JSONObject()
|
||||
return when (turn.optString("status")) {
|
||||
"completed" -> finalAgentMessage?.takeIf(String::isNotBlank)
|
||||
?: throw IOException("Planner turn completed without an assistant message")
|
||||
|
||||
"interrupted" -> throw IOException("Planner turn interrupted")
|
||||
else -> throw IOException(
|
||||
turn.opt("error")?.toString()
|
||||
?: "Planner turn failed with status ${turn.optString("status", "unknown")}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleServerRequest(
|
||||
message: JSONObject,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
|
||||
) {
|
||||
val requestId = message.opt("id") ?: return
|
||||
val method = message.optString("method", "unknown")
|
||||
val params = message.optJSONObject("params") ?: JSONObject()
|
||||
when (method) {
|
||||
"item/tool/requestUserInput" -> {
|
||||
if (requestUserInputHandler == null) {
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32601,
|
||||
message = "No Agent user-input handler registered for $method",
|
||||
)
|
||||
return
|
||||
}
|
||||
val questions = params.optJSONArray("questions") ?: JSONArray()
|
||||
val result = runCatching { requestUserInputHandler(questions) }
|
||||
.getOrElse { err ->
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32000,
|
||||
message = err.message ?: "Agent user input request failed",
|
||||
)
|
||||
return
|
||||
}
|
||||
sendResult(requestId, result)
|
||||
}
|
||||
|
||||
else -> {
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32601,
|
||||
message = "Unsupported planner app-server request: $method",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun forwardResponsesRequest(requestBody: String): AgentResponsesProxy.HttpResponse {
|
||||
val activeFrameworkSessionId = frameworkSessionId
|
||||
check(!activeFrameworkSessionId.isNullOrBlank()) {
|
||||
"Planner runtime requires a framework session id for /responses transport"
|
||||
}
|
||||
val agentManager = context.getSystemService(AgentManager::class.java)
|
||||
?: throw IOException("AgentManager unavailable for framework session transport")
|
||||
return AgentResponsesProxy.sendResponsesRequestThroughFramework(
|
||||
agentManager = agentManager,
|
||||
sessionId = activeFrameworkSessionId,
|
||||
context = context,
|
||||
requestBody = requestBody,
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(
|
||||
method: String,
|
||||
params: JSONObject?,
|
||||
): JSONObject {
|
||||
val requestId = requestIdSequence.getAndIncrement().toString()
|
||||
val responseQueue = LinkedBlockingQueue<JSONObject>(1)
|
||||
pendingResponses[requestId] = responseQueue
|
||||
try {
|
||||
val message = JSONObject()
|
||||
.put("id", requestId)
|
||||
.put("method", method)
|
||||
if (params != null) {
|
||||
message.put("params", params)
|
||||
}
|
||||
sendMessage(message)
|
||||
val response = responseQueue.poll(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
?: throw IOException("Timed out waiting for $method response")
|
||||
val error = response.optJSONObject("error")
|
||||
if (error != null) {
|
||||
throw IOException("$method failed: ${error.optString("message", error.toString())}")
|
||||
}
|
||||
return response.optJSONObject("result") ?: JSONObject()
|
||||
} finally {
|
||||
pendingResponses.remove(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
method: String,
|
||||
params: JSONObject,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("method", method)
|
||||
.put("params", params),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendResult(
|
||||
requestId: Any,
|
||||
result: JSONObject,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("id", requestId)
|
||||
.put("result", result),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendError(
|
||||
requestId: Any,
|
||||
code: Int,
|
||||
message: String,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("id", requestId)
|
||||
.put(
|
||||
"error",
|
||||
JSONObject()
|
||||
.put("code", code)
|
||||
.put("message", message),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendMessage(message: JSONObject) {
|
||||
writer.write(message.toString())
|
||||
writer.newLine()
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
private fun startStdoutPump() {
|
||||
stdoutThread = thread(name = "AgentPlannerStdout-$frameworkSessionId") {
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
if (line.isBlank()) {
|
||||
return@forEach
|
||||
}
|
||||
val message = runCatching { JSONObject(line) }
|
||||
.getOrElse { err ->
|
||||
Log.w(TAG, "Failed to parse planner app-server stdout line", err)
|
||||
return@forEach
|
||||
}
|
||||
if (message.has("id") && !message.has("method")) {
|
||||
pendingResponses[message.get("id").toString()]?.offer(message)
|
||||
} else {
|
||||
notifications.offer(message)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: InterruptedIOException) {
|
||||
if (!closing.get()) {
|
||||
Log.w(TAG, "Planner stdout pump interrupted unexpectedly", err)
|
||||
}
|
||||
} catch (err: IOException) {
|
||||
if (!closing.get()) {
|
||||
Log.w(TAG, "Planner stdout pump failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startStderrPump() {
|
||||
stderrThread = thread(name = "AgentPlannerStderr-$frameworkSessionId") {
|
||||
try {
|
||||
process.errorStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
if (line.contains(" ERROR ") || line.startsWith("ERROR")) {
|
||||
Log.e(TAG, line)
|
||||
} else if (line.contains(" WARN ") || line.startsWith("WARN")) {
|
||||
Log.w(TAG, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: InterruptedIOException) {
|
||||
if (!closing.get()) {
|
||||
Log.w(TAG, "Planner stderr pump interrupted unexpectedly", err)
|
||||
}
|
||||
} catch (err: IOException) {
|
||||
if (!closing.get()) {
|
||||
Log.w(TAG, "Planner stderr pump failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkProcessAlive() {
|
||||
if (!process.isAlive) {
|
||||
throw IOException("Planner app-server exited with code ${process.exitValue()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
|
||||
object AgentQuestionNotifier {
|
||||
private const val CHANNEL_ID = "codex_agent_questions"
|
||||
private const val CHANNEL_NAME = "Codex Agent Questions"
|
||||
|
||||
fun showQuestion(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
targetPackage: String?,
|
||||
question: String,
|
||||
) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
ensureChannel(manager)
|
||||
manager.notify(notificationId(sessionId), buildNotification(context, sessionId, targetPackage, question))
|
||||
}
|
||||
|
||||
fun cancel(context: Context, sessionId: String) {
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
manager.cancel(notificationId(sessionId))
|
||||
}
|
||||
|
||||
private fun buildNotification(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
targetPackage: String?,
|
||||
question: String,
|
||||
): Notification {
|
||||
val title = targetPackage?.let { "Question for $it" } ?: "Question for Codex Agent"
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId(sessionId),
|
||||
Intent(context, SessionDetailActivity::class.java).apply {
|
||||
putExtra(SessionDetailActivity.EXTRA_SESSION_ID, sessionId)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle(title)
|
||||
.setContentText(question)
|
||||
.setStyle(Notification.BigTextStyle().bigText(question))
|
||||
.setContentIntent(contentIntent)
|
||||
.setAutoCancel(false)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel(manager: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
|
||||
return
|
||||
}
|
||||
val channel = NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_HIGH,
|
||||
).apply {
|
||||
description = "Questions that need user input for Codex Agent sessions"
|
||||
setShowBadge(true)
|
||||
}
|
||||
manager.createNotificationChannel(channel)
|
||||
}
|
||||
|
||||
private fun notificationId(sessionId: String): Int {
|
||||
return sessionId.hashCode()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentManager
|
||||
import android.content.Context
|
||||
import android.os.Bundle
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.FrameworkSessionTransportCompat
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.SocketException
|
||||
import java.net.URL
|
||||
import java.nio.charset.StandardCharsets
|
||||
import org.json.JSONObject
|
||||
|
||||
object AgentResponsesProxy {
|
||||
private const val TAG = "AgentResponsesProxy"
|
||||
private const val CONNECT_TIMEOUT_MS = 30_000
|
||||
private const val READ_TIMEOUT_MS = 0
|
||||
private const val DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1"
|
||||
private const val DEFAULT_CHATGPT_BASE_URL = "https://chatgpt.com/backend-api/codex"
|
||||
private const val DEFAULT_ORIGINATOR = "codex_cli_rs"
|
||||
private const val DEFAULT_USER_AGENT = "codex_cli_rs/android_agent_bridge"
|
||||
private const val HEADER_AUTHORIZATION = "Authorization"
|
||||
private const val HEADER_CONTENT_TYPE = "Content-Type"
|
||||
private const val HEADER_ACCEPT = "Accept"
|
||||
private const val HEADER_ACCEPT_ENCODING = "Accept-Encoding"
|
||||
private const val HEADER_CHATGPT_ACCOUNT_ID = "ChatGPT-Account-ID"
|
||||
private const val HEADER_ORIGINATOR = "originator"
|
||||
private const val HEADER_USER_AGENT = "User-Agent"
|
||||
private const val HEADER_VALUE_BEARER_PREFIX = "Bearer "
|
||||
private const val HEADER_VALUE_APPLICATION_JSON = "application/json"
|
||||
private const val HEADER_VALUE_TEXT_EVENT_STREAM = "text/event-stream"
|
||||
private const val HEADER_VALUE_IDENTITY = "identity"
|
||||
|
||||
internal data class AuthSnapshot(
|
||||
val authMode: String,
|
||||
val bearerToken: String,
|
||||
val accountId: String?,
|
||||
)
|
||||
|
||||
data class HttpResponse(
|
||||
val statusCode: Int,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
internal data class FrameworkTransportTarget(
|
||||
val baseUrl: String,
|
||||
val responsesPath: String,
|
||||
)
|
||||
|
||||
fun sendResponsesRequest(
|
||||
context: Context,
|
||||
requestBody: String,
|
||||
): HttpResponse {
|
||||
val authSnapshot = loadAuthSnapshot(File(context.filesDir, "codex-home/auth.json"))
|
||||
val upstreamUrl = buildResponsesUrl(upstreamBaseUrl = "provider-default", authMode = authSnapshot.authMode)
|
||||
val requestBodyBytes = requestBody.toByteArray(StandardCharsets.UTF_8)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Proxying /v1/responses -> $upstreamUrl (auth_mode=${authSnapshot.authMode}, bytes=${requestBodyBytes.size})",
|
||||
)
|
||||
return executeRequest(upstreamUrl, requestBodyBytes, authSnapshot)
|
||||
}
|
||||
|
||||
fun sendResponsesRequestThroughFramework(
|
||||
agentManager: AgentManager,
|
||||
sessionId: String,
|
||||
context: Context,
|
||||
requestBody: String,
|
||||
): HttpResponse {
|
||||
val authSnapshot = loadAuthSnapshot(File(context.filesDir, "codex-home/auth.json"))
|
||||
val requestBodyBytes = requestBody.toByteArray(StandardCharsets.UTF_8)
|
||||
val transportTarget = buildFrameworkTransportTarget(
|
||||
buildResponsesBaseUrl(upstreamBaseUrl = "provider-default", authMode = authSnapshot.authMode),
|
||||
)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Proxying /v1/responses via framework session $sessionId -> ${transportTarget.baseUrl}${transportTarget.responsesPath} (auth_mode=${authSnapshot.authMode}, bytes=${requestBodyBytes.size})",
|
||||
)
|
||||
FrameworkSessionTransportCompat.setSessionNetworkConfig(
|
||||
agentManager = agentManager,
|
||||
sessionId = sessionId,
|
||||
config = buildFrameworkSessionNetworkConfig(
|
||||
context = context,
|
||||
upstreamBaseUrl = "provider-default",
|
||||
),
|
||||
)
|
||||
val response = FrameworkSessionTransportCompat.executeStreamingRequest(
|
||||
agentManager = agentManager,
|
||||
sessionId = sessionId,
|
||||
request = FrameworkSessionTransportCompat.HttpRequest(
|
||||
method = "POST",
|
||||
path = transportTarget.responsesPath,
|
||||
headers = buildResponsesRequestHeaders(),
|
||||
body = requestBodyBytes,
|
||||
),
|
||||
)
|
||||
Log.i(
|
||||
TAG,
|
||||
"Framework responses proxy completed status=${response.statusCode} response_bytes=${response.body.size}",
|
||||
)
|
||||
return HttpResponse(
|
||||
statusCode = response.statusCode,
|
||||
body = response.bodyString,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun buildFrameworkSessionNetworkConfig(
|
||||
context: Context,
|
||||
upstreamBaseUrl: String,
|
||||
): FrameworkSessionTransportCompat.SessionNetworkConfig {
|
||||
val authSnapshot = loadAuthSnapshot(File(context.filesDir, "codex-home/auth.json"))
|
||||
val transportTarget = buildFrameworkTransportTarget(
|
||||
buildResponsesBaseUrl(upstreamBaseUrl, authSnapshot.authMode),
|
||||
)
|
||||
return FrameworkSessionTransportCompat.SessionNetworkConfig(
|
||||
baseUrl = transportTarget.baseUrl,
|
||||
defaultHeaders = buildDefaultHeaders(authSnapshot),
|
||||
connectTimeoutMillis = CONNECT_TIMEOUT_MS,
|
||||
readTimeoutMillis = READ_TIMEOUT_MS,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun buildFrameworkResponsesPath(responsesBaseUrl: String): String {
|
||||
return buildFrameworkTransportTarget(responsesBaseUrl).responsesPath
|
||||
}
|
||||
|
||||
internal fun buildResponsesBaseUrl(
|
||||
upstreamBaseUrl: String,
|
||||
authMode: String,
|
||||
): String {
|
||||
val normalizedUpstreamBaseUrl = upstreamBaseUrl.trim()
|
||||
return when {
|
||||
normalizedUpstreamBaseUrl.isBlank() ||
|
||||
normalizedUpstreamBaseUrl == "provider-default" ||
|
||||
normalizedUpstreamBaseUrl == "null" -> {
|
||||
if (authMode == "chatgpt") {
|
||||
DEFAULT_CHATGPT_BASE_URL
|
||||
} else {
|
||||
DEFAULT_OPENAI_BASE_URL
|
||||
}
|
||||
}
|
||||
else -> normalizedUpstreamBaseUrl
|
||||
}.trimEnd('/')
|
||||
}
|
||||
|
||||
internal fun buildResponsesUrl(
|
||||
upstreamBaseUrl: String,
|
||||
authMode: String,
|
||||
): String {
|
||||
return "${buildResponsesBaseUrl(upstreamBaseUrl, authMode)}/responses"
|
||||
}
|
||||
|
||||
internal fun buildFrameworkTransportTarget(responsesBaseUrl: String): FrameworkTransportTarget {
|
||||
val upstreamUrl = URL(responsesBaseUrl)
|
||||
val baseUrl = buildString {
|
||||
append(upstreamUrl.protocol)
|
||||
append("://")
|
||||
append(upstreamUrl.host)
|
||||
if (upstreamUrl.port != -1) {
|
||||
append(":")
|
||||
append(upstreamUrl.port)
|
||||
}
|
||||
}
|
||||
val normalizedPath = upstreamUrl.path.trimEnd('/').ifBlank { "/" }
|
||||
val responsesPath = if (normalizedPath == "/") {
|
||||
"/responses"
|
||||
} else {
|
||||
"$normalizedPath/responses"
|
||||
}
|
||||
return FrameworkTransportTarget(
|
||||
baseUrl = baseUrl,
|
||||
responsesPath = responsesPath,
|
||||
)
|
||||
}
|
||||
|
||||
internal fun loadAuthSnapshot(authFile: File): AuthSnapshot {
|
||||
if (!authFile.isFile) {
|
||||
throw IOException("Missing Agent auth file at ${authFile.absolutePath}")
|
||||
}
|
||||
val json = JSONObject(authFile.readText())
|
||||
val openAiApiKey = json.stringOrNull("OPENAI_API_KEY")
|
||||
val authMode = when (json.stringOrNull("auth_mode")) {
|
||||
"apiKey", "apikey", "api_key" -> "apiKey"
|
||||
"chatgpt", "chatgptAuthTokens", "chatgpt_auth_tokens" -> "chatgpt"
|
||||
null -> if (openAiApiKey != null) "apiKey" else "chatgpt"
|
||||
else -> if (openAiApiKey != null) "apiKey" else "chatgpt"
|
||||
}
|
||||
return if (authMode == "apiKey") {
|
||||
val apiKey = openAiApiKey
|
||||
?: throw IOException("Agent auth file is missing OPENAI_API_KEY")
|
||||
AuthSnapshot(
|
||||
authMode = authMode,
|
||||
bearerToken = apiKey,
|
||||
accountId = null,
|
||||
)
|
||||
} else {
|
||||
val tokens = json.optJSONObject("tokens")
|
||||
?: throw IOException("Agent auth file is missing chatgpt tokens")
|
||||
val accessToken = tokens.stringOrNull("access_token")
|
||||
?: throw IOException("Agent auth file is missing access_token")
|
||||
AuthSnapshot(
|
||||
authMode = "chatgpt",
|
||||
bearerToken = accessToken,
|
||||
accountId = tokens.stringOrNull("account_id"),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun executeRequest(
|
||||
upstreamUrl: String,
|
||||
requestBodyBytes: ByteArray,
|
||||
authSnapshot: AuthSnapshot,
|
||||
): HttpResponse {
|
||||
val connection = openConnection(upstreamUrl, authSnapshot)
|
||||
return try {
|
||||
try {
|
||||
connection.outputStream.use { output ->
|
||||
output.write(requestBodyBytes)
|
||||
output.flush()
|
||||
}
|
||||
} catch (err: IOException) {
|
||||
throw wrapRequestFailure("write request body", upstreamUrl, err)
|
||||
}
|
||||
val statusCode = try {
|
||||
connection.responseCode
|
||||
} catch (err: IOException) {
|
||||
throw wrapRequestFailure("read response status", upstreamUrl, err)
|
||||
}
|
||||
val responseBody = try {
|
||||
val stream = if (statusCode >= 400) connection.errorStream else connection.inputStream
|
||||
stream?.bufferedReader(StandardCharsets.UTF_8)?.use { it.readText() }.orEmpty()
|
||||
} catch (err: IOException) {
|
||||
throw wrapRequestFailure("read response body", upstreamUrl, err)
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"Responses proxy completed status=$statusCode response_bytes=${responseBody.toByteArray(StandardCharsets.UTF_8).size}",
|
||||
)
|
||||
HttpResponse(
|
||||
statusCode = statusCode,
|
||||
body = responseBody,
|
||||
)
|
||||
} finally {
|
||||
connection.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun openConnection(
|
||||
upstreamUrl: String,
|
||||
authSnapshot: AuthSnapshot,
|
||||
): HttpURLConnection {
|
||||
return try {
|
||||
(URL(upstreamUrl).openConnection() as HttpURLConnection).apply {
|
||||
requestMethod = "POST"
|
||||
connectTimeout = CONNECT_TIMEOUT_MS
|
||||
readTimeout = READ_TIMEOUT_MS
|
||||
doInput = true
|
||||
doOutput = true
|
||||
instanceFollowRedirects = true
|
||||
val defaultHeaders = buildDefaultHeaders(authSnapshot)
|
||||
defaultHeaders.keySet().forEach { key ->
|
||||
defaultHeaders.getString(key)?.let { value ->
|
||||
setRequestProperty(key, value)
|
||||
}
|
||||
}
|
||||
val requestHeaders = buildResponsesRequestHeaders()
|
||||
requestHeaders.keySet().forEach { key ->
|
||||
requestHeaders.getString(key)?.let { value ->
|
||||
setRequestProperty(key, value)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: IOException) {
|
||||
throw wrapRequestFailure("open connection", upstreamUrl, err)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildDefaultHeaders(authSnapshot: AuthSnapshot): Bundle {
|
||||
return Bundle().apply {
|
||||
putString(HEADER_AUTHORIZATION, "$HEADER_VALUE_BEARER_PREFIX${authSnapshot.bearerToken}")
|
||||
putString(HEADER_ORIGINATOR, DEFAULT_ORIGINATOR)
|
||||
putString(HEADER_USER_AGENT, DEFAULT_USER_AGENT)
|
||||
if (authSnapshot.authMode == "chatgpt" && !authSnapshot.accountId.isNullOrBlank()) {
|
||||
putString(HEADER_CHATGPT_ACCOUNT_ID, authSnapshot.accountId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildResponsesRequestHeaders(): Bundle {
|
||||
return Bundle().apply {
|
||||
putString(HEADER_CONTENT_TYPE, HEADER_VALUE_APPLICATION_JSON)
|
||||
putString(HEADER_ACCEPT, HEADER_VALUE_TEXT_EVENT_STREAM)
|
||||
putString(HEADER_ACCEPT_ENCODING, HEADER_VALUE_IDENTITY)
|
||||
}
|
||||
}
|
||||
|
||||
internal fun describeRequestFailure(
|
||||
phase: String,
|
||||
upstreamUrl: String,
|
||||
err: IOException,
|
||||
): String {
|
||||
val reason = err.message?.ifBlank { err::class.java.simpleName } ?: err::class.java.simpleName
|
||||
return "Responses proxy failed during $phase for $upstreamUrl: ${err::class.java.simpleName}: $reason"
|
||||
}
|
||||
|
||||
private fun wrapRequestFailure(
|
||||
phase: String,
|
||||
upstreamUrl: String,
|
||||
err: IOException,
|
||||
): IOException {
|
||||
val wrapped = IOException(describeRequestFailure(phase, upstreamUrl, err), err)
|
||||
if (err is SocketException) {
|
||||
Log.w(TAG, wrapped.message, err)
|
||||
} else {
|
||||
Log.e(TAG, wrapped.message, err)
|
||||
}
|
||||
return wrapped
|
||||
}
|
||||
|
||||
private fun JSONObject.stringOrNull(key: String): String? {
|
||||
if (!has(key) || isNull(key)) {
|
||||
return null
|
||||
}
|
||||
return optString(key).ifBlank { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentManager
|
||||
import android.content.Context
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.HostedCodexConfig
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.Closeable
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.EOFException
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.File
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
import org.json.JSONObject
|
||||
|
||||
object AgentSessionBridgeServer {
|
||||
private val runningBridges = ConcurrentHashMap<String, RunningBridge>()
|
||||
|
||||
fun ensureStarted(
|
||||
context: Context,
|
||||
agentManager: AgentManager,
|
||||
sessionId: String,
|
||||
) {
|
||||
runningBridges.computeIfAbsent(sessionId) {
|
||||
RunningBridge(
|
||||
context = context.applicationContext,
|
||||
agentManager = agentManager,
|
||||
sessionId = sessionId,
|
||||
).also(RunningBridge::start)
|
||||
}
|
||||
}
|
||||
|
||||
fun closeSession(sessionId: String) {
|
||||
runningBridges.remove(sessionId)?.close()
|
||||
}
|
||||
|
||||
private class RunningBridge(
|
||||
private val context: Context,
|
||||
private val agentManager: AgentManager,
|
||||
private val sessionId: String,
|
||||
) : Closeable {
|
||||
companion object {
|
||||
private const val TAG = "AgentSessionBridge"
|
||||
private const val METHOD_GET_RUNTIME_STATUS = "getRuntimeStatus"
|
||||
private const val METHOD_READ_INSTALLED_AGENTS_FILE = "readInstalledAgentsFile"
|
||||
private const val METHOD_READ_SESSION_EXECUTION_SETTINGS = "readSessionExecutionSettings"
|
||||
private const val WRITE_CHUNK_BYTES = 4096
|
||||
}
|
||||
|
||||
private val closed = AtomicBoolean(false)
|
||||
private var bridgeFd: ParcelFileDescriptor? = null
|
||||
private var input: DataInputStream? = null
|
||||
private var output: DataOutputStream? = null
|
||||
private val executionSettingsStore = SessionExecutionSettingsStore(context)
|
||||
private val serveThread = thread(
|
||||
start = false,
|
||||
name = "AgentSessionBridge-$sessionId",
|
||||
) {
|
||||
serveLoop()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
serveThread.start()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
runCatching { input?.close() }
|
||||
runCatching { output?.close() }
|
||||
runCatching { bridgeFd?.close() }
|
||||
serveThread.interrupt()
|
||||
}
|
||||
|
||||
private fun serveLoop() {
|
||||
try {
|
||||
val fd = agentManager.openSessionBridge(sessionId)
|
||||
bridgeFd = fd
|
||||
input = DataInputStream(BufferedInputStream(FileInputStream(fd.fileDescriptor)))
|
||||
output = DataOutputStream(BufferedOutputStream(FileOutputStream(fd.fileDescriptor)))
|
||||
Log.i(TAG, "Opened framework session bridge for $sessionId")
|
||||
while (!closed.get()) {
|
||||
val request = try {
|
||||
readMessage(input ?: break)
|
||||
} catch (_: EOFException) {
|
||||
return
|
||||
}
|
||||
val response = handleRequest(request)
|
||||
writeMessage(output ?: break, response)
|
||||
}
|
||||
} catch (err: Exception) {
|
||||
if (!closed.get() && !isExpectedSessionShutdown(err)) {
|
||||
Log.w(TAG, "Session bridge failed for $sessionId", err)
|
||||
}
|
||||
} finally {
|
||||
runningBridges.remove(sessionId, this)
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun isExpectedSessionShutdown(err: Exception): Boolean {
|
||||
return err is IllegalStateException
|
||||
&& err.message?.contains("No active Genie runtime for session") == true
|
||||
}
|
||||
|
||||
private fun handleRequest(request: JSONObject): JSONObject {
|
||||
val requestId = request.optString("requestId")
|
||||
return runCatching {
|
||||
when (request.optString("method")) {
|
||||
METHOD_GET_RUNTIME_STATUS -> {
|
||||
val status = AgentCodexAppServerClient.readRuntimeStatus(context)
|
||||
JSONObject()
|
||||
.put("requestId", requestId)
|
||||
.put("ok", true)
|
||||
.put(
|
||||
"runtimeStatus",
|
||||
JSONObject()
|
||||
.put("authenticated", status.authenticated)
|
||||
.put("accountEmail", status.accountEmail)
|
||||
.put("clientCount", status.clientCount)
|
||||
.put("modelProviderId", status.modelProviderId)
|
||||
.put("configuredModel", status.configuredModel)
|
||||
.put("effectiveModel", status.effectiveModel)
|
||||
.put("upstreamBaseUrl", status.upstreamBaseUrl)
|
||||
.put("frameworkResponsesPath", status.frameworkResponsesPath),
|
||||
)
|
||||
}
|
||||
METHOD_READ_INSTALLED_AGENTS_FILE -> {
|
||||
val codexHome = File(context.filesDir, "codex-home")
|
||||
HostedCodexConfig.installBundledAgentsFile(context, codexHome)
|
||||
JSONObject()
|
||||
.put("requestId", requestId)
|
||||
.put("ok", true)
|
||||
.put("agentsMarkdown", HostedCodexConfig.readInstalledAgentsMarkdown(codexHome))
|
||||
}
|
||||
METHOD_READ_SESSION_EXECUTION_SETTINGS -> {
|
||||
JSONObject()
|
||||
.put("requestId", requestId)
|
||||
.put("ok", true)
|
||||
.put("executionSettings", executionSettingsStore.toJson(sessionId))
|
||||
}
|
||||
else -> {
|
||||
JSONObject()
|
||||
.put("requestId", requestId)
|
||||
.put("ok", false)
|
||||
.put("error", "Unsupported bridge method: ${request.optString("method")}")
|
||||
}
|
||||
}
|
||||
}.getOrElse { err ->
|
||||
JSONObject()
|
||||
.put("requestId", requestId)
|
||||
.put("ok", false)
|
||||
.put("error", err.message ?: err::class.java.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readMessage(input: DataInputStream): JSONObject {
|
||||
val size = input.readInt()
|
||||
if (size <= 0) {
|
||||
throw IOException("Invalid session bridge message length: $size")
|
||||
}
|
||||
val payload = ByteArray(size)
|
||||
input.readFully(payload)
|
||||
return JSONObject(payload.toString(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
private fun writeMessage(
|
||||
output: DataOutputStream,
|
||||
message: JSONObject,
|
||||
) {
|
||||
val payload = message.toString().toByteArray(StandardCharsets.UTF_8)
|
||||
output.writeInt(payload.size)
|
||||
output.flush()
|
||||
var offset = 0
|
||||
while (offset < payload.size) {
|
||||
val chunkSize = minOf(WRITE_CHUNK_BYTES, payload.size - offset)
|
||||
output.write(payload, offset, chunkSize)
|
||||
output.flush()
|
||||
offset += chunkSize
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,797 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentManager
|
||||
import android.app.agent.AgentSessionEvent
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.content.Context
|
||||
import android.os.Binder
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.DetachedTargetCompat
|
||||
import com.openai.codex.bridge.FrameworkSessionTransportCompat
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import java.util.concurrent.Executor
|
||||
|
||||
class AgentSessionController(context: Context) {
|
||||
companion object {
|
||||
private const val TAG = "AgentSessionController"
|
||||
private const val BRIDGE_REQUEST_PREFIX = "__codex_bridge__ "
|
||||
private const val BRIDGE_RESPONSE_PREFIX = "__codex_bridge_result__ "
|
||||
private const val DIAGNOSTIC_NOT_LOADED = "Diagnostics not loaded."
|
||||
private const val MAX_TIMELINE_EVENTS = 12
|
||||
private const val PREFERRED_GENIE_PACKAGE = "com.openai.codex.genie"
|
||||
private const val QUESTION_ANSWER_RETRY_COUNT = 10
|
||||
private const val QUESTION_ANSWER_RETRY_DELAY_MS = 50L
|
||||
}
|
||||
|
||||
private val appContext = context.applicationContext
|
||||
private val agentManager = appContext.getSystemService(AgentManager::class.java)
|
||||
private val presentationPolicyStore = SessionPresentationPolicyStore(context)
|
||||
private val executionSettingsStore = SessionExecutionSettingsStore(context)
|
||||
|
||||
fun isAvailable(): Boolean = agentManager != null
|
||||
|
||||
fun canStartSessionForTarget(packageName: String): Boolean {
|
||||
val manager = agentManager ?: return false
|
||||
return manager.canStartSessionForTarget(packageName, currentUserId())
|
||||
}
|
||||
|
||||
fun registerSessionListener(
|
||||
executor: Executor,
|
||||
listener: AgentManager.SessionListener,
|
||||
): Boolean {
|
||||
val manager = agentManager ?: return false
|
||||
manager.registerSessionListener(currentUserId(), executor, listener)
|
||||
return true
|
||||
}
|
||||
|
||||
fun unregisterSessionListener(listener: AgentManager.SessionListener) {
|
||||
agentManager?.unregisterSessionListener(listener)
|
||||
}
|
||||
|
||||
fun registerSessionUiLease(parentSessionId: String, token: Binder) {
|
||||
agentManager?.registerSessionUiLease(parentSessionId, token)
|
||||
}
|
||||
|
||||
fun unregisterSessionUiLease(parentSessionId: String, token: Binder) {
|
||||
agentManager?.unregisterSessionUiLease(parentSessionId, token)
|
||||
}
|
||||
|
||||
fun acknowledgeSessionUi(parentSessionId: String) {
|
||||
val manager = agentManager ?: return
|
||||
val token = Binder()
|
||||
runCatching {
|
||||
manager.registerSessionUiLease(parentSessionId, token)
|
||||
}
|
||||
runCatching {
|
||||
manager.unregisterSessionUiLease(parentSessionId, token)
|
||||
}
|
||||
}
|
||||
|
||||
fun loadSnapshot(focusedSessionId: String?): AgentSnapshot {
|
||||
val manager = agentManager ?: return AgentSnapshot.unavailable
|
||||
val roleHolders = manager.getGenieRoleHolders(currentUserId())
|
||||
val selectedGeniePackage = selectGeniePackage(roleHolders)
|
||||
val sessions = manager.getSessions(currentUserId())
|
||||
presentationPolicyStore.prunePolicies(sessions.map { it.sessionId }.toSet())
|
||||
executionSettingsStore.pruneSettings(sessions.map { it.sessionId }.toSet())
|
||||
var sessionDetails = sessions.map { session ->
|
||||
val targetRuntime = DetachedTargetCompat.getTargetRuntime(session)
|
||||
AgentSessionDetails(
|
||||
sessionId = session.sessionId,
|
||||
parentSessionId = session.parentSessionId,
|
||||
targetPackage = session.targetPackage,
|
||||
anchor = session.anchor,
|
||||
state = session.state,
|
||||
stateLabel = stateToString(session.state),
|
||||
targetPresentation = session.targetPresentation,
|
||||
targetPresentationLabel = targetPresentationToString(session.targetPresentation),
|
||||
targetRuntime = targetRuntime.value,
|
||||
targetRuntimeLabel = targetRuntime.label,
|
||||
targetDetached = session.isTargetDetached,
|
||||
requiredFinalPresentationPolicy = presentationPolicyStore.getPolicy(session.sessionId),
|
||||
latestQuestion = null,
|
||||
latestResult = null,
|
||||
latestError = null,
|
||||
latestTrace = null,
|
||||
timeline = DIAGNOSTIC_NOT_LOADED,
|
||||
)
|
||||
}
|
||||
val selectedSessionId = chooseSelectedSession(sessionDetails, focusedSessionId)?.sessionId
|
||||
val parentSessionId = selectedSessionId?.let { selectedId ->
|
||||
findParentSession(sessionDetails, sessionDetails.firstOrNull { it.sessionId == selectedId })?.sessionId
|
||||
}
|
||||
val diagnosticSessionIds = linkedSetOf<String>().apply {
|
||||
parentSessionId?.let(::add)
|
||||
selectedSessionId?.let(::add)
|
||||
}
|
||||
val diagnosticsBySessionId = diagnosticSessionIds.associateWith { sessionId ->
|
||||
loadSessionDiagnostics(manager, sessionId)
|
||||
}
|
||||
sessionDetails = sessionDetails.map { session ->
|
||||
diagnosticsBySessionId[session.sessionId]?.let(session::withDiagnostics) ?: session
|
||||
}
|
||||
sessionDetails = deriveDirectParentUiState(sessionDetails)
|
||||
val selectedSession = chooseSelectedSession(sessionDetails, focusedSessionId)
|
||||
val parentSession = findParentSession(sessionDetails, selectedSession)
|
||||
val relatedSessions = if (parentSession == null) {
|
||||
selectedSession?.let(::listOf) ?: emptyList()
|
||||
} else {
|
||||
sessionDetails.filter { session ->
|
||||
session.sessionId == parentSession.sessionId ||
|
||||
session.parentSessionId == parentSession.sessionId
|
||||
}.sortedWith(compareBy<AgentSessionDetails> { it.parentSessionId != null }.thenBy { it.sessionId })
|
||||
}
|
||||
return AgentSnapshot(
|
||||
available = true,
|
||||
roleHolders = roleHolders,
|
||||
selectedGeniePackage = selectedGeniePackage,
|
||||
sessions = sessionDetails,
|
||||
selectedSession = selectedSession,
|
||||
parentSession = parentSession,
|
||||
relatedSessions = relatedSessions,
|
||||
)
|
||||
}
|
||||
|
||||
fun startDirectSession(
|
||||
plan: AgentDelegationPlan,
|
||||
allowDetachedMode: Boolean,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
): SessionStartResult {
|
||||
val pendingSession = createPendingDirectSession(
|
||||
objective = plan.originalObjective,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
return startDirectSessionChildren(
|
||||
parentSessionId = pendingSession.parentSessionId,
|
||||
geniePackage = pendingSession.geniePackage,
|
||||
plan = plan,
|
||||
allowDetachedMode = allowDetachedMode,
|
||||
executionSettings = executionSettings,
|
||||
cancelParentOnFailure = true,
|
||||
)
|
||||
}
|
||||
|
||||
fun createPendingDirectSession(
|
||||
objective: String,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
): PendingDirectSessionStart {
|
||||
val manager = requireAgentManager()
|
||||
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
|
||||
?: throw IllegalStateException("No GENIE role holder configured")
|
||||
val parentSession = manager.createDirectSession(currentUserId())
|
||||
try {
|
||||
executionSettingsStore.saveSettings(parentSession.sessionId, executionSettings)
|
||||
manager.publishTrace(
|
||||
parentSession.sessionId,
|
||||
"Planning Codex direct session for objective: $objective",
|
||||
)
|
||||
manager.updateSessionState(parentSession.sessionId, AgentSessionInfo.STATE_RUNNING)
|
||||
return PendingDirectSessionStart(
|
||||
parentSessionId = parentSession.sessionId,
|
||||
geniePackage = geniePackage,
|
||||
)
|
||||
} catch (err: RuntimeException) {
|
||||
runCatching { manager.cancelSession(parentSession.sessionId) }
|
||||
executionSettingsStore.removeSettings(parentSession.sessionId)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
fun startDirectSessionChildren(
|
||||
parentSessionId: String,
|
||||
geniePackage: String,
|
||||
plan: AgentDelegationPlan,
|
||||
allowDetachedMode: Boolean,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
cancelParentOnFailure: Boolean = false,
|
||||
): SessionStartResult {
|
||||
val manager = requireAgentManager()
|
||||
requireActiveDirectParentSession(manager, parentSessionId)
|
||||
val detachedPolicyTargets = plan.targets.filter { it.finalPresentationPolicy.requiresDetachedMode() }
|
||||
check(allowDetachedMode || detachedPolicyTargets.isEmpty()) {
|
||||
"Detached final presentation requires detached mode for ${detachedPolicyTargets.joinToString(", ") { it.packageName }}"
|
||||
}
|
||||
val childSessionIds = mutableListOf<String>()
|
||||
try {
|
||||
manager.publishTrace(
|
||||
parentSessionId,
|
||||
"Starting Codex direct session for objective: ${plan.originalObjective}",
|
||||
)
|
||||
plan.rationale?.let { rationale ->
|
||||
manager.publishTrace(parentSessionId, "Planning rationale: $rationale")
|
||||
}
|
||||
plan.targets.forEach { target ->
|
||||
requireActiveDirectParentSession(manager, parentSessionId)
|
||||
val childSession = manager.createChildSession(parentSessionId, target.packageName)
|
||||
childSessionIds += childSession.sessionId
|
||||
presentationPolicyStore.savePolicy(childSession.sessionId, target.finalPresentationPolicy)
|
||||
executionSettingsStore.saveSettings(childSession.sessionId, executionSettings)
|
||||
provisionSessionNetworkConfig(childSession.sessionId)
|
||||
manager.publishTrace(
|
||||
parentSessionId,
|
||||
"Created child session ${childSession.sessionId} for ${target.packageName} with required final presentation ${target.finalPresentationPolicy.wireValue}.",
|
||||
)
|
||||
requireActiveDirectParentSession(manager, parentSessionId)
|
||||
manager.startGenieSession(
|
||||
childSession.sessionId,
|
||||
geniePackage,
|
||||
buildDelegatedPrompt(target),
|
||||
allowDetachedMode,
|
||||
)
|
||||
}
|
||||
return SessionStartResult(
|
||||
parentSessionId = parentSessionId,
|
||||
childSessionIds = childSessionIds,
|
||||
plannedTargets = plan.targets.map(AgentDelegationTarget::packageName),
|
||||
geniePackage = geniePackage,
|
||||
anchor = AgentSessionInfo.ANCHOR_AGENT,
|
||||
)
|
||||
} catch (err: RuntimeException) {
|
||||
childSessionIds.forEach { childSessionId ->
|
||||
runCatching { manager.cancelSession(childSessionId) }
|
||||
presentationPolicyStore.removePolicy(childSessionId)
|
||||
executionSettingsStore.removeSettings(childSessionId)
|
||||
}
|
||||
if (cancelParentOnFailure) {
|
||||
runCatching { manager.cancelSession(parentSessionId) }
|
||||
executionSettingsStore.removeSettings(parentSessionId)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
fun startHomeSession(
|
||||
targetPackage: String,
|
||||
prompt: String,
|
||||
allowDetachedMode: Boolean,
|
||||
finalPresentationPolicy: SessionFinalPresentationPolicy,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
): SessionStartResult {
|
||||
val manager = requireAgentManager()
|
||||
check(canStartSessionForTarget(targetPackage)) {
|
||||
"Target package $targetPackage is not eligible for session start"
|
||||
}
|
||||
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
|
||||
?: throw IllegalStateException("No GENIE role holder configured")
|
||||
val session = manager.createAppScopedSession(targetPackage, currentUserId())
|
||||
presentationPolicyStore.savePolicy(session.sessionId, finalPresentationPolicy)
|
||||
executionSettingsStore.saveSettings(session.sessionId, executionSettings)
|
||||
try {
|
||||
provisionSessionNetworkConfig(session.sessionId)
|
||||
manager.publishTrace(
|
||||
session.sessionId,
|
||||
"Starting Codex app-scoped session for $targetPackage with required final presentation ${finalPresentationPolicy.wireValue}.",
|
||||
)
|
||||
manager.startGenieSession(
|
||||
session.sessionId,
|
||||
geniePackage,
|
||||
buildDelegatedPrompt(
|
||||
AgentDelegationTarget(
|
||||
packageName = targetPackage,
|
||||
objective = prompt,
|
||||
finalPresentationPolicy = finalPresentationPolicy,
|
||||
),
|
||||
),
|
||||
allowDetachedMode,
|
||||
)
|
||||
return SessionStartResult(
|
||||
parentSessionId = session.sessionId,
|
||||
childSessionIds = listOf(session.sessionId),
|
||||
plannedTargets = listOf(targetPackage),
|
||||
geniePackage = geniePackage,
|
||||
anchor = AgentSessionInfo.ANCHOR_HOME,
|
||||
)
|
||||
} catch (err: RuntimeException) {
|
||||
presentationPolicyStore.removePolicy(session.sessionId)
|
||||
executionSettingsStore.removeSettings(session.sessionId)
|
||||
runCatching { manager.cancelSession(session.sessionId) }
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
fun startExistingHomeSession(
|
||||
sessionId: String,
|
||||
targetPackage: String,
|
||||
prompt: String,
|
||||
allowDetachedMode: Boolean,
|
||||
finalPresentationPolicy: SessionFinalPresentationPolicy,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
): SessionStartResult {
|
||||
val manager = requireAgentManager()
|
||||
check(canStartSessionForTarget(targetPackage)) {
|
||||
"Target package $targetPackage is not eligible for session start"
|
||||
}
|
||||
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
|
||||
?: throw IllegalStateException("No GENIE role holder configured")
|
||||
presentationPolicyStore.savePolicy(sessionId, finalPresentationPolicy)
|
||||
executionSettingsStore.saveSettings(sessionId, executionSettings)
|
||||
try {
|
||||
provisionSessionNetworkConfig(sessionId)
|
||||
manager.publishTrace(
|
||||
sessionId,
|
||||
"Starting Codex app-scoped session for $targetPackage with required final presentation ${finalPresentationPolicy.wireValue}.",
|
||||
)
|
||||
manager.startGenieSession(
|
||||
sessionId,
|
||||
geniePackage,
|
||||
buildDelegatedPrompt(
|
||||
AgentDelegationTarget(
|
||||
packageName = targetPackage,
|
||||
objective = prompt,
|
||||
finalPresentationPolicy = finalPresentationPolicy,
|
||||
),
|
||||
),
|
||||
allowDetachedMode,
|
||||
)
|
||||
return SessionStartResult(
|
||||
parentSessionId = sessionId,
|
||||
childSessionIds = listOf(sessionId),
|
||||
plannedTargets = listOf(targetPackage),
|
||||
geniePackage = geniePackage,
|
||||
anchor = AgentSessionInfo.ANCHOR_HOME,
|
||||
)
|
||||
} catch (err: RuntimeException) {
|
||||
presentationPolicyStore.removePolicy(sessionId)
|
||||
executionSettingsStore.removeSettings(sessionId)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
fun continueDirectSessionInPlace(
|
||||
parentSessionId: String,
|
||||
target: AgentDelegationTarget,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
): SessionStartResult {
|
||||
val manager = requireAgentManager()
|
||||
check(canStartSessionForTarget(target.packageName)) {
|
||||
"Target package ${target.packageName} is not eligible for session continuation"
|
||||
}
|
||||
val geniePackage = selectGeniePackage(manager.getGenieRoleHolders(currentUserId()))
|
||||
?: throw IllegalStateException("No GENIE role holder configured")
|
||||
executionSettingsStore.saveSettings(parentSessionId, executionSettings)
|
||||
Log.i(TAG, "Continuing AGENT session $parentSessionId with target ${target.packageName}")
|
||||
manager.publishTrace(
|
||||
parentSessionId,
|
||||
"Continuing Codex direct session for ${target.packageName} with required final presentation ${target.finalPresentationPolicy.wireValue}.",
|
||||
)
|
||||
val childSession = manager.createChildSession(parentSessionId, target.packageName)
|
||||
AgentSessionBridgeServer.ensureStarted(appContext, manager, childSession.sessionId)
|
||||
presentationPolicyStore.savePolicy(childSession.sessionId, target.finalPresentationPolicy)
|
||||
executionSettingsStore.saveSettings(childSession.sessionId, executionSettings)
|
||||
provisionSessionNetworkConfig(childSession.sessionId)
|
||||
manager.startGenieSession(
|
||||
childSession.sessionId,
|
||||
geniePackage,
|
||||
buildDelegatedPrompt(target),
|
||||
/* allowDetachedMode = */ true,
|
||||
)
|
||||
return SessionStartResult(
|
||||
parentSessionId = parentSessionId,
|
||||
childSessionIds = listOf(childSession.sessionId),
|
||||
plannedTargets = listOf(target.packageName),
|
||||
geniePackage = geniePackage,
|
||||
anchor = AgentSessionInfo.ANCHOR_AGENT,
|
||||
)
|
||||
}
|
||||
|
||||
fun executionSettingsForSession(sessionId: String): SessionExecutionSettings {
|
||||
return executionSettingsStore.getSettings(sessionId)
|
||||
}
|
||||
|
||||
fun answerQuestion(sessionId: String, answer: String, parentSessionId: String?) {
|
||||
val manager = requireAgentManager()
|
||||
repeat(QUESTION_ANSWER_RETRY_COUNT) { attempt ->
|
||||
runCatching {
|
||||
manager.answerQuestion(sessionId, answer)
|
||||
}.onSuccess {
|
||||
if (parentSessionId != null) {
|
||||
manager.publishTrace(parentSessionId, "Answered question for $sessionId: $answer")
|
||||
}
|
||||
return
|
||||
}.onFailure { err ->
|
||||
if (attempt == QUESTION_ANSWER_RETRY_COUNT - 1 || !shouldRetryAnswerQuestion(sessionId, err)) {
|
||||
throw err
|
||||
}
|
||||
Thread.sleep(QUESTION_ANSWER_RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun isSessionWaitingForUser(sessionId: String): Boolean {
|
||||
val manager = agentManager ?: return false
|
||||
return manager.getSessions(currentUserId()).any { session ->
|
||||
session.sessionId == sessionId &&
|
||||
session.state == AgentSessionInfo.STATE_WAITING_FOR_USER
|
||||
}
|
||||
}
|
||||
|
||||
fun attachTarget(sessionId: String) {
|
||||
requireAgentManager().attachTarget(sessionId)
|
||||
}
|
||||
|
||||
fun cancelSession(sessionId: String) {
|
||||
requireAgentManager().cancelSession(sessionId)
|
||||
}
|
||||
|
||||
fun failDirectSession(
|
||||
sessionId: String,
|
||||
message: String,
|
||||
) {
|
||||
val manager = requireAgentManager()
|
||||
manager.publishError(sessionId, message)
|
||||
manager.updateSessionState(sessionId, AgentSessionInfo.STATE_FAILED)
|
||||
}
|
||||
|
||||
fun isTerminalSession(sessionId: String): Boolean {
|
||||
val manager = agentManager ?: return true
|
||||
val session = manager.getSessions(currentUserId()).firstOrNull { it.sessionId == sessionId } ?: return true
|
||||
return isTerminalState(session.state)
|
||||
}
|
||||
|
||||
fun cancelActiveSessions(): CancelActiveSessionsResult {
|
||||
val manager = requireAgentManager()
|
||||
val activeSessions = manager.getSessions(currentUserId())
|
||||
.filterNot { isTerminalState(it.state) }
|
||||
.sortedWith(
|
||||
compareByDescending<AgentSessionInfo> { it.parentSessionId != null }
|
||||
.thenBy { it.sessionId },
|
||||
)
|
||||
val cancelledSessionIds = mutableListOf<String>()
|
||||
val failedSessionIds = mutableMapOf<String, String>()
|
||||
activeSessions.forEach { session ->
|
||||
runCatching {
|
||||
manager.cancelSession(session.sessionId)
|
||||
}.onSuccess {
|
||||
cancelledSessionIds += session.sessionId
|
||||
}.onFailure { err ->
|
||||
failedSessionIds[session.sessionId] = err.message ?: err::class.java.simpleName
|
||||
}
|
||||
}
|
||||
return CancelActiveSessionsResult(
|
||||
cancelledSessionIds = cancelledSessionIds,
|
||||
failedSessionIds = failedSessionIds,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requireAgentManager(): AgentManager {
|
||||
return checkNotNull(agentManager) { "AgentManager unavailable" }
|
||||
}
|
||||
|
||||
private fun provisionSessionNetworkConfig(sessionId: String) {
|
||||
val manager = requireAgentManager()
|
||||
FrameworkSessionTransportCompat.setSessionNetworkConfig(
|
||||
agentManager = manager,
|
||||
sessionId = sessionId,
|
||||
config = AgentResponsesProxy.buildFrameworkSessionNetworkConfig(
|
||||
context = appContext,
|
||||
upstreamBaseUrl = "provider-default",
|
||||
),
|
||||
)
|
||||
Log.i(TAG, "Configured framework-owned /responses transport for $sessionId")
|
||||
}
|
||||
|
||||
private fun requireActiveDirectParentSession(
|
||||
manager: AgentManager,
|
||||
parentSessionId: String,
|
||||
) {
|
||||
val parentSession = manager.getSessions(currentUserId()).firstOrNull { session ->
|
||||
session.sessionId == parentSessionId
|
||||
} ?: throw IllegalStateException("Parent session $parentSessionId is no longer available")
|
||||
check(isDirectParentSession(parentSession)) {
|
||||
"Session $parentSessionId is not an active direct parent session"
|
||||
}
|
||||
check(!isTerminalState(parentSession.state)) {
|
||||
"Parent session $parentSessionId is no longer active"
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldRetryAnswerQuestion(
|
||||
sessionId: String,
|
||||
err: Throwable,
|
||||
): Boolean {
|
||||
return err.message?.contains("not waiting for user input", ignoreCase = true) == true ||
|
||||
!isSessionWaitingForUser(sessionId)
|
||||
}
|
||||
|
||||
private fun chooseSelectedSession(
|
||||
sessions: List<AgentSessionDetails>,
|
||||
focusedSessionId: String?,
|
||||
): AgentSessionDetails? {
|
||||
val sessionsById = sessions.associateBy(AgentSessionDetails::sessionId)
|
||||
val focusedSession = focusedSessionId?.let(sessionsById::get)
|
||||
if (focusedSession != null) {
|
||||
if (focusedSession.parentSessionId != null) {
|
||||
return focusedSession
|
||||
}
|
||||
val childCandidate = sessions.firstOrNull { session ->
|
||||
session.parentSessionId == focusedSession.sessionId &&
|
||||
session.state == AgentSessionInfo.STATE_WAITING_FOR_USER
|
||||
} ?: sessions.firstOrNull { session ->
|
||||
session.parentSessionId == focusedSession.sessionId &&
|
||||
!isTerminalState(session.state)
|
||||
}
|
||||
val latestChild = sessions.lastOrNull { session ->
|
||||
session.parentSessionId == focusedSession.sessionId
|
||||
}
|
||||
return childCandidate ?: latestChild ?: focusedSession
|
||||
}
|
||||
return sessions.firstOrNull { session ->
|
||||
session.parentSessionId != null &&
|
||||
session.state == AgentSessionInfo.STATE_WAITING_FOR_USER
|
||||
} ?: sessions.firstOrNull { session ->
|
||||
session.parentSessionId != null && !isTerminalState(session.state)
|
||||
} ?: sessions.firstOrNull(::isDirectParentSession) ?: sessions.firstOrNull()
|
||||
}
|
||||
|
||||
private fun findParentSession(
|
||||
sessions: List<AgentSessionDetails>,
|
||||
selectedSession: AgentSessionDetails?,
|
||||
): AgentSessionDetails? {
|
||||
if (selectedSession == null) {
|
||||
return null
|
||||
}
|
||||
if (selectedSession.parentSessionId == null) {
|
||||
return if (isDirectParentSession(selectedSession)) {
|
||||
selectedSession
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
return sessions.firstOrNull { it.sessionId == selectedSession.parentSessionId }
|
||||
}
|
||||
|
||||
private fun selectGeniePackage(roleHolders: List<String>): String? {
|
||||
return when {
|
||||
roleHolders.contains(PREFERRED_GENIE_PACKAGE) -> PREFERRED_GENIE_PACKAGE
|
||||
else -> roleHolders.firstOrNull()
|
||||
}
|
||||
}
|
||||
|
||||
private fun deriveDirectParentUiState(sessions: List<AgentSessionDetails>): List<AgentSessionDetails> {
|
||||
val childrenByParent = sessions
|
||||
.filter { it.parentSessionId != null }
|
||||
.groupBy { it.parentSessionId }
|
||||
return sessions.map { session ->
|
||||
if (!isDirectParentSession(session)) {
|
||||
return@map session
|
||||
}
|
||||
val childSessions = childrenByParent[session.sessionId].orEmpty()
|
||||
if (childSessions.isEmpty()) {
|
||||
return@map session
|
||||
}
|
||||
val rollup = AgentParentSessionAggregator.rollup(
|
||||
childSessions.map { childSession ->
|
||||
ParentSessionChildSummary(
|
||||
sessionId = childSession.sessionId,
|
||||
targetPackage = childSession.targetPackage,
|
||||
state = childSession.state,
|
||||
targetPresentation = childSession.targetPresentation,
|
||||
requiredFinalPresentationPolicy = childSession.requiredFinalPresentationPolicy,
|
||||
latestResult = childSession.latestResult,
|
||||
latestError = childSession.latestError,
|
||||
)
|
||||
},
|
||||
)
|
||||
val isRollupTerminal = isTerminalState(rollup.state)
|
||||
session.copy(
|
||||
state = rollup.state,
|
||||
stateLabel = stateToString(rollup.state),
|
||||
latestResult = rollup.resultMessage ?: session.latestResult.takeIf { isRollupTerminal },
|
||||
latestError = rollup.errorMessage ?: session.latestError.takeIf { isRollupTerminal },
|
||||
latestTrace = when (rollup.state) {
|
||||
AgentSessionInfo.STATE_RUNNING -> "Child session running."
|
||||
AgentSessionInfo.STATE_WAITING_FOR_USER -> "Child session waiting for user input."
|
||||
AgentSessionInfo.STATE_QUEUED -> "Child session queued."
|
||||
else -> session.latestTrace
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildDelegatedPrompt(target: AgentDelegationTarget): String {
|
||||
return buildString {
|
||||
appendLine(target.objective)
|
||||
appendLine()
|
||||
appendLine("Required final target presentation: ${target.finalPresentationPolicy.wireValue}")
|
||||
append(target.finalPresentationPolicy.promptGuidance())
|
||||
}.trim()
|
||||
}
|
||||
|
||||
private fun findLastEventMessage(events: List<AgentSessionEvent>, type: Int): String? {
|
||||
for (index in events.indices.reversed()) {
|
||||
val event = events[index]
|
||||
if (event.type == type && event.message != null) {
|
||||
return normalizeEventMessage(event.message)
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
private fun loadSessionDiagnostics(manager: AgentManager, sessionId: String): SessionDiagnostics {
|
||||
val events = manager.getSessionEvents(sessionId)
|
||||
return SessionDiagnostics(
|
||||
latestQuestion = findLastEventMessage(events, AgentSessionEvent.TYPE_QUESTION),
|
||||
latestResult = findLastEventMessage(events, AgentSessionEvent.TYPE_RESULT),
|
||||
latestError = findLastEventMessage(events, AgentSessionEvent.TYPE_ERROR),
|
||||
latestTrace = findLastEventMessage(events, AgentSessionEvent.TYPE_TRACE),
|
||||
timeline = renderTimeline(events),
|
||||
)
|
||||
}
|
||||
|
||||
private fun renderTimeline(events: List<AgentSessionEvent>): String {
|
||||
if (events.isEmpty()) {
|
||||
return "No framework events yet."
|
||||
}
|
||||
return events.takeLast(MAX_TIMELINE_EVENTS).joinToString("\n") { event ->
|
||||
"${eventTypeToString(event.type)}: ${normalizeEventMessage(event.message).orEmpty()}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun normalizeEventMessage(message: String?): String? {
|
||||
val trimmed = message?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
if (trimmed.startsWith(BRIDGE_REQUEST_PREFIX)) {
|
||||
return summarizeBridgeRequest(trimmed)
|
||||
}
|
||||
if (trimmed.startsWith(BRIDGE_RESPONSE_PREFIX)) {
|
||||
return summarizeBridgeResponse(trimmed)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
private fun summarizeBridgeRequest(message: String): String {
|
||||
val request = runCatching {
|
||||
org.json.JSONObject(message.removePrefix(BRIDGE_REQUEST_PREFIX))
|
||||
}.getOrNull()
|
||||
val method = request?.optString("method")?.ifEmpty { "unknown" } ?: "unknown"
|
||||
val requestId = request?.optString("requestId")?.takeIf(String::isNotBlank)
|
||||
return buildString {
|
||||
append("Bridge request: ")
|
||||
append(method)
|
||||
requestId?.let { append(" (#$it)") }
|
||||
}
|
||||
}
|
||||
|
||||
private fun summarizeBridgeResponse(message: String): String {
|
||||
val response = runCatching {
|
||||
org.json.JSONObject(message.removePrefix(BRIDGE_RESPONSE_PREFIX))
|
||||
}.getOrNull()
|
||||
val requestId = response?.optString("requestId")?.takeIf(String::isNotBlank)
|
||||
val statusCode = response?.optJSONObject("httpResponse")?.optInt("statusCode")
|
||||
val ok = response?.optBoolean("ok")
|
||||
return buildString {
|
||||
append("Bridge response")
|
||||
requestId?.let { append(" (#$it)") }
|
||||
if (statusCode != null) {
|
||||
append(": HTTP $statusCode")
|
||||
} else if (ok != null) {
|
||||
append(": ")
|
||||
append(if (ok) "ok" else "error")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun eventTypeToString(type: Int): String {
|
||||
return when (type) {
|
||||
AgentSessionEvent.TYPE_TRACE -> "Trace"
|
||||
AgentSessionEvent.TYPE_QUESTION -> "Question"
|
||||
AgentSessionEvent.TYPE_RESULT -> "Result"
|
||||
AgentSessionEvent.TYPE_ERROR -> "Error"
|
||||
AgentSessionEvent.TYPE_POLICY -> "Policy"
|
||||
AgentSessionEvent.TYPE_DETACHED_ACTION -> "DetachedAction"
|
||||
AgentSessionEvent.TYPE_ANSWER -> "Answer"
|
||||
else -> "Event($type)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isDirectParentSession(session: AgentSessionDetails): Boolean {
|
||||
return session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
|
||||
session.parentSessionId == null &&
|
||||
session.targetPackage == null
|
||||
}
|
||||
|
||||
private fun isDirectParentSession(session: AgentSessionInfo): Boolean {
|
||||
return session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
|
||||
session.parentSessionId == null &&
|
||||
session.targetPackage == null
|
||||
}
|
||||
|
||||
private fun isTerminalState(state: Int): Boolean {
|
||||
return state == AgentSessionInfo.STATE_COMPLETED ||
|
||||
state == AgentSessionInfo.STATE_CANCELLED ||
|
||||
state == AgentSessionInfo.STATE_FAILED
|
||||
}
|
||||
|
||||
private fun stateToString(state: Int): String {
|
||||
return when (state) {
|
||||
AgentSessionInfo.STATE_CREATED -> "CREATED"
|
||||
AgentSessionInfo.STATE_RUNNING -> "RUNNING"
|
||||
AgentSessionInfo.STATE_WAITING_FOR_USER -> "WAITING_FOR_USER"
|
||||
AgentSessionInfo.STATE_QUEUED -> "QUEUED"
|
||||
AgentSessionInfo.STATE_COMPLETED -> "COMPLETED"
|
||||
AgentSessionInfo.STATE_CANCELLED -> "CANCELLED"
|
||||
AgentSessionInfo.STATE_FAILED -> "FAILED"
|
||||
else -> state.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun currentUserId(): Int = Process.myUid() / 100000
|
||||
}
|
||||
|
||||
data class AgentSnapshot(
|
||||
val available: Boolean,
|
||||
val roleHolders: List<String>,
|
||||
val selectedGeniePackage: String?,
|
||||
val sessions: List<AgentSessionDetails>,
|
||||
val selectedSession: AgentSessionDetails?,
|
||||
val parentSession: AgentSessionDetails?,
|
||||
val relatedSessions: List<AgentSessionDetails>,
|
||||
) {
|
||||
companion object {
|
||||
val unavailable = AgentSnapshot(
|
||||
available = false,
|
||||
roleHolders = emptyList(),
|
||||
selectedGeniePackage = null,
|
||||
sessions = emptyList(),
|
||||
selectedSession = null,
|
||||
parentSession = null,
|
||||
relatedSessions = emptyList(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class AgentSessionDetails(
|
||||
val sessionId: String,
|
||||
val parentSessionId: String?,
|
||||
val targetPackage: String?,
|
||||
val anchor: Int,
|
||||
val state: Int,
|
||||
val stateLabel: String,
|
||||
val targetPresentation: Int,
|
||||
val targetPresentationLabel: String,
|
||||
val targetRuntime: Int?,
|
||||
val targetRuntimeLabel: String,
|
||||
val targetDetached: Boolean,
|
||||
val requiredFinalPresentationPolicy: SessionFinalPresentationPolicy?,
|
||||
val latestQuestion: String?,
|
||||
val latestResult: String?,
|
||||
val latestError: String?,
|
||||
val latestTrace: String?,
|
||||
val timeline: String,
|
||||
) {
|
||||
fun withDiagnostics(diagnostics: SessionDiagnostics): AgentSessionDetails {
|
||||
return copy(
|
||||
latestQuestion = diagnostics.latestQuestion,
|
||||
latestResult = diagnostics.latestResult,
|
||||
latestError = diagnostics.latestError,
|
||||
latestTrace = diagnostics.latestTrace,
|
||||
timeline = diagnostics.timeline,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
data class SessionDiagnostics(
|
||||
val latestQuestion: String?,
|
||||
val latestResult: String?,
|
||||
val latestError: String?,
|
||||
val latestTrace: String?,
|
||||
val timeline: String,
|
||||
)
|
||||
|
||||
data class SessionStartResult(
|
||||
val parentSessionId: String,
|
||||
val childSessionIds: List<String>,
|
||||
val plannedTargets: List<String>,
|
||||
val geniePackage: String,
|
||||
val anchor: Int,
|
||||
)
|
||||
|
||||
data class PendingDirectSessionStart(
|
||||
val parentSessionId: String,
|
||||
val geniePackage: String,
|
||||
)
|
||||
|
||||
data class CancelActiveSessionsResult(
|
||||
val cancelledSessionIds: List<String>,
|
||||
val failedSessionIds: Map<String, String>,
|
||||
)
|
||||
@@ -0,0 +1,173 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.content.Context
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import kotlin.concurrent.thread
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
data class LaunchSessionRequest(
|
||||
val prompt: String,
|
||||
val targetPackage: String?,
|
||||
val model: String?,
|
||||
val reasoningEffort: String?,
|
||||
val existingSessionId: String? = null,
|
||||
)
|
||||
|
||||
object AgentSessionLauncher {
|
||||
fun startSessionAsync(
|
||||
context: Context,
|
||||
request: LaunchSessionRequest,
|
||||
sessionController: AgentSessionController,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
|
||||
): SessionStartResult {
|
||||
val executionSettings = SessionExecutionSettings(
|
||||
model = request.model?.trim()?.ifEmpty { null },
|
||||
reasoningEffort = request.reasoningEffort?.trim()?.ifEmpty { null },
|
||||
)
|
||||
val targetPackage = request.targetPackage?.trim()?.ifEmpty { null }
|
||||
val existingSessionId = request.existingSessionId?.trim()?.ifEmpty { null }
|
||||
if (targetPackage != null || existingSessionId != null) {
|
||||
return startSession(
|
||||
context = context,
|
||||
request = request,
|
||||
sessionController = sessionController,
|
||||
requestUserInputHandler = requestUserInputHandler,
|
||||
)
|
||||
}
|
||||
val pendingSession = sessionController.createPendingDirectSession(
|
||||
objective = request.prompt,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
val applicationContext = context.applicationContext
|
||||
thread(name = "CodexAgentPlanner-${pendingSession.parentSessionId}") {
|
||||
runCatching {
|
||||
AgentTaskPlanner.planSession(
|
||||
context = applicationContext,
|
||||
userObjective = request.prompt,
|
||||
executionSettings = executionSettings,
|
||||
sessionController = sessionController,
|
||||
requestUserInputHandler = null,
|
||||
frameworkSessionId = pendingSession.parentSessionId,
|
||||
)
|
||||
}.onFailure { err ->
|
||||
if (!sessionController.isTerminalSession(pendingSession.parentSessionId)) {
|
||||
sessionController.failDirectSession(
|
||||
pendingSession.parentSessionId,
|
||||
"Planning failed: ${err.message ?: err::class.java.simpleName}",
|
||||
)
|
||||
}
|
||||
}.onSuccess { plannedRequest ->
|
||||
if (!sessionController.isTerminalSession(pendingSession.parentSessionId)) {
|
||||
runCatching {
|
||||
sessionController.startDirectSessionChildren(
|
||||
parentSessionId = pendingSession.parentSessionId,
|
||||
geniePackage = pendingSession.geniePackage,
|
||||
plan = plannedRequest.plan,
|
||||
allowDetachedMode = plannedRequest.allowDetachedMode,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
}.onFailure { err ->
|
||||
if (!sessionController.isTerminalSession(pendingSession.parentSessionId)) {
|
||||
sessionController.failDirectSession(
|
||||
pendingSession.parentSessionId,
|
||||
"Failed to start planned child session: ${err.message ?: err::class.java.simpleName}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return SessionStartResult(
|
||||
parentSessionId = pendingSession.parentSessionId,
|
||||
childSessionIds = emptyList(),
|
||||
plannedTargets = emptyList(),
|
||||
geniePackage = pendingSession.geniePackage,
|
||||
anchor = AgentSessionInfo.ANCHOR_AGENT,
|
||||
)
|
||||
}
|
||||
|
||||
fun startSession(
|
||||
context: Context,
|
||||
request: LaunchSessionRequest,
|
||||
sessionController: AgentSessionController,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
|
||||
): SessionStartResult {
|
||||
val executionSettings = SessionExecutionSettings(
|
||||
model = request.model?.trim()?.ifEmpty { null },
|
||||
reasoningEffort = request.reasoningEffort?.trim()?.ifEmpty { null },
|
||||
)
|
||||
val targetPackage = request.targetPackage?.trim()?.ifEmpty { null }
|
||||
val existingSessionId = request.existingSessionId?.trim()?.ifEmpty { null }
|
||||
return if (targetPackage == null) {
|
||||
check(existingSessionId == null) {
|
||||
"Existing HOME sessions require a target package"
|
||||
}
|
||||
AgentTaskPlanner.startSession(
|
||||
context = context,
|
||||
userObjective = request.prompt,
|
||||
targetPackageOverride = null,
|
||||
allowDetachedMode = true,
|
||||
executionSettings = executionSettings,
|
||||
sessionController = sessionController,
|
||||
requestUserInputHandler = requestUserInputHandler,
|
||||
)
|
||||
} else {
|
||||
if (existingSessionId != null) {
|
||||
sessionController.startExistingHomeSession(
|
||||
sessionId = existingSessionId,
|
||||
targetPackage = targetPackage,
|
||||
prompt = request.prompt,
|
||||
allowDetachedMode = true,
|
||||
finalPresentationPolicy = SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
} else {
|
||||
sessionController.startHomeSession(
|
||||
targetPackage = targetPackage,
|
||||
prompt = request.prompt,
|
||||
allowDetachedMode = true,
|
||||
finalPresentationPolicy = SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun continueSessionInPlace(
|
||||
sourceTopLevelSession: AgentSessionDetails,
|
||||
selectedSession: AgentSessionDetails,
|
||||
prompt: String,
|
||||
sessionController: AgentSessionController,
|
||||
): SessionStartResult {
|
||||
val executionSettings = sessionController.executionSettingsForSession(sourceTopLevelSession.sessionId)
|
||||
return when (sourceTopLevelSession.anchor) {
|
||||
AgentSessionInfo.ANCHOR_HOME -> {
|
||||
throw UnsupportedOperationException(
|
||||
"In-place continuation is not supported for app-scoped HOME sessions on the current framework",
|
||||
)
|
||||
}
|
||||
|
||||
else -> {
|
||||
val targetPackage = checkNotNull(selectedSession.targetPackage) {
|
||||
"Select a target child session to continue"
|
||||
}
|
||||
sessionController.continueDirectSessionInPlace(
|
||||
parentSessionId = sourceTopLevelSession.sessionId,
|
||||
target = AgentDelegationTarget(
|
||||
packageName = targetPackage,
|
||||
objective = SessionContinuationPromptBuilder.build(
|
||||
sourceTopLevelSession = sourceTopLevelSession,
|
||||
selectedSession = selectedSession,
|
||||
prompt = prompt,
|
||||
),
|
||||
finalPresentationPolicy = selectedSession.requiredFinalPresentationPolicy
|
||||
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
),
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import java.io.IOException
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.json.JSONTokener
|
||||
|
||||
data class AgentDelegationTarget(
|
||||
val packageName: String,
|
||||
val objective: String,
|
||||
val finalPresentationPolicy: SessionFinalPresentationPolicy,
|
||||
)
|
||||
|
||||
data class AgentDelegationPlan(
|
||||
val originalObjective: String,
|
||||
val targets: List<AgentDelegationTarget>,
|
||||
val rationale: String?,
|
||||
val usedOverride: Boolean,
|
||||
) {
|
||||
val primaryTargetPackage: String
|
||||
get() = targets.first().packageName
|
||||
}
|
||||
|
||||
object AgentTaskPlanner {
|
||||
private const val TAG = "AgentTaskPlanner"
|
||||
private const val PLANNER_ATTEMPTS = 2
|
||||
private const val PLANNER_REQUEST_TIMEOUT_MS = 90_000L
|
||||
|
||||
private val PLANNER_INSTRUCTIONS =
|
||||
"""
|
||||
You are Codex acting as the Android Agent orchestrator.
|
||||
The user interacts only with the Agent. Decide which installed Android packages should receive delegated Genie sessions.
|
||||
Use the standard Android shell tools already available in this runtime, such as `cmd package`, `pm`, and `am`, to inspect installed packages and resolve the correct targets.
|
||||
Return exactly one JSON object and nothing else. Do not wrap it in markdown fences.
|
||||
JSON schema:
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"packageName": "installed.package",
|
||||
"objective": "free-form delegated objective for the child Genie",
|
||||
"finalPresentationPolicy": "ATTACHED | DETACHED_HIDDEN | DETACHED_SHOWN | AGENT_CHOICE"
|
||||
}
|
||||
],
|
||||
"reason": "short rationale",
|
||||
"allowDetachedMode": true
|
||||
}
|
||||
Rules:
|
||||
- Choose the fewest packages needed to complete the request.
|
||||
- `targets` must be non-empty.
|
||||
- Each delegated `objective` should be written for the child Genie, not the user.
|
||||
- Each target must include `finalPresentationPolicy`.
|
||||
- Use `ATTACHED` when the user wants the target left on the main screen or explicitly visible to them.
|
||||
- Use `DETACHED_SHOWN` when the target should remain visible but stay detached.
|
||||
- Use `DETACHED_HIDDEN` when the target should complete in the background without remaining visible.
|
||||
- Use `AGENT_CHOICE` only when the final presentation state does not matter.
|
||||
- Stop after at most 6 shell commands.
|
||||
- Start from the installed package list, then narrow to the most likely candidates.
|
||||
- Prefer direct package-manager commands over broad shell pipelines.
|
||||
- Verify each chosen package by inspecting focused query-activities or resolve-activity output before returning it.
|
||||
- Only choose packages that directly own the requested app behavior. Never choose helper packages such as `com.android.shell`, `com.android.systemui`, or the Codex Agent/Genie packages unless the user explicitly asked for them.
|
||||
- If the user objective already names a specific installed package, use it directly after verification.
|
||||
- `pm list packages PACKAGE_NAME` alone is not sufficient verification.
|
||||
- Prefer focused verification commands such as `pm list packages clock`, `cmd package query-activities --brief -p PACKAGE -a android.intent.action.MAIN`, and `cmd package resolve-activity --brief -a RELEVANT_ACTION PACKAGE`.
|
||||
- Do not enumerate every launcher activity on the device. Query specific candidate packages instead.
|
||||
""".trimIndent()
|
||||
private val PLANNER_OUTPUT_SCHEMA =
|
||||
JSONObject()
|
||||
.put("type", "object")
|
||||
.put(
|
||||
"properties",
|
||||
JSONObject()
|
||||
.put(
|
||||
"targets",
|
||||
JSONObject()
|
||||
.put("type", "array")
|
||||
.put("minItems", 1)
|
||||
.put(
|
||||
"items",
|
||||
JSONObject()
|
||||
.put("type", "object")
|
||||
.put(
|
||||
"properties",
|
||||
JSONObject()
|
||||
.put("packageName", JSONObject().put("type", "string"))
|
||||
.put("objective", JSONObject().put("type", "string"))
|
||||
.put(
|
||||
"finalPresentationPolicy",
|
||||
JSONObject()
|
||||
.put("type", "string")
|
||||
.put(
|
||||
"enum",
|
||||
JSONArray()
|
||||
.put(SessionFinalPresentationPolicy.ATTACHED.wireValue)
|
||||
.put(SessionFinalPresentationPolicy.DETACHED_HIDDEN.wireValue)
|
||||
.put(SessionFinalPresentationPolicy.DETACHED_SHOWN.wireValue)
|
||||
.put(SessionFinalPresentationPolicy.AGENT_CHOICE.wireValue),
|
||||
),
|
||||
),
|
||||
)
|
||||
.put(
|
||||
"required",
|
||||
JSONArray()
|
||||
.put("packageName")
|
||||
.put("objective")
|
||||
.put("finalPresentationPolicy"),
|
||||
)
|
||||
.put("additionalProperties", false),
|
||||
),
|
||||
)
|
||||
.put("reason", JSONObject().put("type", "string"))
|
||||
.put("allowDetachedMode", JSONObject().put("type", "boolean")),
|
||||
)
|
||||
.put("required", JSONArray().put("targets").put("reason").put("allowDetachedMode"))
|
||||
.put("additionalProperties", false)
|
||||
|
||||
fun startSession(
|
||||
context: Context,
|
||||
userObjective: String,
|
||||
targetPackageOverride: String?,
|
||||
allowDetachedMode: Boolean,
|
||||
finalPresentationPolicyOverride: SessionFinalPresentationPolicy? = null,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
sessionController: AgentSessionController,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
|
||||
): SessionStartResult {
|
||||
if (!targetPackageOverride.isNullOrBlank()) {
|
||||
Log.i(TAG, "Using explicit target override $targetPackageOverride")
|
||||
return sessionController.startDirectSession(
|
||||
plan = AgentDelegationPlan(
|
||||
originalObjective = userObjective,
|
||||
targets = listOf(
|
||||
AgentDelegationTarget(
|
||||
packageName = targetPackageOverride,
|
||||
objective = userObjective,
|
||||
finalPresentationPolicy =
|
||||
finalPresentationPolicyOverride ?: SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
),
|
||||
),
|
||||
rationale = "Using explicit target package override.",
|
||||
usedOverride = true,
|
||||
),
|
||||
allowDetachedMode = allowDetachedMode,
|
||||
)
|
||||
}
|
||||
val pendingSession = sessionController.createPendingDirectSession(
|
||||
objective = userObjective,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
val sessionStartResult = try {
|
||||
val request = planSession(
|
||||
context = context,
|
||||
userObjective = userObjective,
|
||||
executionSettings = executionSettings,
|
||||
sessionController = sessionController,
|
||||
requestUserInputHandler = requestUserInputHandler,
|
||||
frameworkSessionId = pendingSession.parentSessionId,
|
||||
)
|
||||
sessionController.startDirectSessionChildren(
|
||||
parentSessionId = pendingSession.parentSessionId,
|
||||
geniePackage = pendingSession.geniePackage,
|
||||
plan = request.plan,
|
||||
allowDetachedMode = allowDetachedMode && request.allowDetachedMode,
|
||||
executionSettings = executionSettings,
|
||||
cancelParentOnFailure = true,
|
||||
)
|
||||
} catch (err: IOException) {
|
||||
runCatching { sessionController.cancelSession(pendingSession.parentSessionId) }
|
||||
throw err
|
||||
} catch (err: RuntimeException) {
|
||||
runCatching { sessionController.cancelSession(pendingSession.parentSessionId) }
|
||||
throw err
|
||||
}
|
||||
Log.i(TAG, "Planner sessionStartResult=$sessionStartResult")
|
||||
return sessionStartResult
|
||||
}
|
||||
|
||||
fun planSession(
|
||||
context: Context,
|
||||
userObjective: String,
|
||||
executionSettings: SessionExecutionSettings = SessionExecutionSettings.default,
|
||||
sessionController: AgentSessionController,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
|
||||
frameworkSessionId: String? = null,
|
||||
): AgentFrameworkToolBridge.StartDirectSessionRequest {
|
||||
Log.i(TAG, "Planning Agent session for objective=${userObjective.take(160)}")
|
||||
val isEligibleTargetPackage = { packageName: String ->
|
||||
sessionController.canStartSessionForTarget(packageName) &&
|
||||
packageName !in setOf(
|
||||
"com.android.shell",
|
||||
"com.android.systemui",
|
||||
"com.openai.codex.agent",
|
||||
"com.openai.codex.genie",
|
||||
)
|
||||
}
|
||||
var previousPlannerResponse: String? = null
|
||||
var plannerRequest: AgentFrameworkToolBridge.StartDirectSessionRequest? = null
|
||||
var lastPlannerError: IOException? = null
|
||||
for (attemptIndex in 0 until PLANNER_ATTEMPTS) {
|
||||
val plannerResponse = AgentPlannerRuntimeManager.requestText(
|
||||
context = context,
|
||||
instructions = PLANNER_INSTRUCTIONS,
|
||||
prompt = buildPlannerPrompt(
|
||||
userObjective = userObjective,
|
||||
previousPlannerResponse = previousPlannerResponse,
|
||||
previousPlannerError = lastPlannerError?.message,
|
||||
),
|
||||
outputSchema = PLANNER_OUTPUT_SCHEMA,
|
||||
requestUserInputHandler = requestUserInputHandler,
|
||||
executionSettings = executionSettings,
|
||||
requestTimeoutMs = PLANNER_REQUEST_TIMEOUT_MS,
|
||||
frameworkSessionId = frameworkSessionId,
|
||||
)
|
||||
Log.i(TAG, "Planner response=${plannerResponse.take(400)}")
|
||||
previousPlannerResponse = plannerResponse
|
||||
val parsedRequest = runCatching {
|
||||
parsePlannerResponse(
|
||||
responseText = plannerResponse,
|
||||
userObjective = userObjective,
|
||||
isEligibleTargetPackage = isEligibleTargetPackage,
|
||||
)
|
||||
}.getOrElse { err ->
|
||||
if (err is IOException && attemptIndex < PLANNER_ATTEMPTS - 1) {
|
||||
Log.w(TAG, "Planner response rejected: ${err.message}")
|
||||
lastPlannerError = err
|
||||
continue
|
||||
}
|
||||
throw err
|
||||
}
|
||||
plannerRequest = parsedRequest
|
||||
break
|
||||
}
|
||||
return plannerRequest ?: throw (lastPlannerError
|
||||
?: IOException("Planner did not return a valid session plan"))
|
||||
}
|
||||
|
||||
private fun buildPlannerPrompt(
|
||||
userObjective: String,
|
||||
previousPlannerResponse: String?,
|
||||
previousPlannerError: String?,
|
||||
): String {
|
||||
return buildString {
|
||||
appendLine("User objective:")
|
||||
appendLine(userObjective)
|
||||
if (!previousPlannerError.isNullOrBlank()) {
|
||||
appendLine()
|
||||
appendLine("Previous candidate plan was rejected by host validation:")
|
||||
appendLine(previousPlannerError)
|
||||
appendLine("Choose a different installed target package and verify it with focused package commands.")
|
||||
}
|
||||
if (!previousPlannerResponse.isNullOrBlank()) {
|
||||
appendLine()
|
||||
appendLine("Previous invalid planner response:")
|
||||
appendLine(previousPlannerResponse)
|
||||
}
|
||||
}.trim()
|
||||
}
|
||||
|
||||
internal fun parsePlannerResponse(
|
||||
responseText: String,
|
||||
userObjective: String,
|
||||
isEligibleTargetPackage: (String) -> Boolean,
|
||||
): AgentFrameworkToolBridge.StartDirectSessionRequest {
|
||||
val plannerJson = extractPlannerJson(responseText)
|
||||
return AgentFrameworkToolBridge.parseStartDirectSessionArguments(
|
||||
arguments = plannerJson,
|
||||
userObjective = userObjective,
|
||||
isEligibleTargetPackage = isEligibleTargetPackage,
|
||||
)
|
||||
}
|
||||
|
||||
private fun extractPlannerJson(responseText: String): JSONObject {
|
||||
val trimmed = responseText.trim()
|
||||
parseJsonObject(trimmed)?.let { return it }
|
||||
val unfenced = trimmed
|
||||
.removePrefix("```json")
|
||||
.removePrefix("```")
|
||||
.removeSuffix("```")
|
||||
.trim()
|
||||
parseJsonObject(unfenced)?.let { return it }
|
||||
val firstBrace = trimmed.indexOf('{')
|
||||
val lastBrace = trimmed.lastIndexOf('}')
|
||||
if (firstBrace >= 0 && lastBrace > firstBrace) {
|
||||
parseJsonObject(trimmed.substring(firstBrace, lastBrace + 1))?.let { return it }
|
||||
}
|
||||
throw IOException("Planner did not return a valid JSON object")
|
||||
}
|
||||
|
||||
private fun parseJsonObject(text: String): JSONObject? {
|
||||
return runCatching {
|
||||
val tokener = JSONTokener(text)
|
||||
val value = tokener.nextValue()
|
||||
value as? JSONObject
|
||||
}.getOrNull()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.AlertDialog
|
||||
import android.widget.EditText
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.atomic.AtomicReference
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object AgentUserInputPrompter {
|
||||
fun promptForAnswers(
|
||||
activity: Activity,
|
||||
questions: JSONArray,
|
||||
): JSONObject {
|
||||
val latch = CountDownLatch(1)
|
||||
val answerText = AtomicReference("")
|
||||
val error = AtomicReference<IOException?>(null)
|
||||
activity.runOnUiThread {
|
||||
val input = EditText(activity).apply {
|
||||
minLines = 4
|
||||
maxLines = 8
|
||||
setSingleLine(false)
|
||||
setText("")
|
||||
hint = "Type your answer here"
|
||||
}
|
||||
AlertDialog.Builder(activity)
|
||||
.setTitle("Codex needs input")
|
||||
.setMessage(renderQuestions(questions))
|
||||
.setView(input)
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Submit") { dialog, _ ->
|
||||
answerText.set(input.text?.toString().orEmpty())
|
||||
dialog.dismiss()
|
||||
latch.countDown()
|
||||
}
|
||||
.setNegativeButton("Cancel") { dialog, _ ->
|
||||
error.set(IOException("User cancelled Agent input"))
|
||||
dialog.dismiss()
|
||||
latch.countDown()
|
||||
}
|
||||
.show()
|
||||
}
|
||||
latch.await()
|
||||
error.get()?.let { throw it }
|
||||
return JSONObject().put("answers", buildQuestionAnswers(questions, answerText.get()))
|
||||
}
|
||||
|
||||
internal fun renderQuestions(questions: JSONArray): String {
|
||||
if (questions.length() == 0) {
|
||||
return "Codex requested input but did not provide a question."
|
||||
}
|
||||
val rendered = buildString {
|
||||
for (index in 0 until questions.length()) {
|
||||
val question = questions.optJSONObject(index) ?: continue
|
||||
if (length > 0) {
|
||||
append("\n\n")
|
||||
}
|
||||
val header = question.optString("header").takeIf(String::isNotBlank)
|
||||
if (header != null) {
|
||||
append(header)
|
||||
append(":\n")
|
||||
}
|
||||
append(question.optString("question"))
|
||||
val options = question.optJSONArray("options")
|
||||
if (options != null && options.length() > 0) {
|
||||
append("\nOptions:")
|
||||
for (optionIndex in 0 until options.length()) {
|
||||
val option = options.optJSONObject(optionIndex) ?: continue
|
||||
append("\n- ")
|
||||
append(option.optString("label"))
|
||||
val description = option.optString("description")
|
||||
if (description.isNotBlank()) {
|
||||
append(": ")
|
||||
append(description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (questions.length() == 1) {
|
||||
rendered
|
||||
} else {
|
||||
"$rendered\n\nReply with one answer per question, separated by a blank line."
|
||||
}
|
||||
}
|
||||
|
||||
internal fun buildQuestionAnswers(
|
||||
questions: JSONArray,
|
||||
answer: String,
|
||||
): JSONObject {
|
||||
val splitAnswers = answer
|
||||
.split(Regex("\\n\\s*\\n"))
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
val answersJson = JSONObject()
|
||||
for (index in 0 until questions.length()) {
|
||||
val question = questions.optJSONObject(index) ?: continue
|
||||
val questionId = question.optString("id")
|
||||
if (questionId.isBlank()) {
|
||||
continue
|
||||
}
|
||||
val responseText = splitAnswers.getOrNull(index)
|
||||
?: if (index == 0) answer.trim() else ""
|
||||
answersJson.put(
|
||||
questionId,
|
||||
JSONObject().put(
|
||||
"answers",
|
||||
JSONArray().put(responseText),
|
||||
),
|
||||
)
|
||||
}
|
||||
return answersJson
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object AppLabelResolver {
|
||||
fun loadAppLabel(
|
||||
context: Context,
|
||||
packageName: String?,
|
||||
): String {
|
||||
if (packageName.isNullOrBlank()) {
|
||||
return "Agent"
|
||||
}
|
||||
val pm = context.packageManager
|
||||
return runCatching {
|
||||
val applicationInfo = pm.getApplicationInfo(packageName, 0)
|
||||
pm.getApplicationLabel(applicationInfo)?.toString().orEmpty().ifBlank { packageName }
|
||||
}.getOrDefault(packageName)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,473 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentManager
|
||||
import android.app.agent.AgentService
|
||||
import android.app.agent.AgentSessionEvent
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.os.Process
|
||||
import android.util.Log
|
||||
import java.io.IOException
|
||||
import kotlin.concurrent.thread
|
||||
import org.json.JSONObject
|
||||
|
||||
class CodexAgentService : AgentService() {
|
||||
companion object {
|
||||
private const val TAG = "CodexAgentService"
|
||||
private const val BRIDGE_REQUEST_PREFIX = "__codex_bridge__ "
|
||||
private const val BRIDGE_RESPONSE_PREFIX = "__codex_bridge_result__ "
|
||||
private const val BRIDGE_METHOD_GET_RUNTIME_STATUS = "getRuntimeStatus"
|
||||
private const val AUTO_ANSWER_ESCALATE_PREFIX = "ESCALATE:"
|
||||
private const val AUTO_ANSWER_INSTRUCTIONS =
|
||||
"You are Codex acting as the Android Agent supervising a Genie execution. If you can answer the current Genie question from the available session context, call the framework session tool `android.framework.sessions.answer_question` exactly once with a short free-form answer. You may inspect current framework state with `android.framework.sessions.list`. If user input is required, do not call any framework tool. Instead reply with `ESCALATE: ` followed by the exact question the Agent should ask the user."
|
||||
private const val MAX_AUTO_ANSWER_CONTEXT_CHARS = 800
|
||||
private val handledGenieQuestions = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
|
||||
private val pendingGenieQuestions = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
|
||||
private val pendingQuestionLoads = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
|
||||
private val handledBridgeRequests = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
|
||||
private val pendingParentRollups = java.util.concurrent.ConcurrentHashMap.newKeySet<String>()
|
||||
}
|
||||
|
||||
private sealed class AutoAnswerResult {
|
||||
data object Answered : AutoAnswerResult()
|
||||
|
||||
data class Escalate(
|
||||
val question: String,
|
||||
) : AutoAnswerResult()
|
||||
}
|
||||
|
||||
private val agentManager by lazy { getSystemService(AgentManager::class.java) }
|
||||
private val sessionController by lazy { AgentSessionController(this) }
|
||||
private val presentationPolicyStore by lazy { SessionPresentationPolicyStore(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
}
|
||||
|
||||
override fun onSessionChanged(session: AgentSessionInfo) {
|
||||
Log.i(TAG, "onSessionChanged $session")
|
||||
maybeRollUpParentSession(session)
|
||||
agentManager?.let { manager ->
|
||||
if (shouldServeSessionBridge(session)) {
|
||||
AgentSessionBridgeServer.ensureStarted(this, manager, session.sessionId)
|
||||
} else if (isTerminalSessionState(session.state)) {
|
||||
AgentSessionBridgeServer.closeSession(session.sessionId)
|
||||
}
|
||||
}
|
||||
if (session.state != AgentSessionInfo.STATE_WAITING_FOR_USER) {
|
||||
AgentQuestionNotifier.cancel(this, session.sessionId)
|
||||
return
|
||||
}
|
||||
if (!pendingQuestionLoads.add(session.sessionId)) {
|
||||
return
|
||||
}
|
||||
thread(name = "CodexAgentQuestionLoad-${session.sessionId}") {
|
||||
try {
|
||||
handleWaitingSession(session)
|
||||
} finally {
|
||||
pendingQuestionLoads.remove(session.sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSessionRemoved(sessionId: String) {
|
||||
Log.i(TAG, "onSessionRemoved sessionId=$sessionId")
|
||||
AgentSessionBridgeServer.closeSession(sessionId)
|
||||
AgentQuestionNotifier.cancel(this, sessionId)
|
||||
presentationPolicyStore.removePolicy(sessionId)
|
||||
handledGenieQuestions.removeIf { it.startsWith("$sessionId:") }
|
||||
handledBridgeRequests.removeIf { it.startsWith("$sessionId:") }
|
||||
pendingGenieQuestions.removeIf { it.startsWith("$sessionId:") }
|
||||
}
|
||||
|
||||
private fun maybeRollUpParentSession(session: AgentSessionInfo) {
|
||||
val parentSessionId = when {
|
||||
!session.parentSessionId.isNullOrBlank() -> session.parentSessionId
|
||||
isDirectParentSession(session) -> session.sessionId
|
||||
else -> null
|
||||
} ?: return
|
||||
if (!pendingParentRollups.add(parentSessionId)) {
|
||||
return
|
||||
}
|
||||
thread(name = "CodexAgentParentRollup-$parentSessionId") {
|
||||
try {
|
||||
runCatching {
|
||||
rollUpParentSession(parentSessionId)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Parent session roll-up failed for $parentSessionId", err)
|
||||
}
|
||||
} finally {
|
||||
pendingParentRollups.remove(parentSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun rollUpParentSession(parentSessionId: String) {
|
||||
val manager = agentManager ?: return
|
||||
val sessions = manager.getSessions(currentUserId())
|
||||
val parentSession = sessions.firstOrNull { it.sessionId == parentSessionId } ?: return
|
||||
if (!isDirectParentSession(parentSession)) {
|
||||
return
|
||||
}
|
||||
val childSessions = sessions.filter { it.parentSessionId == parentSessionId }
|
||||
if (childSessions.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val rollup = AgentParentSessionAggregator.rollup(
|
||||
childSessions.map { childSession ->
|
||||
val events = manager.getSessionEvents(childSession.sessionId)
|
||||
ParentSessionChildSummary(
|
||||
sessionId = childSession.sessionId,
|
||||
targetPackage = childSession.targetPackage,
|
||||
state = childSession.state,
|
||||
targetPresentation = childSession.targetPresentation,
|
||||
requiredFinalPresentationPolicy = presentationPolicyStore.getPolicy(childSession.sessionId),
|
||||
latestResult = findLastEventMessage(events, AgentSessionEvent.TYPE_RESULT),
|
||||
latestError = findLastEventMessage(events, AgentSessionEvent.TYPE_ERROR),
|
||||
)
|
||||
},
|
||||
)
|
||||
rollup.sessionsToAttach.forEach { childSessionId ->
|
||||
runCatching {
|
||||
manager.attachTarget(childSessionId)
|
||||
manager.publishTrace(
|
||||
parentSessionId,
|
||||
"Requested attach for $childSessionId to satisfy the required final presentation policy.",
|
||||
)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Failed to attach target for $childSessionId", err)
|
||||
}
|
||||
}
|
||||
if (shouldUpdateParentSessionState(parentSession.state, rollup.state)) {
|
||||
runCatching {
|
||||
manager.updateSessionState(parentSessionId, rollup.state)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Failed to update parent session state for $parentSessionId", err)
|
||||
}
|
||||
}
|
||||
val parentEvents = if (rollup.resultMessage != null || rollup.errorMessage != null) {
|
||||
manager.getSessionEvents(parentSessionId)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
if (rollup.resultMessage != null && findLastEventMessage(parentEvents, AgentSessionEvent.TYPE_RESULT) != rollup.resultMessage) {
|
||||
runCatching {
|
||||
manager.publishResult(parentSessionId, rollup.resultMessage)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Failed to publish parent result for $parentSessionId", err)
|
||||
}
|
||||
}
|
||||
if (rollup.errorMessage != null && findLastEventMessage(parentEvents, AgentSessionEvent.TYPE_ERROR) != rollup.errorMessage) {
|
||||
runCatching {
|
||||
manager.publishError(parentSessionId, rollup.errorMessage)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Failed to publish parent error for $parentSessionId", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun shouldServeSessionBridge(session: AgentSessionInfo): Boolean {
|
||||
if (session.targetPackage.isNullOrBlank()) {
|
||||
return false
|
||||
}
|
||||
return !isTerminalSessionState(session.state)
|
||||
}
|
||||
|
||||
private fun shouldUpdateParentSessionState(
|
||||
currentState: Int,
|
||||
proposedState: Int,
|
||||
): Boolean {
|
||||
if (currentState == proposedState || isTerminalSessionState(currentState)) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
(currentState == AgentSessionInfo.STATE_RUNNING || currentState == AgentSessionInfo.STATE_WAITING_FOR_USER) &&
|
||||
(proposedState == AgentSessionInfo.STATE_CREATED || proposedState == AgentSessionInfo.STATE_QUEUED)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isTerminalSessionState(state: Int): Boolean {
|
||||
return when (state) {
|
||||
AgentSessionInfo.STATE_COMPLETED,
|
||||
AgentSessionInfo.STATE_CANCELLED,
|
||||
AgentSessionInfo.STATE_FAILED,
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleWaitingSession(session: AgentSessionInfo) {
|
||||
val manager = agentManager ?: return
|
||||
val events = manager.getSessionEvents(session.sessionId)
|
||||
val question = findLatestQuestion(events) ?: return
|
||||
updateQuestionNotification(session, question)
|
||||
maybeAutoAnswerGenieQuestion(session, question, events)
|
||||
}
|
||||
|
||||
private fun maybeAutoAnswerGenieQuestion(
|
||||
session: AgentSessionInfo,
|
||||
question: String,
|
||||
events: List<AgentSessionEvent>,
|
||||
) {
|
||||
val questionKey = genieQuestionKey(session.sessionId, question)
|
||||
if (handledGenieQuestions.contains(questionKey) || !pendingGenieQuestions.add(questionKey)) {
|
||||
return
|
||||
}
|
||||
thread(name = "CodexAgentAutoAnswer-${session.sessionId}") {
|
||||
Log.i(TAG, "Attempting Agent auto-answer for ${session.sessionId}")
|
||||
runCatching {
|
||||
if (isBridgeQuestion(question)) {
|
||||
answerBridgeQuestion(session, question)
|
||||
handledGenieQuestions.add(questionKey)
|
||||
AgentQuestionNotifier.cancel(this, session.sessionId)
|
||||
Log.i(TAG, "Answered bridge question for ${session.sessionId}")
|
||||
} else {
|
||||
when (val result = requestGenieAutoAnswer(session, question, events)) {
|
||||
AutoAnswerResult.Answered -> {
|
||||
handledGenieQuestions.add(questionKey)
|
||||
AgentQuestionNotifier.cancel(this, session.sessionId)
|
||||
Log.i(TAG, "Auto-answered Genie question for ${session.sessionId}")
|
||||
}
|
||||
is AutoAnswerResult.Escalate -> {
|
||||
if (sessionController.isSessionWaitingForUser(session.sessionId)) {
|
||||
AgentQuestionNotifier.showQuestion(
|
||||
context = this,
|
||||
sessionId = session.sessionId,
|
||||
targetPackage = session.targetPackage,
|
||||
question = result.question,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}.onFailure { err ->
|
||||
Log.i(TAG, "Agent auto-answer unavailable for ${session.sessionId}: ${err.message}")
|
||||
if (!isBridgeQuestion(question) && sessionController.isSessionWaitingForUser(session.sessionId)) {
|
||||
AgentQuestionNotifier.showQuestion(
|
||||
context = this,
|
||||
sessionId = session.sessionId,
|
||||
targetPackage = session.targetPackage,
|
||||
question = question,
|
||||
)
|
||||
}
|
||||
}
|
||||
pendingGenieQuestions.remove(questionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateQuestionNotification(session: AgentSessionInfo, question: String) {
|
||||
if (question.isBlank()) {
|
||||
AgentQuestionNotifier.cancel(this, session.sessionId)
|
||||
return
|
||||
}
|
||||
if (isBridgeQuestion(question)) {
|
||||
AgentQuestionNotifier.cancel(this, session.sessionId)
|
||||
return
|
||||
}
|
||||
if (pendingGenieQuestions.contains(genieQuestionKey(session.sessionId, question))) {
|
||||
return
|
||||
}
|
||||
AgentQuestionNotifier.showQuestion(
|
||||
context = this,
|
||||
sessionId = session.sessionId,
|
||||
targetPackage = session.targetPackage,
|
||||
question = question,
|
||||
)
|
||||
}
|
||||
|
||||
private fun requestGenieAutoAnswer(
|
||||
session: AgentSessionInfo,
|
||||
question: String,
|
||||
events: List<AgentSessionEvent>,
|
||||
): AutoAnswerResult {
|
||||
val runtimeStatus = AgentCodexAppServerClient.readRuntimeStatus(this)
|
||||
if (!runtimeStatus.authenticated) {
|
||||
throw IOException("Agent runtime is not authenticated")
|
||||
}
|
||||
val frameworkToolBridge = AgentFrameworkToolBridge(this, sessionController)
|
||||
var answered = false
|
||||
val response = AgentCodexAppServerClient.requestText(
|
||||
context = this,
|
||||
instructions = AUTO_ANSWER_INSTRUCTIONS,
|
||||
prompt = buildAutoAnswerPrompt(session, question, events),
|
||||
dynamicTools = frameworkToolBridge.buildQuestionResolutionToolSpecs(),
|
||||
toolCallHandler = { toolName, arguments ->
|
||||
if (
|
||||
toolName == AgentFrameworkToolBridge.ANSWER_QUESTION_TOOL &&
|
||||
arguments.optString("sessionId").trim().isEmpty()
|
||||
) {
|
||||
arguments.put("sessionId", session.sessionId)
|
||||
}
|
||||
if (
|
||||
toolName == AgentFrameworkToolBridge.ANSWER_QUESTION_TOOL &&
|
||||
arguments.optString("parentSessionId").trim().isEmpty() &&
|
||||
!session.parentSessionId.isNullOrBlank()
|
||||
) {
|
||||
arguments.put("parentSessionId", session.parentSessionId)
|
||||
}
|
||||
val toolResult = frameworkToolBridge.handleToolCall(
|
||||
toolName = toolName,
|
||||
arguments = arguments,
|
||||
userObjective = question,
|
||||
focusedSessionId = session.sessionId,
|
||||
)
|
||||
if (toolName == AgentFrameworkToolBridge.ANSWER_QUESTION_TOOL) {
|
||||
answered = true
|
||||
}
|
||||
toolResult
|
||||
},
|
||||
frameworkSessionId = session.sessionId,
|
||||
).trim()
|
||||
if (answered) {
|
||||
return AutoAnswerResult.Answered
|
||||
}
|
||||
if (response.startsWith(AUTO_ANSWER_ESCALATE_PREFIX, ignoreCase = true)) {
|
||||
val escalateQuestion = response.substringAfter(':').trim().ifEmpty { question }
|
||||
return AutoAnswerResult.Escalate(escalateQuestion)
|
||||
}
|
||||
if (response.isNotBlank()) {
|
||||
sessionController.answerQuestion(session.sessionId, response, session.parentSessionId)
|
||||
return AutoAnswerResult.Answered
|
||||
}
|
||||
throw IOException("Agent runtime did not return an answer")
|
||||
}
|
||||
|
||||
private fun buildAutoAnswerPrompt(
|
||||
session: AgentSessionInfo,
|
||||
question: String,
|
||||
events: List<AgentSessionEvent>,
|
||||
): String {
|
||||
val recentContext = renderRecentContext(events)
|
||||
return """
|
||||
Target package: ${session.targetPackage ?: "unknown"}
|
||||
Current Genie question: $question
|
||||
|
||||
Recent session context:
|
||||
$recentContext
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun renderRecentContext(events: List<AgentSessionEvent>): String {
|
||||
val context = events
|
||||
.takeLast(6)
|
||||
.joinToString("\n") { event ->
|
||||
"${eventTypeToString(event.type)}: ${event.message ?: ""}"
|
||||
}
|
||||
if (context.length <= MAX_AUTO_ANSWER_CONTEXT_CHARS) {
|
||||
return context.ifBlank { "No prior Genie context." }
|
||||
}
|
||||
return context.takeLast(MAX_AUTO_ANSWER_CONTEXT_CHARS)
|
||||
}
|
||||
|
||||
private fun findLatestQuestion(events: List<AgentSessionEvent>): String? {
|
||||
return events.lastOrNull { event ->
|
||||
event.type == AgentSessionEvent.TYPE_QUESTION &&
|
||||
!event.message.isNullOrBlank()
|
||||
}?.message
|
||||
}
|
||||
|
||||
private fun findLastEventMessage(events: List<AgentSessionEvent>, type: Int): String? {
|
||||
return events.lastOrNull { event ->
|
||||
event.type == type && !event.message.isNullOrBlank()
|
||||
}?.message
|
||||
}
|
||||
|
||||
private fun isBridgeQuestion(question: String): Boolean {
|
||||
return question.startsWith(BRIDGE_REQUEST_PREFIX)
|
||||
}
|
||||
|
||||
private fun answerBridgeQuestion(
|
||||
session: AgentSessionInfo,
|
||||
question: String,
|
||||
) {
|
||||
val request = JSONObject(question.removePrefix(BRIDGE_REQUEST_PREFIX))
|
||||
val requestId = request.optString("requestId")
|
||||
if (requestId.isNotBlank()) {
|
||||
val bridgeRequestKey = "${session.sessionId}:$requestId"
|
||||
if (!handledBridgeRequests.add(bridgeRequestKey)) {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Skipping duplicate bridge question method=${request.optString("method")} requestId=$requestId session=${session.sessionId}",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
Log.i(
|
||||
TAG,
|
||||
"Answering bridge question method=${request.optString("method")} requestId=$requestId session=${session.sessionId}",
|
||||
)
|
||||
val response: JSONObject = runCatching {
|
||||
when (request.optString("method")) {
|
||||
BRIDGE_METHOD_GET_RUNTIME_STATUS -> {
|
||||
val status = AgentCodexAppServerClient.readRuntimeStatus(this)
|
||||
JSONObject()
|
||||
.put("requestId", requestId)
|
||||
.put("ok", true)
|
||||
.put(
|
||||
"runtimeStatus",
|
||||
JSONObject()
|
||||
.put("authenticated", status.authenticated)
|
||||
.put("accountEmail", status.accountEmail)
|
||||
.put("clientCount", status.clientCount)
|
||||
.put("modelProviderId", status.modelProviderId)
|
||||
.put("configuredModel", status.configuredModel)
|
||||
.put("effectiveModel", status.effectiveModel)
|
||||
.put("upstreamBaseUrl", status.upstreamBaseUrl)
|
||||
.put("frameworkResponsesPath", status.frameworkResponsesPath),
|
||||
)
|
||||
}
|
||||
else -> JSONObject()
|
||||
.put("requestId", requestId)
|
||||
.put("ok", false)
|
||||
.put("error", "Unsupported bridge method: ${request.optString("method")}")
|
||||
}
|
||||
}.getOrElse { err ->
|
||||
JSONObject()
|
||||
.put("requestId", requestId)
|
||||
.put("ok", false)
|
||||
.put("error", err.message ?: err::class.java.simpleName)
|
||||
}
|
||||
sessionController.answerQuestion(
|
||||
session.sessionId,
|
||||
BRIDGE_RESPONSE_PREFIX + response.toString(),
|
||||
session.parentSessionId,
|
||||
)
|
||||
}
|
||||
|
||||
private fun eventTypeToString(type: Int): String {
|
||||
return when (type) {
|
||||
AgentSessionEvent.TYPE_TRACE -> "Trace"
|
||||
AgentSessionEvent.TYPE_QUESTION -> "Question"
|
||||
AgentSessionEvent.TYPE_RESULT -> "Result"
|
||||
AgentSessionEvent.TYPE_ERROR -> "Error"
|
||||
AgentSessionEvent.TYPE_POLICY -> "Policy"
|
||||
AgentSessionEvent.TYPE_DETACHED_ACTION -> "DetachedAction"
|
||||
AgentSessionEvent.TYPE_ANSWER -> "Answer"
|
||||
else -> "Event($type)"
|
||||
}
|
||||
}
|
||||
|
||||
private fun genieQuestionKey(sessionId: String, question: String): String {
|
||||
if (isBridgeQuestion(question)) {
|
||||
val requestId = runCatching {
|
||||
JSONObject(question.removePrefix(BRIDGE_REQUEST_PREFIX)).optString("requestId").trim()
|
||||
}.getOrNull()
|
||||
if (!requestId.isNullOrEmpty()) {
|
||||
return "$sessionId:bridge:$requestId"
|
||||
}
|
||||
}
|
||||
return "$sessionId:$question"
|
||||
}
|
||||
|
||||
private fun isDirectParentSession(session: AgentSessionInfo): Boolean {
|
||||
return session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
|
||||
session.parentSessionId == null &&
|
||||
session.targetPackage == null
|
||||
}
|
||||
|
||||
private fun currentUserId(): Int {
|
||||
return Process.myUid() / 100000
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
object CodexCliBinaryLocator {
|
||||
fun resolve(context: Context): File {
|
||||
val binary = File(context.applicationInfo.nativeLibraryDir, "libcodex.so")
|
||||
if (!binary.exists()) {
|
||||
throw IOException("codex binary missing at ${binary.absolutePath}")
|
||||
}
|
||||
return binary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,599 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.agent.AgentManager
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.graphics.drawable.Drawable
|
||||
import android.os.Bundle
|
||||
import android.os.Binder
|
||||
import android.util.Log
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.Spinner
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class CreateSessionActivity : Activity() {
|
||||
companion object {
|
||||
private const val TAG = "CodexCreateSession"
|
||||
const val ACTION_CREATE_SESSION = "com.openai.codex.agent.action.CREATE_SESSION"
|
||||
const val EXTRA_INITIAL_PROMPT = "com.openai.codex.agent.extra.INITIAL_PROMPT"
|
||||
private const val EXTRA_EXISTING_SESSION_ID = "existingSessionId"
|
||||
private const val EXTRA_TARGET_PACKAGE = "targetPackage"
|
||||
private const val EXTRA_LOCK_TARGET = "lockTarget"
|
||||
private const val EXTRA_INITIAL_MODEL = "initialModel"
|
||||
private const val EXTRA_INITIAL_REASONING_EFFORT = "initialReasoningEffort"
|
||||
private const val DEFAULT_MODEL = "gpt-5.3-codex-spark"
|
||||
private const val DEFAULT_REASONING_EFFORT = "low"
|
||||
|
||||
fun preferredInitialSettings(): SessionExecutionSettings {
|
||||
return SessionExecutionSettings(
|
||||
model = DEFAULT_MODEL,
|
||||
reasoningEffort = DEFAULT_REASONING_EFFORT,
|
||||
)
|
||||
}
|
||||
|
||||
private fun mergedWithPreferredDefaults(settings: SessionExecutionSettings): SessionExecutionSettings {
|
||||
val defaults = preferredInitialSettings()
|
||||
return SessionExecutionSettings(
|
||||
model = settings.model ?: defaults.model,
|
||||
reasoningEffort = settings.reasoningEffort ?: defaults.reasoningEffort,
|
||||
)
|
||||
}
|
||||
|
||||
fun externalCreateSessionIntent(initialPrompt: String): Intent {
|
||||
return Intent(ACTION_CREATE_SESSION).apply {
|
||||
addCategory(Intent.CATEGORY_DEFAULT)
|
||||
putExtra(EXTRA_INITIAL_PROMPT, initialPrompt)
|
||||
}
|
||||
}
|
||||
|
||||
fun newSessionIntent(
|
||||
context: Context,
|
||||
initialSettings: SessionExecutionSettings,
|
||||
): Intent {
|
||||
return Intent(context, CreateSessionActivity::class.java).apply {
|
||||
putExtra(EXTRA_INITIAL_MODEL, initialSettings.model)
|
||||
putExtra(EXTRA_INITIAL_REASONING_EFFORT, initialSettings.reasoningEffort)
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
}
|
||||
}
|
||||
|
||||
fun existingHomeSessionIntent(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
targetPackage: String,
|
||||
initialSettings: SessionExecutionSettings,
|
||||
): Intent {
|
||||
return newSessionIntent(context, initialSettings).apply {
|
||||
putExtra(EXTRA_EXISTING_SESSION_ID, sessionId)
|
||||
putExtra(EXTRA_TARGET_PACKAGE, targetPackage)
|
||||
putExtra(EXTRA_LOCK_TARGET, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val sessionController by lazy { AgentSessionController(this) }
|
||||
private val sessionUiLeaseToken = Binder()
|
||||
private var availableModels: List<AgentModelOption> = emptyList()
|
||||
@Volatile
|
||||
private var modelsRefreshInFlight = false
|
||||
private val pendingModelCallbacks = mutableListOf<() -> Unit>()
|
||||
|
||||
private var existingSessionId: String? = null
|
||||
private var leasedSessionId: String? = null
|
||||
private var uiActive = false
|
||||
private var selectedPackage: InstalledApp? = null
|
||||
private var targetLocked = false
|
||||
|
||||
private lateinit var promptInput: EditText
|
||||
private lateinit var packageSummary: TextView
|
||||
private lateinit var packageButton: Button
|
||||
private lateinit var clearPackageButton: Button
|
||||
private lateinit var modelSpinner: Spinner
|
||||
private lateinit var effortSpinner: Spinner
|
||||
private lateinit var titleView: TextView
|
||||
private lateinit var statusView: TextView
|
||||
private lateinit var startButton: Button
|
||||
|
||||
private var selectedReasoningOptions = emptyList<AgentReasoningEffortOption>()
|
||||
private var pendingEffortOverride: String? = null
|
||||
private lateinit var effortLabelAdapter: ArrayAdapter<String>
|
||||
private var initialSettings = preferredInitialSettings()
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_create_session)
|
||||
setFinishOnTouchOutside(true)
|
||||
bindViews()
|
||||
loadInitialState()
|
||||
refreshModelsIfNeeded(force = true)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
loadInitialState()
|
||||
if (availableModels.isNotEmpty()) {
|
||||
applyModelOptions()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
uiActive = true
|
||||
updateSessionUiLease(existingSessionId)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
uiActive = false
|
||||
updateSessionUiLease(null)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun bindViews() {
|
||||
titleView = findViewById(R.id.create_session_title)
|
||||
statusView = findViewById(R.id.create_session_status)
|
||||
promptInput = findViewById(R.id.create_session_prompt)
|
||||
packageSummary = findViewById(R.id.create_session_target_summary)
|
||||
packageButton = findViewById(R.id.create_session_pick_target_button)
|
||||
clearPackageButton = findViewById(R.id.create_session_clear_target_button)
|
||||
modelSpinner = findViewById(R.id.create_session_model_spinner)
|
||||
effortSpinner = findViewById(R.id.create_session_effort_spinner)
|
||||
startButton = findViewById(R.id.create_session_start_button)
|
||||
|
||||
effortLabelAdapter = ArrayAdapter(
|
||||
this,
|
||||
android.R.layout.simple_spinner_item,
|
||||
mutableListOf<String>(),
|
||||
).also {
|
||||
it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
effortSpinner.adapter = it
|
||||
}
|
||||
modelSpinner.adapter = ArrayAdapter(
|
||||
this,
|
||||
android.R.layout.simple_spinner_item,
|
||||
mutableListOf<String>(),
|
||||
).also { it.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item) }
|
||||
modelSpinner.onItemSelectedListener = SimpleItemSelectedListener {
|
||||
updateEffortOptions(pendingEffortOverride)
|
||||
pendingEffortOverride = null
|
||||
}
|
||||
|
||||
packageButton.setOnClickListener {
|
||||
showInstalledAppPicker { app ->
|
||||
selectedPackage = app
|
||||
updatePackageSummary()
|
||||
}
|
||||
}
|
||||
clearPackageButton.setOnClickListener {
|
||||
selectedPackage = null
|
||||
updatePackageSummary()
|
||||
}
|
||||
findViewById<Button>(R.id.create_session_cancel_button).setOnClickListener {
|
||||
cancelAndFinish()
|
||||
}
|
||||
startButton.setOnClickListener {
|
||||
startSession()
|
||||
}
|
||||
updatePackageSummary()
|
||||
}
|
||||
|
||||
private fun loadInitialState() {
|
||||
updateSessionUiLease(null)
|
||||
existingSessionId = null
|
||||
selectedPackage = null
|
||||
targetLocked = false
|
||||
titleView.text = "New Session"
|
||||
statusView.visibility = View.GONE
|
||||
statusView.text = "Loading session…"
|
||||
startButton.isEnabled = true
|
||||
unlockTargetSelection()
|
||||
updatePackageSummary()
|
||||
|
||||
existingSessionId = intent.getStringExtra(EXTRA_EXISTING_SESSION_ID)?.trim()?.ifEmpty { null }
|
||||
initialSettings = mergedWithPreferredDefaults(
|
||||
SessionExecutionSettings(
|
||||
model = intent.getStringExtra(EXTRA_INITIAL_MODEL)?.trim()?.ifEmpty { null } ?: DEFAULT_MODEL,
|
||||
reasoningEffort = intent.getStringExtra(EXTRA_INITIAL_REASONING_EFFORT)?.trim()?.ifEmpty { null }
|
||||
?: DEFAULT_REASONING_EFFORT,
|
||||
),
|
||||
)
|
||||
promptInput.setText(intent.getStringExtra(EXTRA_INITIAL_PROMPT).orEmpty())
|
||||
promptInput.setSelection(promptInput.text.length)
|
||||
val explicitTarget = intent.getStringExtra(EXTRA_TARGET_PACKAGE)?.trim()?.ifEmpty { null }
|
||||
targetLocked = intent.getBooleanExtra(EXTRA_LOCK_TARGET, false)
|
||||
if (explicitTarget != null) {
|
||||
selectedPackage = InstalledAppCatalog.resolveInstalledApp(this, sessionController, explicitTarget)
|
||||
titleView.text = "New Session"
|
||||
updatePackageSummary()
|
||||
if (targetLocked) {
|
||||
lockTargetSelection()
|
||||
}
|
||||
if (uiActive) {
|
||||
updateSessionUiLease(existingSessionId)
|
||||
}
|
||||
return
|
||||
}
|
||||
val incomingSessionId = intent.getStringExtra(AgentManager.EXTRA_SESSION_ID)?.trim()?.ifEmpty { null }
|
||||
if (incomingSessionId != null) {
|
||||
statusView.visibility = View.VISIBLE
|
||||
statusView.text = "Loading session…"
|
||||
startButton.isEnabled = false
|
||||
thread {
|
||||
val draftSession = runCatching {
|
||||
findStandaloneHomeDraftSession(incomingSessionId)
|
||||
}.getOrElse { err ->
|
||||
Log.w(TAG, "Failed to inspect incoming session $incomingSessionId", err)
|
||||
null
|
||||
}
|
||||
runOnUiThread {
|
||||
if (draftSession == null) {
|
||||
startActivity(
|
||||
Intent(this, SessionDetailActivity::class.java)
|
||||
.putExtra(SessionDetailActivity.EXTRA_SESSION_ID, incomingSessionId),
|
||||
)
|
||||
finish()
|
||||
return@runOnUiThread
|
||||
}
|
||||
existingSessionId = draftSession.sessionId
|
||||
selectedPackage = InstalledAppCatalog.resolveInstalledApp(
|
||||
this,
|
||||
sessionController,
|
||||
checkNotNull(draftSession.targetPackage),
|
||||
)
|
||||
initialSettings = mergedWithPreferredDefaults(
|
||||
sessionController.executionSettingsForSession(draftSession.sessionId),
|
||||
)
|
||||
targetLocked = true
|
||||
titleView.text = "New Session"
|
||||
updatePackageSummary()
|
||||
lockTargetSelection()
|
||||
statusView.visibility = View.GONE
|
||||
startButton.isEnabled = true
|
||||
if (uiActive) {
|
||||
updateSessionUiLease(existingSessionId)
|
||||
}
|
||||
if (availableModels.isNotEmpty()) {
|
||||
applyModelOptions()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelAndFinish() {
|
||||
val sessionId = existingSessionId
|
||||
if (sessionId == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
startButton.isEnabled = false
|
||||
thread {
|
||||
runCatching {
|
||||
sessionController.cancelSession(sessionId)
|
||||
}.onFailure { err ->
|
||||
runOnUiThread {
|
||||
startButton.isEnabled = true
|
||||
showToast("Failed to cancel session: ${err.message}")
|
||||
}
|
||||
}.onSuccess {
|
||||
runOnUiThread {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun lockTargetSelection() {
|
||||
packageButton.visibility = View.GONE
|
||||
clearPackageButton.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun unlockTargetSelection() {
|
||||
packageButton.visibility = View.VISIBLE
|
||||
clearPackageButton.visibility = View.VISIBLE
|
||||
}
|
||||
|
||||
private fun startSession() {
|
||||
val prompt = promptInput.text.toString().trim()
|
||||
if (prompt.isEmpty()) {
|
||||
promptInput.error = "Enter a prompt"
|
||||
return
|
||||
}
|
||||
val targetPackage = selectedPackage?.packageName
|
||||
if (existingSessionId != null && targetPackage == null) {
|
||||
showToast("Missing target app for existing session")
|
||||
return
|
||||
}
|
||||
startButton.isEnabled = false
|
||||
thread {
|
||||
runCatching {
|
||||
AgentSessionLauncher.startSessionAsync(
|
||||
context = this,
|
||||
request = LaunchSessionRequest(
|
||||
prompt = prompt,
|
||||
targetPackage = targetPackage,
|
||||
model = selectedModel().model,
|
||||
reasoningEffort = selectedEffort(),
|
||||
existingSessionId = existingSessionId,
|
||||
),
|
||||
sessionController = sessionController,
|
||||
requestUserInputHandler = { questions ->
|
||||
AgentUserInputPrompter.promptForAnswers(this, questions)
|
||||
},
|
||||
)
|
||||
}.onFailure { err ->
|
||||
runOnUiThread {
|
||||
startButton.isEnabled = true
|
||||
showToast("Failed to start session: ${err.message}")
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
runOnUiThread {
|
||||
showToast("Started session")
|
||||
setResult(RESULT_OK, Intent().putExtra(SessionDetailActivity.EXTRA_SESSION_ID, result.parentSessionId))
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshModelsIfNeeded(
|
||||
force: Boolean,
|
||||
onComplete: (() -> Unit)? = null,
|
||||
) {
|
||||
if (!force && availableModels.isNotEmpty()) {
|
||||
onComplete?.invoke()
|
||||
return
|
||||
}
|
||||
if (onComplete != null) {
|
||||
synchronized(pendingModelCallbacks) {
|
||||
pendingModelCallbacks += onComplete
|
||||
}
|
||||
}
|
||||
if (modelsRefreshInFlight) {
|
||||
return
|
||||
}
|
||||
modelsRefreshInFlight = true
|
||||
thread {
|
||||
try {
|
||||
runCatching { AgentCodexAppServerClient.listModels(this) }
|
||||
.onFailure { err ->
|
||||
Log.w(TAG, "Failed to load model catalog", err)
|
||||
}
|
||||
.onSuccess { models ->
|
||||
availableModels = models
|
||||
}
|
||||
} finally {
|
||||
runOnUiThread {
|
||||
if (availableModels.isNotEmpty()) {
|
||||
applyModelOptions()
|
||||
} else {
|
||||
statusView.visibility = View.VISIBLE
|
||||
statusView.text = "Failed to load model catalog."
|
||||
}
|
||||
}
|
||||
modelsRefreshInFlight = false
|
||||
val callbacks = synchronized(pendingModelCallbacks) {
|
||||
pendingModelCallbacks.toList().also { pendingModelCallbacks.clear() }
|
||||
}
|
||||
callbacks.forEach { callback -> callback.invoke() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun applyModelOptions() {
|
||||
val models = availableModels.ifEmpty(::fallbackModels)
|
||||
if (availableModels.isEmpty()) {
|
||||
availableModels = models
|
||||
}
|
||||
val labels = models.map { model ->
|
||||
if (model.description.isBlank()) {
|
||||
model.displayName
|
||||
} else {
|
||||
"${model.displayName} (${model.description})"
|
||||
}
|
||||
}
|
||||
val adapter = ArrayAdapter(
|
||||
this,
|
||||
android.R.layout.simple_spinner_item,
|
||||
labels,
|
||||
)
|
||||
adapter.setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
|
||||
pendingEffortOverride = initialSettings.reasoningEffort
|
||||
modelSpinner.adapter = adapter
|
||||
val modelIndex = models.indexOfFirst { it.model == initialSettings.model }
|
||||
.takeIf { it >= 0 } ?: models.indexOfFirst(AgentModelOption::isDefault)
|
||||
.takeIf { it >= 0 } ?: 0
|
||||
modelSpinner.setSelection(modelIndex, false)
|
||||
updateEffortOptions(initialSettings.reasoningEffort)
|
||||
statusView.visibility = View.GONE
|
||||
}
|
||||
|
||||
private fun selectedModel(): AgentModelOption {
|
||||
return availableModels[modelSpinner.selectedItemPosition.coerceIn(0, availableModels.lastIndex)]
|
||||
}
|
||||
|
||||
private fun selectedEffort(): String? {
|
||||
return selectedReasoningOptions.getOrNull(effortSpinner.selectedItemPosition)?.reasoningEffort
|
||||
}
|
||||
|
||||
private fun updateEffortOptions(requestedEffort: String?) {
|
||||
if (availableModels.isEmpty()) {
|
||||
return
|
||||
}
|
||||
selectedReasoningOptions = selectedModel().supportedReasoningEfforts
|
||||
val labels = selectedReasoningOptions.map { option ->
|
||||
"${option.reasoningEffort} — ${option.description}"
|
||||
}
|
||||
effortLabelAdapter.clear()
|
||||
effortLabelAdapter.addAll(labels)
|
||||
effortLabelAdapter.notifyDataSetChanged()
|
||||
val desiredEffort = requestedEffort ?: selectedModel().defaultReasoningEffort
|
||||
val selectedIndex = selectedReasoningOptions.indexOfFirst { it.reasoningEffort == desiredEffort }
|
||||
.takeIf { it >= 0 } ?: 0
|
||||
effortSpinner.setSelection(selectedIndex, false)
|
||||
}
|
||||
|
||||
private fun updatePackageSummary() {
|
||||
val app = selectedPackage
|
||||
if (app == null) {
|
||||
packageSummary.text = "No target app selected. This will start an Agent-anchored session."
|
||||
packageSummary.setCompoundDrawablesRelativeWithIntrinsicBounds(null, null, null, null)
|
||||
return
|
||||
}
|
||||
packageSummary.text = "${app.label} (${app.packageName})"
|
||||
packageSummary.setCompoundDrawablesRelativeWithIntrinsicBounds(
|
||||
resizeIcon(app.icon),
|
||||
null,
|
||||
null,
|
||||
null,
|
||||
)
|
||||
packageSummary.compoundDrawablePadding =
|
||||
resources.getDimensionPixelSize(android.R.dimen.app_icon_size) / 4
|
||||
}
|
||||
|
||||
private fun showInstalledAppPicker(onSelected: (InstalledApp) -> Unit) {
|
||||
val apps = InstalledAppCatalog.listInstalledApps(this, sessionController)
|
||||
if (apps.isEmpty()) {
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setMessage("No launchable target apps are available.")
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
return
|
||||
}
|
||||
val adapter = object : ArrayAdapter<InstalledApp>(
|
||||
this,
|
||||
R.layout.list_item_installed_app,
|
||||
apps,
|
||||
) {
|
||||
override fun getView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
return bindAppRow(position, convertView, parent)
|
||||
}
|
||||
|
||||
override fun getDropDownView(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
return bindAppRow(position, convertView, parent)
|
||||
}
|
||||
|
||||
private fun bindAppRow(position: Int, convertView: View?, parent: ViewGroup): View {
|
||||
val row = convertView ?: LayoutInflater.from(context)
|
||||
.inflate(R.layout.list_item_installed_app, parent, false)
|
||||
val app = getItem(position) ?: return row
|
||||
val iconView = row.findViewById<ImageView>(R.id.installed_app_icon)
|
||||
val titleView = row.findViewById<TextView>(R.id.installed_app_title)
|
||||
val subtitleView = row.findViewById<TextView>(R.id.installed_app_subtitle)
|
||||
iconView.setImageDrawable(app.icon ?: getDrawable(android.R.drawable.sym_def_app_icon))
|
||||
titleView.text = app.label
|
||||
subtitleView.text = if (app.eligibleTarget) {
|
||||
app.packageName
|
||||
} else {
|
||||
"${app.packageName} — unavailable"
|
||||
}
|
||||
row.isEnabled = app.eligibleTarget
|
||||
titleView.isEnabled = app.eligibleTarget
|
||||
subtitleView.isEnabled = app.eligibleTarget
|
||||
iconView.alpha = if (app.eligibleTarget) 1f else 0.5f
|
||||
row.alpha = if (app.eligibleTarget) 1f else 0.6f
|
||||
return row
|
||||
}
|
||||
}
|
||||
val dialog = android.app.AlertDialog.Builder(this)
|
||||
.setTitle("Choose app")
|
||||
.setAdapter(adapter) { _, which ->
|
||||
val app = apps[which]
|
||||
if (!app.eligibleTarget) {
|
||||
android.app.AlertDialog.Builder(this)
|
||||
.setMessage(
|
||||
"The current framework rejected ${app.packageName} as a target for Genie sessions on this device.",
|
||||
)
|
||||
.setPositiveButton(android.R.string.ok, null)
|
||||
.show()
|
||||
return@setAdapter
|
||||
}
|
||||
onSelected(app)
|
||||
}
|
||||
.setNegativeButton(android.R.string.cancel, null)
|
||||
.create()
|
||||
dialog.setOnShowListener {
|
||||
dialog.listView?.isVerticalScrollBarEnabled = true
|
||||
dialog.listView?.isScrollbarFadingEnabled = false
|
||||
dialog.listView?.isFastScrollEnabled = true
|
||||
dialog.listView?.scrollBarStyle = View.SCROLLBARS_INSIDE_INSET
|
||||
}
|
||||
dialog.show()
|
||||
}
|
||||
|
||||
private fun findStandaloneHomeDraftSession(sessionId: String): AgentSessionDetails? {
|
||||
val snapshot = sessionController.loadSnapshot(sessionId)
|
||||
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId } ?: return null
|
||||
val hasChildren = snapshot.sessions.any { it.parentSessionId == sessionId }
|
||||
return session.takeIf {
|
||||
it.anchor == AgentSessionInfo.ANCHOR_HOME &&
|
||||
it.state == AgentSessionInfo.STATE_CREATED &&
|
||||
!hasChildren &&
|
||||
!it.targetPackage.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateSessionUiLease(sessionId: String?) {
|
||||
if (leasedSessionId == sessionId) {
|
||||
return
|
||||
}
|
||||
leasedSessionId?.let { previous ->
|
||||
runCatching {
|
||||
sessionController.unregisterSessionUiLease(previous, sessionUiLeaseToken)
|
||||
}
|
||||
leasedSessionId = null
|
||||
}
|
||||
sessionId?.let { current ->
|
||||
val registered = runCatching {
|
||||
sessionController.registerSessionUiLease(current, sessionUiLeaseToken)
|
||||
}
|
||||
if (registered.isSuccess) {
|
||||
leasedSessionId = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resizeIcon(icon: Drawable?): Drawable? {
|
||||
val sizedIcon = icon?.constantState?.newDrawable()?.mutate() ?: return null
|
||||
val iconSize = resources.getDimensionPixelSize(android.R.dimen.app_icon_size)
|
||||
sizedIcon.setBounds(0, 0, iconSize, iconSize)
|
||||
return sizedIcon
|
||||
}
|
||||
|
||||
private fun fallbackModels(): List<AgentModelOption> {
|
||||
return listOf(
|
||||
AgentModelOption(
|
||||
id = initialSettings.model ?: DEFAULT_MODEL,
|
||||
model = initialSettings.model ?: DEFAULT_MODEL,
|
||||
displayName = initialSettings.model ?: DEFAULT_MODEL,
|
||||
description = "Current Agent runtime default",
|
||||
supportedReasoningEfforts = listOf(
|
||||
AgentReasoningEffortOption("minimal", "Fastest"),
|
||||
AgentReasoningEffortOption("low", "Low"),
|
||||
AgentReasoningEffortOption("medium", "Balanced"),
|
||||
AgentReasoningEffortOption("high", "Deep"),
|
||||
AgentReasoningEffortOption("xhigh", "Max"),
|
||||
),
|
||||
defaultReasoningEffort = initialSettings.reasoningEffort ?: DEFAULT_REASONING_EFFORT,
|
||||
isDefault = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class DismissedSessionStore(context: Context) {
|
||||
companion object {
|
||||
private const val PREFS_NAME = "dismissed_sessions"
|
||||
}
|
||||
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun dismiss(sessionId: String) {
|
||||
prefs.edit().putBoolean(sessionId, true).apply()
|
||||
}
|
||||
|
||||
fun isDismissed(sessionId: String): Boolean {
|
||||
return prefs.getBoolean(sessionId, false)
|
||||
}
|
||||
|
||||
fun clearDismissed(sessionId: String) {
|
||||
prefs.edit().remove(sessionId).apply()
|
||||
}
|
||||
|
||||
fun prune(activeSessionIds: Set<String>) {
|
||||
val keysToRemove = prefs.all.keys.filter { it !in activeSessionIds }
|
||||
if (keysToRemove.isEmpty()) {
|
||||
return
|
||||
}
|
||||
prefs.edit().apply {
|
||||
keysToRemove.forEach(::remove)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Drawable
|
||||
|
||||
data class InstalledApp(
|
||||
val packageName: String,
|
||||
val label: String,
|
||||
val icon: Drawable?,
|
||||
val eligibleTarget: Boolean,
|
||||
)
|
||||
|
||||
object InstalledAppCatalog {
|
||||
private val excludedPackages = setOf(
|
||||
"com.openai.codex.agent",
|
||||
"com.openai.codex.genie",
|
||||
)
|
||||
|
||||
fun listInstalledApps(
|
||||
context: Context,
|
||||
sessionController: AgentSessionController,
|
||||
): List<InstalledApp> {
|
||||
val pm = context.packageManager
|
||||
val launcherIntent = Intent(Intent.ACTION_MAIN)
|
||||
.addCategory(Intent.CATEGORY_LAUNCHER)
|
||||
val appsByPackage = linkedMapOf<String, InstalledApp>()
|
||||
pm.queryIntentActivities(launcherIntent, 0).forEach { resolveInfo ->
|
||||
val applicationInfo = resolveInfo.activityInfo?.applicationInfo ?: return@forEach
|
||||
val packageName = applicationInfo.packageName.takeIf(String::isNotBlank) ?: return@forEach
|
||||
if (packageName in excludedPackages) {
|
||||
return@forEach
|
||||
}
|
||||
if (packageName in appsByPackage) {
|
||||
return@forEach
|
||||
}
|
||||
val label = resolveInfo.loadLabel(pm)?.toString().orEmpty().ifBlank { packageName }
|
||||
appsByPackage[packageName] = InstalledApp(
|
||||
packageName = packageName,
|
||||
label = label,
|
||||
icon = resolveInfo.loadIcon(pm),
|
||||
eligibleTarget = sessionController.canStartSessionForTarget(packageName),
|
||||
)
|
||||
}
|
||||
return appsByPackage.values.sortedWith(
|
||||
compareBy<InstalledApp>({ it.label.lowercase() }).thenBy { it.packageName },
|
||||
)
|
||||
}
|
||||
|
||||
fun resolveInstalledApp(
|
||||
context: Context,
|
||||
sessionController: AgentSessionController,
|
||||
packageName: String,
|
||||
): InstalledApp {
|
||||
listInstalledApps(context, sessionController)
|
||||
.firstOrNull { it.packageName == packageName }
|
||||
?.let { return it }
|
||||
val pm = context.packageManager
|
||||
val applicationInfo = pm.getApplicationInfo(packageName, 0)
|
||||
return InstalledApp(
|
||||
packageName = packageName,
|
||||
label = pm.getApplicationLabel(applicationInfo)?.toString().orEmpty().ifBlank { packageName },
|
||||
icon = pm.getApplicationIcon(applicationInfo),
|
||||
eligibleTarget = sessionController.canStartSessionForTarget(packageName),
|
||||
)
|
||||
}
|
||||
}
|
||||
473
android/app/src/main/java/com/openai/codex/agent/MainActivity.kt
Normal file
@@ -0,0 +1,473 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.app.agent.AgentManager
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.os.Bundle
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.ListView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class MainActivity : Activity() {
|
||||
companion object {
|
||||
private const val TAG = "CodexMainActivity"
|
||||
private const val ACTION_DEBUG_START_AGENT_SESSION =
|
||||
"com.openai.codex.agent.action.DEBUG_START_AGENT_SESSION"
|
||||
private const val ACTION_DEBUG_CANCEL_ALL_AGENT_SESSIONS =
|
||||
"com.openai.codex.agent.action.DEBUG_CANCEL_ALL_AGENT_SESSIONS"
|
||||
private const val EXTRA_DEBUG_PROMPT = "prompt"
|
||||
private const val EXTRA_DEBUG_PROMPT_BASE64 = "promptBase64"
|
||||
private const val EXTRA_DEBUG_TARGET_PACKAGE = "targetPackage"
|
||||
private const val EXTRA_DEBUG_FINAL_PRESENTATION_POLICY = "finalPresentationPolicy"
|
||||
}
|
||||
|
||||
@Volatile
|
||||
private var isAuthenticated = false
|
||||
@Volatile
|
||||
private var agentRefreshInFlight = false
|
||||
@Volatile
|
||||
private var latestAgentRuntimeStatus: AgentCodexAppServerClient.RuntimeStatus? = null
|
||||
@Volatile
|
||||
private var pendingAuthMessage: String? = null
|
||||
|
||||
private val agentSessionController by lazy { AgentSessionController(this) }
|
||||
private val dismissedSessionStore by lazy { DismissedSessionStore(this) }
|
||||
private val sessionListAdapter by lazy { TopLevelSessionListAdapter(this) }
|
||||
private var latestSnapshot: AgentSnapshot = AgentSnapshot.unavailable
|
||||
|
||||
private val runtimeStatusListener = AgentCodexAppServerClient.RuntimeStatusListener { status ->
|
||||
latestAgentRuntimeStatus = status
|
||||
if (status != null) {
|
||||
pendingAuthMessage = null
|
||||
}
|
||||
runOnUiThread {
|
||||
updateAuthUi(renderAuthStatus(), status?.authenticated == true)
|
||||
updateRuntimeStatusUi()
|
||||
}
|
||||
}
|
||||
private val sessionListener = object : AgentManager.SessionListener {
|
||||
override fun onSessionChanged(session: AgentSessionInfo) {
|
||||
refreshAgentSessions()
|
||||
}
|
||||
|
||||
override fun onSessionRemoved(sessionId: String, userId: Int) {
|
||||
refreshAgentSessions()
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionListenerRegistered = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_main)
|
||||
setupViews()
|
||||
requestNotificationPermissionIfNeeded()
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
Log.i(TAG, "onNewIntent action=${intent.action}")
|
||||
setIntent(intent)
|
||||
handleIncomingIntent(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
registerSessionListenerIfNeeded()
|
||||
AgentCodexAppServerClient.registerRuntimeStatusListener(runtimeStatusListener)
|
||||
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this, refreshToken = true)
|
||||
refreshAgentSessions(force = true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
AgentCodexAppServerClient.unregisterRuntimeStatusListener(runtimeStatusListener)
|
||||
unregisterSessionListenerIfNeeded()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun setupViews() {
|
||||
findViewById<ListView>(R.id.session_list).adapter = sessionListAdapter
|
||||
findViewById<ListView>(R.id.session_list).setOnItemClickListener { _, _, position, _ ->
|
||||
sessionListAdapter.getItem(position)?.let { session ->
|
||||
openSessionDetail(session.sessionId)
|
||||
}
|
||||
}
|
||||
findViewById<Button>(R.id.create_session_button).setOnClickListener {
|
||||
launchCreateSessionActivity()
|
||||
}
|
||||
findViewById<Button>(R.id.auth_action).setOnClickListener {
|
||||
authAction()
|
||||
}
|
||||
findViewById<Button>(R.id.refresh_sessions_button).setOnClickListener {
|
||||
refreshAgentSessions(force = true)
|
||||
}
|
||||
updateAuthUi("Agent auth: probing...", false)
|
||||
updateRuntimeStatusUi()
|
||||
updateSessionList(emptyList())
|
||||
}
|
||||
|
||||
private fun handleIncomingIntent(intent: Intent?) {
|
||||
val sessionId = intent?.getStringExtra(AgentManager.EXTRA_SESSION_ID)
|
||||
if (!sessionId.isNullOrBlank()) {
|
||||
openSessionDetail(sessionId)
|
||||
return
|
||||
}
|
||||
if (shouldRouteLauncherIntentToActiveSession(intent)) {
|
||||
routeLauncherIntentToActiveSession()
|
||||
return
|
||||
}
|
||||
maybeHandleDebugIntent(intent)
|
||||
}
|
||||
|
||||
private fun shouldRouteLauncherIntentToActiveSession(intent: Intent?): Boolean {
|
||||
if (intent == null) {
|
||||
return false
|
||||
}
|
||||
if (
|
||||
intent.action == ACTION_DEBUG_CANCEL_ALL_AGENT_SESSIONS ||
|
||||
intent.action == ACTION_DEBUG_START_AGENT_SESSION
|
||||
) {
|
||||
return false
|
||||
}
|
||||
return intent.action == Intent.ACTION_MAIN &&
|
||||
intent.hasCategory(Intent.CATEGORY_LAUNCHER) &&
|
||||
intent.getStringExtra(AgentManager.EXTRA_SESSION_ID).isNullOrBlank()
|
||||
}
|
||||
|
||||
private fun routeLauncherIntentToActiveSession() {
|
||||
thread {
|
||||
val snapshot = runCatching { agentSessionController.loadSnapshot(null) }.getOrNull() ?: return@thread
|
||||
val activeTopLevelSessions = SessionUiFormatter.topLevelSessions(snapshot)
|
||||
.filterNot { isTerminalState(it.state) }
|
||||
if (activeTopLevelSessions.size != 1) {
|
||||
return@thread
|
||||
}
|
||||
val activeSessionId = activeTopLevelSessions.single().sessionId
|
||||
runOnUiThread {
|
||||
openSessionDetail(activeSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeHandleDebugIntent(intent: Intent?) {
|
||||
when (intent?.action) {
|
||||
ACTION_DEBUG_CANCEL_ALL_AGENT_SESSIONS -> {
|
||||
thread {
|
||||
runCatching { agentSessionController.cancelActiveSessions() }
|
||||
.onFailure { err ->
|
||||
Log.w(TAG, "Failed to cancel Agent sessions from debug intent", err)
|
||||
showToast("Failed to cancel active sessions: ${err.message}")
|
||||
}
|
||||
.onSuccess { result ->
|
||||
showToast(
|
||||
"Cancelled ${result.cancelledSessionIds.size} sessions, ${result.failedSessionIds.size} failed",
|
||||
)
|
||||
refreshAgentSessions(force = true)
|
||||
}
|
||||
}
|
||||
intent.action = null
|
||||
}
|
||||
|
||||
ACTION_DEBUG_START_AGENT_SESSION -> {
|
||||
val prompt = extractDebugPrompt(intent)
|
||||
if (prompt.isEmpty()) {
|
||||
intent.action = null
|
||||
return
|
||||
}
|
||||
val targetPackage = intent.getStringExtra(EXTRA_DEBUG_TARGET_PACKAGE)?.trim()?.ifEmpty { null }
|
||||
val finalPresentationPolicy = SessionFinalPresentationPolicy.fromWireValue(
|
||||
intent.getStringExtra(EXTRA_DEBUG_FINAL_PRESENTATION_POLICY),
|
||||
)
|
||||
startDebugSession(
|
||||
prompt = prompt,
|
||||
targetPackage = targetPackage,
|
||||
finalPresentationPolicy = finalPresentationPolicy,
|
||||
)
|
||||
intent.action = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun extractDebugPrompt(intent: Intent): String {
|
||||
intent.getStringExtra(EXTRA_DEBUG_PROMPT_BASE64)
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?.let { encoded ->
|
||||
runCatching {
|
||||
String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8).trim()
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Failed to decode debug promptBase64", err)
|
||||
}.getOrNull()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?.let { return it }
|
||||
}
|
||||
return intent.getStringExtra(EXTRA_DEBUG_PROMPT)?.trim().orEmpty()
|
||||
}
|
||||
|
||||
private fun startDebugSession(
|
||||
prompt: String,
|
||||
targetPackage: String?,
|
||||
finalPresentationPolicy: SessionFinalPresentationPolicy?,
|
||||
) {
|
||||
thread {
|
||||
val result = runCatching {
|
||||
if (targetPackage != null) {
|
||||
agentSessionController.startHomeSession(
|
||||
targetPackage = targetPackage,
|
||||
prompt = prompt,
|
||||
allowDetachedMode = true,
|
||||
finalPresentationPolicy = finalPresentationPolicy
|
||||
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
executionSettings = SessionExecutionSettings.default,
|
||||
)
|
||||
} else {
|
||||
AgentTaskPlanner.startSession(
|
||||
context = this,
|
||||
userObjective = prompt,
|
||||
targetPackageOverride = null,
|
||||
allowDetachedMode = true,
|
||||
finalPresentationPolicyOverride = finalPresentationPolicy,
|
||||
executionSettings = SessionExecutionSettings.default,
|
||||
sessionController = agentSessionController,
|
||||
requestUserInputHandler = { questions ->
|
||||
AgentUserInputPrompter.promptForAnswers(this, questions)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
result.onFailure { err ->
|
||||
Log.w(TAG, "Failed to start debug Agent session", err)
|
||||
showToast("Failed to start Agent session: ${err.message}")
|
||||
}
|
||||
result.onSuccess { started ->
|
||||
showToast("Started session ${started.parentSessionId}")
|
||||
refreshAgentSessions(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refreshAgentSessions(force: Boolean = false) {
|
||||
if (!force && agentRefreshInFlight) {
|
||||
return
|
||||
}
|
||||
agentRefreshInFlight = true
|
||||
thread {
|
||||
try {
|
||||
val result = runCatching { agentSessionController.loadSnapshot(null) }
|
||||
result.onFailure { err ->
|
||||
latestSnapshot = AgentSnapshot.unavailable
|
||||
runOnUiThread {
|
||||
findViewById<TextView>(R.id.agent_status).text =
|
||||
"Agent framework unavailable (${err.message})"
|
||||
updateSessionList(emptyList())
|
||||
}
|
||||
}
|
||||
result.onSuccess { snapshot ->
|
||||
latestSnapshot = snapshot
|
||||
dismissedSessionStore.prune(snapshot.sessions.map(AgentSessionDetails::sessionId).toSet())
|
||||
val topLevelSessions = SessionUiFormatter.topLevelSessions(snapshot)
|
||||
.filter { session ->
|
||||
if (!isTerminalState(session.state)) {
|
||||
dismissedSessionStore.clearDismissed(session.sessionId)
|
||||
true
|
||||
} else {
|
||||
!dismissedSessionStore.isDismissed(session.sessionId)
|
||||
}
|
||||
}
|
||||
runOnUiThread {
|
||||
updateFrameworkStatus(snapshot)
|
||||
updateSessionList(topLevelSessions)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
agentRefreshInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateFrameworkStatus(snapshot: AgentSnapshot) {
|
||||
val roleHolders = if (snapshot.roleHolders.isEmpty()) {
|
||||
"none"
|
||||
} else {
|
||||
snapshot.roleHolders.joinToString(", ")
|
||||
}
|
||||
findViewById<TextView>(R.id.agent_status).text =
|
||||
"Agent framework active. Genie role holders: $roleHolders"
|
||||
}
|
||||
|
||||
private fun updateSessionList(sessions: List<AgentSessionDetails>) {
|
||||
sessionListAdapter.replaceItems(sessions)
|
||||
findViewById<TextView>(R.id.session_list_empty).visibility =
|
||||
if (sessions.isEmpty()) View.VISIBLE else View.GONE
|
||||
}
|
||||
|
||||
private fun registerSessionListenerIfNeeded() {
|
||||
if (sessionListenerRegistered || !agentSessionController.isAvailable()) {
|
||||
return
|
||||
}
|
||||
sessionListenerRegistered = runCatching {
|
||||
agentSessionController.registerSessionListener(mainExecutor, sessionListener)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun unregisterSessionListenerIfNeeded() {
|
||||
if (!sessionListenerRegistered) {
|
||||
return
|
||||
}
|
||||
runCatching { agentSessionController.unregisterSessionListener(sessionListener) }
|
||||
sessionListenerRegistered = false
|
||||
}
|
||||
|
||||
private fun requestNotificationPermissionIfNeeded() {
|
||||
if (Build.VERSION.SDK_INT < 33) {
|
||||
return
|
||||
}
|
||||
if (checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS)
|
||||
== PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
return
|
||||
}
|
||||
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), 1001)
|
||||
}
|
||||
|
||||
private fun authAction() {
|
||||
if (isAuthenticated) {
|
||||
signOutAgent()
|
||||
} else {
|
||||
startAgentSignIn()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAgentSignIn() {
|
||||
pendingAuthMessage = "Agent auth: opening browser for sign-in..."
|
||||
updateAuthUi(pendingAuthMessage.orEmpty(), false)
|
||||
thread {
|
||||
runCatching { AgentCodexAppServerClient.startChatGptLogin(this) }
|
||||
.onFailure { err ->
|
||||
pendingAuthMessage = null
|
||||
updateAuthUi("Agent auth: sign-in failed (${err.message})", false)
|
||||
}
|
||||
.onSuccess { loginSession ->
|
||||
pendingAuthMessage = "Agent auth: complete sign-in in the browser"
|
||||
updateAuthUi(pendingAuthMessage.orEmpty(), false)
|
||||
runOnUiThread {
|
||||
runCatching {
|
||||
startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(loginSession.authUrl)))
|
||||
}.onFailure { err ->
|
||||
pendingAuthMessage = "Agent auth: open ${loginSession.authUrl}"
|
||||
updateAuthUi(pendingAuthMessage.orEmpty(), false)
|
||||
showToast("Failed to open browser: ${err.message}")
|
||||
}.onSuccess {
|
||||
showToast("Complete sign-in in the browser")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun signOutAgent() {
|
||||
pendingAuthMessage = "Agent auth: signing out..."
|
||||
updateAuthUi(pendingAuthMessage.orEmpty(), false)
|
||||
thread {
|
||||
runCatching { AgentCodexAppServerClient.logoutAccount(this) }
|
||||
.onFailure { err ->
|
||||
pendingAuthMessage = null
|
||||
updateAuthUi("Agent auth: sign out failed (${err.message})", isAuthenticated)
|
||||
}
|
||||
.onSuccess {
|
||||
pendingAuthMessage = null
|
||||
AgentCodexAppServerClient.refreshRuntimeStatusAsync(this)
|
||||
showToast("Signed out")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateRuntimeStatusUi() {
|
||||
findViewById<TextView>(R.id.agent_runtime_status).text = renderAgentRuntimeStatus()
|
||||
}
|
||||
|
||||
private fun renderAgentRuntimeStatus(): String {
|
||||
val runtimeStatus = latestAgentRuntimeStatus
|
||||
if (runtimeStatus == null) {
|
||||
return "Agent runtime: probing..."
|
||||
}
|
||||
val authSummary = if (runtimeStatus.authenticated) {
|
||||
runtimeStatus.accountEmail?.let { "signed in ($it)" } ?: "signed in"
|
||||
} else {
|
||||
"not signed in"
|
||||
}
|
||||
val configuredModelSuffix = runtimeStatus.configuredModel
|
||||
?.takeIf { it != runtimeStatus.effectiveModel }
|
||||
?.let { ", configured=$it" }
|
||||
?: ""
|
||||
val effectiveModel = runtimeStatus.effectiveModel ?: "unknown"
|
||||
return "Agent runtime: $authSummary, provider=${runtimeStatus.modelProviderId}, effective=$effectiveModel$configuredModelSuffix, clients=${runtimeStatus.clientCount}, base=${runtimeStatus.upstreamBaseUrl}"
|
||||
}
|
||||
|
||||
private fun renderAuthStatus(): String {
|
||||
pendingAuthMessage?.let { return it }
|
||||
val runtimeStatus = latestAgentRuntimeStatus
|
||||
if (runtimeStatus == null) {
|
||||
return "Agent auth: probing..."
|
||||
}
|
||||
if (!runtimeStatus.authenticated) {
|
||||
return "Agent auth: not signed in"
|
||||
}
|
||||
return runtimeStatus.accountEmail?.let { email ->
|
||||
"Agent auth: signed in ($email)"
|
||||
} ?: "Agent auth: signed in"
|
||||
}
|
||||
|
||||
private fun updateAuthUi(
|
||||
message: String,
|
||||
authenticated: Boolean,
|
||||
) {
|
||||
isAuthenticated = authenticated
|
||||
runOnUiThread {
|
||||
findViewById<TextView>(R.id.auth_status).text = message
|
||||
findViewById<Button>(R.id.auth_action).text =
|
||||
if (authenticated) "Sign out" else "Start sign-in"
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTerminalState(state: Int): Boolean {
|
||||
return state == AgentSessionInfo.STATE_COMPLETED ||
|
||||
state == AgentSessionInfo.STATE_CANCELLED ||
|
||||
state == AgentSessionInfo.STATE_FAILED
|
||||
}
|
||||
|
||||
private fun openSessionDetail(sessionId: String) {
|
||||
startActivity(
|
||||
Intent(this, SessionDetailActivity::class.java)
|
||||
.putExtra(SessionDetailActivity.EXTRA_SESSION_ID, sessionId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun launchCreateSessionActivity() {
|
||||
startActivity(
|
||||
CreateSessionActivity.newSessionIntent(
|
||||
context = this,
|
||||
initialSettings = CreateSessionActivity.preferredInitialSettings(),
|
||||
),
|
||||
)
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
object SessionContinuationPromptBuilder {
|
||||
private const val MAX_TIMELINE_CHARS = 1200
|
||||
private const val MAX_DETAIL_CHARS = 600
|
||||
|
||||
fun build(
|
||||
sourceTopLevelSession: AgentSessionDetails,
|
||||
selectedSession: AgentSessionDetails,
|
||||
prompt: String,
|
||||
): String {
|
||||
return buildString {
|
||||
appendLine(prompt.trim())
|
||||
appendLine()
|
||||
appendLine("This is a follow-up continuation of an earlier attempt in the same top-level Agent session.")
|
||||
appendLine("Reuse facts learned previously instead of starting over from scratch.")
|
||||
appendLine()
|
||||
appendLine("Previous session context:")
|
||||
appendLine("- Top-level session: ${sourceTopLevelSession.sessionId}")
|
||||
appendLine("- Previous child session: ${selectedSession.sessionId}")
|
||||
selectedSession.targetPackage?.let { appendLine("- Target package: $it") }
|
||||
appendLine("- Previous state: ${selectedSession.stateLabel}")
|
||||
appendLine("- Previous presentation: ${selectedSession.targetPresentationLabel}")
|
||||
appendLine("- Previous runtime: ${selectedSession.targetRuntimeLabel}")
|
||||
selectedSession.latestResult
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { appendLine("- Previous result: ${it.take(MAX_DETAIL_CHARS)}") }
|
||||
selectedSession.latestError
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { appendLine("- Previous error: ${it.take(MAX_DETAIL_CHARS)}") }
|
||||
selectedSession.latestTrace
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { appendLine("- Previous trace: ${it.take(MAX_DETAIL_CHARS)}") }
|
||||
val timeline = selectedSession.timeline.trim()
|
||||
if (timeline.isNotEmpty() && timeline != "Diagnostics not loaded.") {
|
||||
appendLine()
|
||||
appendLine("Recent timeline from the previous child session:")
|
||||
appendLine(timeline.take(MAX_TIMELINE_CHARS))
|
||||
}
|
||||
val parentSummary = sourceTopLevelSession.latestResult
|
||||
?: sourceTopLevelSession.latestError
|
||||
?: sourceTopLevelSession.latestTrace
|
||||
parentSummary
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let {
|
||||
appendLine()
|
||||
appendLine("Top-level session summary:")
|
||||
appendLine(it.take(MAX_DETAIL_CHARS))
|
||||
}
|
||||
}.trim()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,777 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.Activity
|
||||
import android.app.agent.AgentManager
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.content.Intent
|
||||
import android.graphics.Typeface
|
||||
import android.os.Binder
|
||||
import android.os.Bundle
|
||||
import android.text.SpannableStringBuilder
|
||||
import android.text.Spanned
|
||||
import android.text.style.StyleSpan
|
||||
import android.util.Log
|
||||
import android.view.View
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.LinearLayout
|
||||
import android.widget.ScrollView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class SessionDetailActivity : Activity() {
|
||||
companion object {
|
||||
private const val TAG = "CodexSessionDetail"
|
||||
const val EXTRA_SESSION_ID = "sessionId"
|
||||
private const val ACTION_DEBUG_CONTINUE_SESSION =
|
||||
"com.openai.codex.agent.action.DEBUG_CONTINUE_SESSION"
|
||||
private const val EXTRA_DEBUG_PROMPT = "prompt"
|
||||
}
|
||||
|
||||
private data class SessionViewState(
|
||||
val topLevelSession: AgentSessionDetails,
|
||||
val childSessions: List<AgentSessionDetails>,
|
||||
val selectedChildSession: AgentSessionDetails?,
|
||||
)
|
||||
|
||||
private val sessionController by lazy { AgentSessionController(this) }
|
||||
private val dismissedSessionStore by lazy { DismissedSessionStore(this) }
|
||||
private val sessionUiLeaseToken = Binder()
|
||||
private var leasedSessionId: String? = null
|
||||
private var requestedSessionId: String? = null
|
||||
private var topLevelSessionId: String? = null
|
||||
private var selectedChildSessionId: String? = null
|
||||
private var latestSnapshot: AgentSnapshot = AgentSnapshot.unavailable
|
||||
private var refreshInFlight = false
|
||||
|
||||
private val sessionListener = object : AgentManager.SessionListener {
|
||||
override fun onSessionChanged(session: AgentSessionInfo) {
|
||||
refreshSnapshot()
|
||||
}
|
||||
|
||||
override fun onSessionRemoved(sessionId: String, userId: Int) {
|
||||
refreshSnapshot()
|
||||
}
|
||||
}
|
||||
|
||||
private var sessionListenerRegistered = false
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
setContentView(R.layout.activity_session_detail)
|
||||
requestedSessionId = intent.getStringExtra(EXTRA_SESSION_ID)
|
||||
setupViews()
|
||||
maybeHandleDebugIntent(intent)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
registerSessionListenerIfNeeded()
|
||||
refreshSnapshot(force = true)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
requestedSessionId = intent.getStringExtra(EXTRA_SESSION_ID)
|
||||
topLevelSessionId = null
|
||||
selectedChildSessionId = null
|
||||
maybeHandleDebugIntent(intent)
|
||||
refreshSnapshot(force = true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
unregisterSessionListenerIfNeeded()
|
||||
updateSessionUiLease(null)
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
private fun setupViews() {
|
||||
findViewById<Button>(R.id.session_detail_cancel_button).setOnClickListener {
|
||||
cancelSession()
|
||||
}
|
||||
findViewById<Button>(R.id.session_detail_delete_button).setOnClickListener {
|
||||
deleteSession()
|
||||
}
|
||||
findViewById<Button>(R.id.session_detail_child_cancel_button).setOnClickListener {
|
||||
cancelSelectedChildSession()
|
||||
}
|
||||
findViewById<Button>(R.id.session_detail_child_delete_button).setOnClickListener {
|
||||
deleteSelectedChildSession()
|
||||
}
|
||||
findViewById<Button>(R.id.session_detail_attach_button).setOnClickListener {
|
||||
attachTarget()
|
||||
}
|
||||
findViewById<Button>(R.id.session_detail_answer_button).setOnClickListener {
|
||||
answerQuestion()
|
||||
}
|
||||
findViewById<Button>(R.id.session_detail_follow_up_button).setOnClickListener {
|
||||
sendFollowUpPrompt()
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeHandleDebugIntent(intent: Intent?) {
|
||||
if (intent?.action != ACTION_DEBUG_CONTINUE_SESSION) {
|
||||
return
|
||||
}
|
||||
val prompt = intent.getStringExtra(EXTRA_DEBUG_PROMPT)?.trim().orEmpty()
|
||||
val sessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.trim().orEmpty()
|
||||
if (prompt.isEmpty()) {
|
||||
intent.action = null
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Handling debug continuation for sessionId=$sessionId")
|
||||
thread {
|
||||
runCatching {
|
||||
val snapshot = sessionController.loadSnapshot(sessionId.ifEmpty { requestedSessionId })
|
||||
val viewState = resolveViewState(snapshot) ?: error("Session not found")
|
||||
Log.i(TAG, "Loaded snapshot for continuation topLevel=${viewState.topLevelSession.sessionId} child=${viewState.selectedChildSession?.sessionId}")
|
||||
continueSessionInPlaceOnce(prompt, snapshot, viewState)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Debug continuation failed", err)
|
||||
showToast("Failed to continue session: ${err.message}")
|
||||
}.onSuccess { result ->
|
||||
Log.i(TAG, "Debug continuation reused topLevel=${result.parentSessionId}")
|
||||
showToast("Continued session in place")
|
||||
runOnUiThread {
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
intent.action = null
|
||||
}
|
||||
|
||||
private fun registerSessionListenerIfNeeded() {
|
||||
if (sessionListenerRegistered || !sessionController.isAvailable()) {
|
||||
return
|
||||
}
|
||||
sessionListenerRegistered = runCatching {
|
||||
sessionController.registerSessionListener(mainExecutor, sessionListener)
|
||||
}.getOrDefault(false)
|
||||
}
|
||||
|
||||
private fun unregisterSessionListenerIfNeeded() {
|
||||
if (!sessionListenerRegistered) {
|
||||
return
|
||||
}
|
||||
runCatching { sessionController.unregisterSessionListener(sessionListener) }
|
||||
sessionListenerRegistered = false
|
||||
}
|
||||
|
||||
private fun refreshSnapshot(force: Boolean = false) {
|
||||
if (!force && refreshInFlight) {
|
||||
return
|
||||
}
|
||||
refreshInFlight = true
|
||||
thread {
|
||||
try {
|
||||
val snapshot = runCatching {
|
||||
sessionController.loadSnapshot(requestedSessionId ?: selectedChildSessionId ?: topLevelSessionId)
|
||||
}
|
||||
.getOrElse {
|
||||
runOnUiThread {
|
||||
findViewById<TextView>(R.id.session_detail_summary).text =
|
||||
"Failed to load session: ${it.message}"
|
||||
}
|
||||
return@thread
|
||||
}
|
||||
latestSnapshot = snapshot
|
||||
runOnUiThread {
|
||||
updateUi(snapshot)
|
||||
}
|
||||
} finally {
|
||||
refreshInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateUi(snapshot: AgentSnapshot) {
|
||||
val viewState = resolveViewState(snapshot)
|
||||
if (viewState == null) {
|
||||
findViewById<TextView>(R.id.session_detail_summary).text = "Session not found"
|
||||
findViewById<TextView>(R.id.session_detail_child_summary).text = "Session not found"
|
||||
updateSessionUiLease(null)
|
||||
return
|
||||
}
|
||||
val topLevelSession = viewState.topLevelSession
|
||||
val selectedChildSession = viewState.selectedChildSession
|
||||
val actionableSession = selectedChildSession ?: topLevelSession
|
||||
val canStartStandaloneHomeSession = canStartStandaloneHomeSession(viewState)
|
||||
val executionSettings = sessionController.executionSettingsForSession(topLevelSession.sessionId)
|
||||
val summary = buildString {
|
||||
append(
|
||||
SessionUiFormatter.detailSummary(
|
||||
context = this@SessionDetailActivity,
|
||||
session = topLevelSession,
|
||||
parentSession = null,
|
||||
),
|
||||
)
|
||||
if (!executionSettings.model.isNullOrBlank()) {
|
||||
append("\nModel: ${executionSettings.model}")
|
||||
}
|
||||
if (!executionSettings.reasoningEffort.isNullOrBlank()) {
|
||||
append("\nThinking depth: ${executionSettings.reasoningEffort}")
|
||||
}
|
||||
}
|
||||
findViewById<TextView>(R.id.session_detail_summary).text = formatDetailSummary(summary.trimEnd())
|
||||
renderChildSessions(viewState.childSessions, selectedChildSession?.sessionId)
|
||||
val childSummaryText = selectedChildSession?.let { child ->
|
||||
SessionUiFormatter.detailSummary(
|
||||
context = this,
|
||||
session = child,
|
||||
parentSession = topLevelSession,
|
||||
)
|
||||
} ?: if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT && viewState.childSessions.isEmpty()) {
|
||||
"No child sessions yet. The Agent is still planning targets or waiting to start them."
|
||||
} else {
|
||||
"Select a child session to inspect it. Tap the same child again to collapse it."
|
||||
}
|
||||
findViewById<TextView>(R.id.session_detail_child_summary).text = formatDetailSummary(childSummaryText)
|
||||
findViewById<ScrollView>(R.id.session_detail_child_summary_container).scrollTo(0, 0)
|
||||
findViewById<TextView>(R.id.session_detail_timeline).text = formatTimeline(
|
||||
topLevelSession,
|
||||
selectedChildSession,
|
||||
)
|
||||
findViewById<ScrollView>(R.id.session_detail_timeline_container).scrollTo(0, 0)
|
||||
|
||||
val isWaitingForUser = actionableSession.state == AgentSessionInfo.STATE_WAITING_FOR_USER &&
|
||||
!actionableSession.latestQuestion.isNullOrBlank()
|
||||
findViewById<TextView>(R.id.session_detail_question_label).visibility =
|
||||
if (isWaitingForUser) View.VISIBLE else View.GONE
|
||||
findViewById<TextView>(R.id.session_detail_question).visibility =
|
||||
if (isWaitingForUser) View.VISIBLE else View.GONE
|
||||
findViewById<EditText>(R.id.session_detail_answer_input).visibility =
|
||||
if (isWaitingForUser) View.VISIBLE else View.GONE
|
||||
findViewById<Button>(R.id.session_detail_answer_button).visibility =
|
||||
if (isWaitingForUser) View.VISIBLE else View.GONE
|
||||
findViewById<TextView>(R.id.session_detail_question).text =
|
||||
actionableSession.latestQuestion.orEmpty()
|
||||
|
||||
val isTopLevelActive = !isTerminalState(topLevelSession.state)
|
||||
val topLevelActionNote = findViewById<TextView>(R.id.session_detail_top_level_action_note)
|
||||
findViewById<Button>(R.id.session_detail_cancel_button).apply {
|
||||
visibility = if (isTopLevelActive) View.VISIBLE else View.GONE
|
||||
text = if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT && viewState.childSessions.isNotEmpty()) {
|
||||
"Cancel Child Sessions"
|
||||
} else {
|
||||
"Cancel Session"
|
||||
}
|
||||
}
|
||||
findViewById<Button>(R.id.session_detail_delete_button).visibility =
|
||||
if (isTopLevelActive) View.GONE else View.VISIBLE
|
||||
findViewById<Button>(R.id.session_detail_delete_button).text = "Delete Session"
|
||||
topLevelActionNote.visibility = View.VISIBLE
|
||||
topLevelActionNote.text = if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT) {
|
||||
if (isTopLevelActive && viewState.childSessions.isEmpty()) {
|
||||
"This Agent-anchored session is still planning targets."
|
||||
} else if (isTopLevelActive) {
|
||||
"Cancelling the top-level session cancels all active child sessions."
|
||||
} else {
|
||||
"Deleting the top-level session removes it and its child sessions from the Agent UI."
|
||||
}
|
||||
} else {
|
||||
if (canStartStandaloneHomeSession) {
|
||||
"This app-scoped session is ready to start. Use the Start dialog below."
|
||||
} else if (isTopLevelActive) {
|
||||
"This app-scoped session is still active."
|
||||
} else {
|
||||
"Deleting this app-scoped session dismisses it from framework and removes it from the Agent UI."
|
||||
}
|
||||
}
|
||||
val childIsSelected = selectedChildSession != null
|
||||
val isSelectedChildActive = selectedChildSession?.let { !isTerminalState(it.state) } == true
|
||||
findViewById<LinearLayout>(R.id.session_detail_child_actions).visibility =
|
||||
if (childIsSelected) View.VISIBLE else View.GONE
|
||||
findViewById<Button>(R.id.session_detail_child_cancel_button).visibility =
|
||||
if (isSelectedChildActive) View.VISIBLE else View.GONE
|
||||
findViewById<Button>(R.id.session_detail_child_delete_button).visibility =
|
||||
if (childIsSelected && !isSelectedChildActive) View.VISIBLE else View.GONE
|
||||
val canAttach = childIsSelected &&
|
||||
actionableSession.targetPresentation != AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
|
||||
findViewById<Button>(R.id.session_detail_attach_button).visibility =
|
||||
if (canAttach) View.VISIBLE else View.GONE
|
||||
val supportsInPlaceContinuation = topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT
|
||||
val continueVisibility = if (canStartStandaloneHomeSession || (!isTopLevelActive && supportsInPlaceContinuation)) {
|
||||
View.VISIBLE
|
||||
} else {
|
||||
View.GONE
|
||||
}
|
||||
findViewById<TextView>(R.id.session_detail_follow_up_label).apply {
|
||||
visibility = continueVisibility
|
||||
text = if (canStartStandaloneHomeSession) {
|
||||
"Start Session"
|
||||
} else {
|
||||
"Continue Same Session"
|
||||
}
|
||||
}
|
||||
findViewById<EditText>(R.id.session_detail_follow_up_prompt).visibility =
|
||||
if (canStartStandaloneHomeSession) View.GONE else continueVisibility
|
||||
findViewById<Button>(R.id.session_detail_follow_up_button).apply {
|
||||
visibility = continueVisibility
|
||||
text = if (canStartStandaloneHomeSession) {
|
||||
"Start Session"
|
||||
} else {
|
||||
"Send Continuation Prompt"
|
||||
}
|
||||
}
|
||||
findViewById<TextView>(R.id.session_detail_follow_up_note).visibility =
|
||||
if (!isTopLevelActive && !supportsInPlaceContinuation) View.VISIBLE else View.GONE
|
||||
|
||||
updateSessionUiLease(topLevelSession.sessionId)
|
||||
}
|
||||
|
||||
private fun renderChildSessions(
|
||||
sessions: List<AgentSessionDetails>,
|
||||
selectedSessionId: String?,
|
||||
) {
|
||||
val container = findViewById<LinearLayout>(R.id.session_detail_children_container)
|
||||
val emptyView = findViewById<TextView>(R.id.session_detail_children_empty)
|
||||
container.removeAllViews()
|
||||
emptyView.visibility = if (sessions.isEmpty()) View.VISIBLE else View.GONE
|
||||
sessions.forEach { session ->
|
||||
val isSelected = session.sessionId == selectedSessionId
|
||||
val row = LinearLayout(this).apply {
|
||||
orientation = LinearLayout.VERTICAL
|
||||
setPadding(dp(12), dp(12), dp(12), dp(12))
|
||||
isClickable = true
|
||||
isFocusable = true
|
||||
background = getDrawable(
|
||||
if (isSelected) {
|
||||
R.drawable.session_child_card_selected_background
|
||||
} else {
|
||||
R.drawable.session_child_card_background
|
||||
},
|
||||
)
|
||||
val layoutParams = LinearLayout.LayoutParams(
|
||||
LinearLayout.LayoutParams.MATCH_PARENT,
|
||||
LinearLayout.LayoutParams.WRAP_CONTENT,
|
||||
).apply {
|
||||
bottomMargin = dp(8)
|
||||
}
|
||||
this.layoutParams = layoutParams
|
||||
setOnClickListener {
|
||||
selectedChildSessionId = if (session.sessionId == selectedChildSessionId) {
|
||||
null
|
||||
} else {
|
||||
session.sessionId
|
||||
}
|
||||
requestedSessionId = topLevelSessionId
|
||||
updateUi(latestSnapshot)
|
||||
}
|
||||
}
|
||||
val title = TextView(this).apply {
|
||||
text = SessionUiFormatter.relatedSessionTitle(this@SessionDetailActivity, session)
|
||||
setTypeface(typeface, if (isSelected) Typeface.BOLD else Typeface.NORMAL)
|
||||
}
|
||||
val subtitle = TextView(this).apply {
|
||||
text = SessionUiFormatter.relatedSessionSubtitle(session)
|
||||
}
|
||||
row.addView(title)
|
||||
row.addView(subtitle)
|
||||
container.addView(row)
|
||||
}
|
||||
}
|
||||
|
||||
private fun renderTimeline(
|
||||
topLevelSession: AgentSessionDetails,
|
||||
selectedChildSession: AgentSessionDetails?,
|
||||
): String {
|
||||
return if (selectedChildSession == null) {
|
||||
topLevelSession.timeline
|
||||
} else {
|
||||
buildString {
|
||||
append("Top-level ${topLevelSession.sessionId}\n")
|
||||
append(topLevelSession.timeline)
|
||||
append("\n\nSelected child ${selectedChildSession.sessionId}\n")
|
||||
append(selectedChildSession.timeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun formatDetailSummary(summary: String): CharSequence {
|
||||
val trimmed = summary.trim()
|
||||
if (trimmed.isEmpty()) {
|
||||
return ""
|
||||
}
|
||||
val builder = SpannableStringBuilder()
|
||||
trimmed.lines().forEachIndexed { index, line ->
|
||||
if (index > 0) {
|
||||
builder.append("\n\n")
|
||||
}
|
||||
val separatorIndex = line.indexOf(':')
|
||||
if (separatorIndex <= 0) {
|
||||
builder.append(line)
|
||||
return@forEachIndexed
|
||||
}
|
||||
val label = line.substring(0, separatorIndex)
|
||||
val value = line.substring(separatorIndex + 1).trim()
|
||||
appendBoldLine(builder, label)
|
||||
if (value.isNotEmpty()) {
|
||||
builder.append('\n')
|
||||
builder.append(value)
|
||||
}
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun formatTimeline(
|
||||
topLevelSession: AgentSessionDetails,
|
||||
selectedChildSession: AgentSessionDetails?,
|
||||
): CharSequence {
|
||||
val builder = SpannableStringBuilder()
|
||||
appendBoldLine(builder, "Top-level session ${topLevelSession.sessionId}")
|
||||
builder.append('\n')
|
||||
builder.append(topLevelSession.timeline.ifBlank { "No framework events yet." })
|
||||
selectedChildSession?.let { child ->
|
||||
builder.append("\n\n")
|
||||
appendBoldLine(builder, "Selected child ${child.sessionId}")
|
||||
builder.append('\n')
|
||||
builder.append(child.timeline.ifBlank { "No framework events yet." })
|
||||
}
|
||||
return builder
|
||||
}
|
||||
|
||||
private fun appendBoldLine(
|
||||
builder: SpannableStringBuilder,
|
||||
text: String,
|
||||
) {
|
||||
val start = builder.length
|
||||
builder.append(text)
|
||||
builder.setSpan(
|
||||
StyleSpan(Typeface.BOLD),
|
||||
start,
|
||||
builder.length,
|
||||
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun answerQuestion() {
|
||||
val selectedSession = currentActionableSession(latestSnapshot) ?: return
|
||||
val answerInput = findViewById<EditText>(R.id.session_detail_answer_input)
|
||||
val answer = answerInput.text.toString().trim()
|
||||
if (answer.isEmpty()) {
|
||||
answerInput.error = "Enter an answer"
|
||||
return
|
||||
}
|
||||
thread {
|
||||
runCatching {
|
||||
sessionController.answerQuestion(
|
||||
selectedSession.sessionId,
|
||||
answer,
|
||||
topLevelSession(latestSnapshot)?.sessionId,
|
||||
)
|
||||
}.onFailure { err ->
|
||||
showToast("Failed to answer question: ${err.message}")
|
||||
}.onSuccess {
|
||||
answerInput.post { answerInput.text.clear() }
|
||||
topLevelSession(latestSnapshot)?.let { topLevelSession ->
|
||||
SessionNotificationCoordinator.acknowledgeSessionTree(
|
||||
context = this,
|
||||
sessionController = sessionController,
|
||||
topLevelSessionId = topLevelSession.sessionId,
|
||||
sessionIds = listOf(topLevelSession.sessionId, selectedSession.sessionId),
|
||||
)
|
||||
}
|
||||
showToast("Answered ${selectedSession.sessionId}")
|
||||
refreshSnapshot(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun attachTarget() {
|
||||
val selectedSession = selectedChildSession(latestSnapshot) ?: return
|
||||
thread {
|
||||
runCatching {
|
||||
sessionController.attachTarget(selectedSession.sessionId)
|
||||
}.onFailure { err ->
|
||||
showToast("Failed to attach target: ${err.message}")
|
||||
}.onSuccess {
|
||||
showToast("Attached target for ${selectedSession.sessionId}")
|
||||
refreshSnapshot(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSession() {
|
||||
val topLevelSession = topLevelSession(latestSnapshot) ?: return
|
||||
thread {
|
||||
runCatching {
|
||||
if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT) {
|
||||
val activeChildren = childSessions(latestSnapshot)
|
||||
.filterNot { isTerminalState(it.state) }
|
||||
if (activeChildren.isEmpty()) {
|
||||
sessionController.cancelSession(topLevelSession.sessionId)
|
||||
} else {
|
||||
activeChildren.forEach { childSession ->
|
||||
sessionController.cancelSession(childSession.sessionId)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
sessionController.cancelSession(topLevelSession.sessionId)
|
||||
}
|
||||
}.onFailure { err ->
|
||||
showToast("Failed to cancel session: ${err.message}")
|
||||
}.onSuccess {
|
||||
SessionNotificationCoordinator.acknowledgeSessionTree(
|
||||
context = this,
|
||||
sessionController = sessionController,
|
||||
topLevelSessionId = topLevelSession.sessionId,
|
||||
sessionIds = listOf(topLevelSession.sessionId) + childSessions(latestSnapshot).map(AgentSessionDetails::sessionId),
|
||||
)
|
||||
showToast(
|
||||
if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_AGENT) {
|
||||
"Cancelled active child sessions"
|
||||
} else {
|
||||
"Cancelled ${topLevelSession.sessionId}"
|
||||
},
|
||||
)
|
||||
refreshSnapshot(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteSession() {
|
||||
val topLevelSession = topLevelSession(latestSnapshot) ?: return
|
||||
thread {
|
||||
runCatching {
|
||||
if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_HOME) {
|
||||
sessionController.cancelSession(topLevelSession.sessionId)
|
||||
}
|
||||
dismissedSessionStore.dismiss(topLevelSession.sessionId)
|
||||
childSessions(latestSnapshot).forEach { childSession ->
|
||||
dismissedSessionStore.dismiss(childSession.sessionId)
|
||||
}
|
||||
SessionNotificationCoordinator.acknowledgeSessionTree(
|
||||
context = this,
|
||||
sessionController = sessionController,
|
||||
topLevelSessionId = topLevelSession.sessionId,
|
||||
sessionIds = listOf(topLevelSession.sessionId) + childSessions(latestSnapshot).map(AgentSessionDetails::sessionId),
|
||||
)
|
||||
}.onFailure { err ->
|
||||
showToast("Failed to delete session: ${err.message}")
|
||||
}.onSuccess {
|
||||
showToast("Deleted session")
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSelectedChildSession() {
|
||||
val selectedChildSession = selectedChildSession(latestSnapshot) ?: return
|
||||
thread {
|
||||
runCatching {
|
||||
sessionController.cancelSession(selectedChildSession.sessionId)
|
||||
}.onFailure { err ->
|
||||
showToast("Failed to cancel child session: ${err.message}")
|
||||
}.onSuccess {
|
||||
topLevelSession(latestSnapshot)?.let { topLevelSession ->
|
||||
SessionNotificationCoordinator.acknowledgeSessionTree(
|
||||
context = this,
|
||||
sessionController = sessionController,
|
||||
topLevelSessionId = topLevelSession.sessionId,
|
||||
sessionIds = listOf(selectedChildSession.sessionId),
|
||||
)
|
||||
}
|
||||
showToast("Cancelled ${selectedChildSession.sessionId}")
|
||||
refreshSnapshot(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun deleteSelectedChildSession() {
|
||||
val selectedChildSession = selectedChildSession(latestSnapshot) ?: return
|
||||
thread {
|
||||
runCatching {
|
||||
dismissedSessionStore.dismiss(selectedChildSession.sessionId)
|
||||
}.onFailure { err ->
|
||||
showToast("Failed to delete child session: ${err.message}")
|
||||
}.onSuccess {
|
||||
topLevelSession(latestSnapshot)?.let { topLevelSession ->
|
||||
SessionNotificationCoordinator.acknowledgeSessionTree(
|
||||
context = this,
|
||||
sessionController = sessionController,
|
||||
topLevelSessionId = topLevelSession.sessionId,
|
||||
sessionIds = listOf(selectedChildSession.sessionId),
|
||||
)
|
||||
}
|
||||
selectedChildSessionId = null
|
||||
showToast("Deleted child session")
|
||||
refreshSnapshot(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendFollowUpPrompt() {
|
||||
val viewState = resolveViewState(latestSnapshot) ?: return
|
||||
val isStandaloneHomeStart = canStartStandaloneHomeSession(viewState)
|
||||
if (isStandaloneHomeStart) {
|
||||
showStandaloneHomeSessionDialog(viewState)
|
||||
} else {
|
||||
val promptInput = findViewById<EditText>(R.id.session_detail_follow_up_prompt)
|
||||
val prompt = promptInput.text.toString().trim()
|
||||
if (prompt.isEmpty()) {
|
||||
promptInput.error = "Enter a follow-up prompt"
|
||||
return
|
||||
}
|
||||
promptInput.text.clear()
|
||||
continueSessionInPlaceAsync(prompt, latestSnapshot)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showStandaloneHomeSessionDialog(
|
||||
viewState: SessionViewState,
|
||||
) {
|
||||
val topLevelSession = viewState.topLevelSession
|
||||
val targetPackage = checkNotNull(topLevelSession.targetPackage) {
|
||||
"No target package available for this session"
|
||||
}
|
||||
startActivity(
|
||||
CreateSessionActivity.existingHomeSessionIntent(
|
||||
context = this,
|
||||
sessionId = topLevelSession.sessionId,
|
||||
targetPackage = targetPackage,
|
||||
initialSettings = sessionController.executionSettingsForSession(topLevelSession.sessionId),
|
||||
),
|
||||
)
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
|
||||
private fun continueSessionInPlaceAsync(
|
||||
prompt: String,
|
||||
snapshot: AgentSnapshot,
|
||||
) {
|
||||
thread {
|
||||
runCatching {
|
||||
continueSessionInPlaceOnce(prompt, snapshot)
|
||||
}.onFailure { err ->
|
||||
showToast("Failed to continue session: ${err.message}")
|
||||
}.onSuccess { result ->
|
||||
showToast("Continued session in place")
|
||||
runOnUiThread {
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun continueSessionInPlaceOnce(
|
||||
prompt: String,
|
||||
snapshot: AgentSnapshot,
|
||||
viewState: SessionViewState = resolveViewState(snapshot) ?: error("Session not found"),
|
||||
): SessionStartResult {
|
||||
val topLevelSession = viewState.topLevelSession
|
||||
val selectedSession = viewState.selectedChildSession
|
||||
?: viewState.childSessions.lastOrNull()
|
||||
?: topLevelSession
|
||||
Log.i(
|
||||
TAG,
|
||||
"Continuing session topLevel=${topLevelSession.sessionId} selected=${selectedSession.sessionId} anchor=${topLevelSession.anchor}",
|
||||
)
|
||||
return AgentSessionLauncher.continueSessionInPlace(
|
||||
sourceTopLevelSession = topLevelSession,
|
||||
selectedSession = selectedSession,
|
||||
prompt = prompt,
|
||||
sessionController = sessionController,
|
||||
)
|
||||
}
|
||||
|
||||
private fun topLevelSession(snapshot: AgentSnapshot): AgentSessionDetails? {
|
||||
return resolveViewState(snapshot)?.topLevelSession
|
||||
}
|
||||
|
||||
private fun childSessions(snapshot: AgentSnapshot): List<AgentSessionDetails> {
|
||||
return resolveViewState(snapshot)?.childSessions.orEmpty()
|
||||
}
|
||||
|
||||
private fun selectedChildSession(snapshot: AgentSnapshot): AgentSessionDetails? {
|
||||
return resolveViewState(snapshot)?.selectedChildSession
|
||||
}
|
||||
|
||||
private fun currentActionableSession(snapshot: AgentSnapshot): AgentSessionDetails? {
|
||||
val viewState = resolveViewState(snapshot) ?: return null
|
||||
return viewState.selectedChildSession ?: viewState.topLevelSession
|
||||
}
|
||||
|
||||
private fun resolveViewState(snapshot: AgentSnapshot): SessionViewState? {
|
||||
val sessionsById = snapshot.sessions.associateBy(AgentSessionDetails::sessionId)
|
||||
val requestedSession = requestedSessionId?.let(sessionsById::get)
|
||||
val resolvedTopLevelSession = topLevelSessionId?.let(sessionsById::get)
|
||||
?: requestedSession?.let { session ->
|
||||
if (session.parentSessionId == null) {
|
||||
session
|
||||
} else {
|
||||
sessionsById[session.parentSessionId]
|
||||
}
|
||||
}
|
||||
?: snapshot.parentSession
|
||||
?: snapshot.selectedSession?.takeIf { it.parentSessionId == null }
|
||||
?: SessionUiFormatter.topLevelSessions(snapshot).firstOrNull()
|
||||
?: return null
|
||||
topLevelSessionId = resolvedTopLevelSession.sessionId
|
||||
requestedSessionId = resolvedTopLevelSession.sessionId
|
||||
val visibleChildSessions = snapshot.sessions
|
||||
.filter { session ->
|
||||
session.parentSessionId == resolvedTopLevelSession.sessionId &&
|
||||
!dismissedSessionStore.isDismissed(session.sessionId)
|
||||
}
|
||||
.sortedBy(AgentSessionDetails::sessionId)
|
||||
val requestedChildSession = requestedSession?.takeIf { session ->
|
||||
session.parentSessionId == resolvedTopLevelSession.sessionId &&
|
||||
!dismissedSessionStore.isDismissed(session.sessionId)
|
||||
}
|
||||
val resolvedSelectedChildSession = selectedChildSessionId?.let(sessionsById::get)?.takeIf { session ->
|
||||
session.parentSessionId == resolvedTopLevelSession.sessionId &&
|
||||
!dismissedSessionStore.isDismissed(session.sessionId)
|
||||
} ?: requestedChildSession
|
||||
selectedChildSessionId = resolvedSelectedChildSession?.sessionId
|
||||
return SessionViewState(
|
||||
topLevelSession = resolvedTopLevelSession,
|
||||
childSessions = visibleChildSessions,
|
||||
selectedChildSession = resolvedSelectedChildSession,
|
||||
)
|
||||
}
|
||||
|
||||
private fun canStartStandaloneHomeSession(viewState: SessionViewState): Boolean {
|
||||
val topLevelSession = viewState.topLevelSession
|
||||
return topLevelSession.anchor == AgentSessionInfo.ANCHOR_HOME &&
|
||||
topLevelSession.state == AgentSessionInfo.STATE_CREATED &&
|
||||
viewState.childSessions.isEmpty()
|
||||
}
|
||||
|
||||
private fun updateSessionUiLease(sessionId: String?) {
|
||||
if (leasedSessionId == sessionId) {
|
||||
return
|
||||
}
|
||||
leasedSessionId?.let { previous ->
|
||||
runCatching {
|
||||
sessionController.unregisterSessionUiLease(previous, sessionUiLeaseToken)
|
||||
}
|
||||
leasedSessionId = null
|
||||
}
|
||||
sessionId?.let { current ->
|
||||
val registered = runCatching {
|
||||
sessionController.registerSessionUiLease(current, sessionUiLeaseToken)
|
||||
}
|
||||
if (registered.isSuccess) {
|
||||
leasedSessionId = current
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTerminalState(state: Int): Boolean {
|
||||
return state == AgentSessionInfo.STATE_COMPLETED ||
|
||||
state == AgentSessionInfo.STATE_CANCELLED ||
|
||||
state == AgentSessionInfo.STATE_FAILED
|
||||
}
|
||||
|
||||
private fun showToast(message: String) {
|
||||
runOnUiThread {
|
||||
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
|
||||
}
|
||||
}
|
||||
|
||||
private fun dp(value: Int): Int {
|
||||
return (value * resources.displayMetrics.density).toInt()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import org.json.JSONObject
|
||||
|
||||
class SessionExecutionSettingsStore(context: Context) {
|
||||
companion object {
|
||||
private const val PREFS_NAME = "session_execution_settings"
|
||||
private const val KEY_MODEL = "model"
|
||||
private const val KEY_REASONING_EFFORT = "reasoningEffort"
|
||||
}
|
||||
|
||||
private val prefs = context.applicationContext.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun saveSettings(
|
||||
sessionId: String,
|
||||
settings: SessionExecutionSettings,
|
||||
) {
|
||||
prefs.edit()
|
||||
.putString(key(sessionId, KEY_MODEL), settings.model)
|
||||
.putString(key(sessionId, KEY_REASONING_EFFORT), settings.reasoningEffort)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun getSettings(sessionId: String): SessionExecutionSettings {
|
||||
return SessionExecutionSettings(
|
||||
model = prefs.getString(key(sessionId, KEY_MODEL), null),
|
||||
reasoningEffort = prefs.getString(key(sessionId, KEY_REASONING_EFFORT), null),
|
||||
)
|
||||
}
|
||||
|
||||
fun removeSettings(sessionId: String) {
|
||||
prefs.edit()
|
||||
.remove(key(sessionId, KEY_MODEL))
|
||||
.remove(key(sessionId, KEY_REASONING_EFFORT))
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun pruneSettings(activeSessionIds: Set<String>) {
|
||||
val keysToRemove = prefs.all.keys.filter { key ->
|
||||
val sessionId = key.substringBefore(':', missingDelimiterValue = "")
|
||||
sessionId.isNotBlank() && sessionId !in activeSessionIds
|
||||
}
|
||||
if (keysToRemove.isEmpty()) {
|
||||
return
|
||||
}
|
||||
prefs.edit().apply {
|
||||
keysToRemove.forEach(::remove)
|
||||
}.apply()
|
||||
}
|
||||
|
||||
fun toJson(sessionId: String): JSONObject {
|
||||
val settings = getSettings(sessionId)
|
||||
return JSONObject().apply {
|
||||
put("model", settings.model)
|
||||
put("reasoningEffort", settings.reasoningEffort)
|
||||
}
|
||||
}
|
||||
|
||||
private fun key(
|
||||
sessionId: String,
|
||||
suffix: String,
|
||||
): String {
|
||||
return "$sessionId:$suffix"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object SessionNotificationCoordinator {
|
||||
fun acknowledgeSessionTree(
|
||||
context: Context,
|
||||
sessionController: AgentSessionController,
|
||||
topLevelSessionId: String,
|
||||
sessionIds: Collection<String>,
|
||||
) {
|
||||
sessionIds.forEach { sessionId ->
|
||||
AgentQuestionNotifier.cancel(context, sessionId)
|
||||
}
|
||||
sessionController.acknowledgeSessionUi(topLevelSessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import java.io.IOException
|
||||
|
||||
enum class SessionFinalPresentationPolicy(
|
||||
val wireValue: String,
|
||||
val description: String,
|
||||
) {
|
||||
ATTACHED(
|
||||
wireValue = "ATTACHED",
|
||||
description = "Finish with the target attached to the main user-facing display/task stack.",
|
||||
),
|
||||
DETACHED_HIDDEN(
|
||||
wireValue = "DETACHED_HIDDEN",
|
||||
description = "Finish with the target still detached and hidden from view.",
|
||||
),
|
||||
DETACHED_SHOWN(
|
||||
wireValue = "DETACHED_SHOWN",
|
||||
description = "Finish with the target detached but visibly shown through the detached host.",
|
||||
),
|
||||
AGENT_CHOICE(
|
||||
wireValue = "AGENT_CHOICE",
|
||||
description = "The Agent does not require a specific final presentation state for this target.",
|
||||
),
|
||||
;
|
||||
|
||||
fun matches(actualPresentation: Int): Boolean {
|
||||
return when (this) {
|
||||
ATTACHED -> actualPresentation == AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
|
||||
DETACHED_HIDDEN -> {
|
||||
actualPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_HIDDEN
|
||||
}
|
||||
DETACHED_SHOWN -> {
|
||||
actualPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_SHOWN
|
||||
}
|
||||
AGENT_CHOICE -> true
|
||||
}
|
||||
}
|
||||
|
||||
fun requiresDetachedMode(): Boolean {
|
||||
return when (this) {
|
||||
DETACHED_HIDDEN, DETACHED_SHOWN -> true
|
||||
ATTACHED, AGENT_CHOICE -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun promptGuidance(): String {
|
||||
return when (this) {
|
||||
ATTACHED -> {
|
||||
"Before reporting success, ensure the target is ATTACHED to the primary user-facing display. Detached-only visibility is not sufficient."
|
||||
}
|
||||
DETACHED_HIDDEN -> {
|
||||
"Before reporting success, ensure the target remains DETACHED_HIDDEN. Do not attach it or leave it shown."
|
||||
}
|
||||
DETACHED_SHOWN -> {
|
||||
"Before reporting success, ensure the target remains DETACHED_SHOWN. It should stay detached but visibly shown through the detached host."
|
||||
}
|
||||
AGENT_CHOICE -> {
|
||||
"Choose the final target presentation state yourself and describe the final state accurately in your result."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun fromWireValue(value: String?): SessionFinalPresentationPolicy? {
|
||||
val normalized = value?.trim().orEmpty()
|
||||
if (normalized.isEmpty()) {
|
||||
return null
|
||||
}
|
||||
return entries.firstOrNull { it.wireValue.equals(normalized, ignoreCase = true) }
|
||||
}
|
||||
|
||||
fun requireFromWireValue(
|
||||
value: String?,
|
||||
fieldName: String,
|
||||
): SessionFinalPresentationPolicy {
|
||||
return fromWireValue(value)
|
||||
?: throw IOException("Unsupported $fieldName: ${value?.trim().orEmpty()}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
object AgentTargetPresentationValues {
|
||||
const val ATTACHED = AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
|
||||
const val DETACHED_HIDDEN = AgentSessionInfo.TARGET_PRESENTATION_DETACHED_HIDDEN
|
||||
const val DETACHED_SHOWN = AgentSessionInfo.TARGET_PRESENTATION_DETACHED_SHOWN
|
||||
}
|
||||
|
||||
fun targetPresentationToString(targetPresentation: Int): String {
|
||||
return when (targetPresentation) {
|
||||
AgentTargetPresentationValues.ATTACHED -> "ATTACHED"
|
||||
AgentTargetPresentationValues.DETACHED_HIDDEN -> "DETACHED_HIDDEN"
|
||||
AgentTargetPresentationValues.DETACHED_SHOWN -> "DETACHED_SHOWN"
|
||||
else -> targetPresentation.toString()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
class SessionPresentationPolicyStore(
|
||||
context: Context,
|
||||
) {
|
||||
companion object {
|
||||
private const val PREFS_NAME = "codex_session_presentation_policies"
|
||||
}
|
||||
|
||||
private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
|
||||
fun savePolicy(
|
||||
sessionId: String,
|
||||
policy: SessionFinalPresentationPolicy,
|
||||
) {
|
||||
prefs.edit().putString(sessionId, policy.wireValue).apply()
|
||||
}
|
||||
|
||||
fun getPolicy(sessionId: String): SessionFinalPresentationPolicy? {
|
||||
return SessionFinalPresentationPolicy.fromWireValue(
|
||||
prefs.getString(sessionId, null),
|
||||
)
|
||||
}
|
||||
|
||||
fun removePolicy(sessionId: String) {
|
||||
prefs.edit().remove(sessionId).apply()
|
||||
}
|
||||
|
||||
fun prunePolicies(activeSessionIds: Set<String>) {
|
||||
val staleSessionIds = prefs.all.keys - activeSessionIds
|
||||
if (staleSessionIds.isEmpty()) {
|
||||
return
|
||||
}
|
||||
prefs.edit().apply {
|
||||
staleSessionIds.forEach(::remove)
|
||||
}.apply()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.content.Context
|
||||
|
||||
object SessionUiFormatter {
|
||||
private const val MAX_LIST_DETAIL_CHARS = 96
|
||||
|
||||
fun topLevelSessions(snapshot: AgentSnapshot): List<AgentSessionDetails> {
|
||||
return snapshot.sessions.filter { it.parentSessionId == null }
|
||||
}
|
||||
|
||||
fun listRowTitle(
|
||||
context: Context,
|
||||
session: AgentSessionDetails,
|
||||
): String {
|
||||
return when (session.anchor) {
|
||||
AgentSessionInfo.ANCHOR_HOME -> AppLabelResolver.loadAppLabel(context, session.targetPackage)
|
||||
AgentSessionInfo.ANCHOR_AGENT -> "Agent Session"
|
||||
else -> session.targetPackage ?: session.sessionId
|
||||
}
|
||||
}
|
||||
|
||||
fun listRowSubtitle(
|
||||
context: Context,
|
||||
session: AgentSessionDetails,
|
||||
): String {
|
||||
val detail = summarizeListDetail(
|
||||
session.latestQuestion ?: session.latestResult ?: session.latestError ?: session.latestTrace,
|
||||
)
|
||||
return buildString {
|
||||
append(anchorLabel(session.anchor))
|
||||
append(" • ")
|
||||
append(session.stateLabel)
|
||||
append(" • ")
|
||||
append(session.targetPresentationLabel)
|
||||
detail?.let {
|
||||
append(" • ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun detailSummary(
|
||||
context: Context,
|
||||
session: AgentSessionDetails,
|
||||
parentSession: AgentSessionDetails?,
|
||||
): String {
|
||||
return buildString {
|
||||
append("Session: ${session.sessionId}\n")
|
||||
append("Anchor: ${anchorLabel(session.anchor)}\n")
|
||||
append("Target: ${AppLabelResolver.loadAppLabel(context, session.targetPackage)}")
|
||||
session.targetPackage?.let { append(" ($it)") }
|
||||
append("\nState: ${session.stateLabel}\n")
|
||||
append("Target presentation: ${session.targetPresentationLabel}\n")
|
||||
append("Target runtime: ${session.targetRuntimeLabel}\n")
|
||||
session.requiredFinalPresentationPolicy?.let { policy ->
|
||||
append("Required final presentation: ${policy.wireValue}\n")
|
||||
}
|
||||
parentSession?.takeIf { it.sessionId != session.sessionId }?.let {
|
||||
append("Parent: ${it.sessionId}\n")
|
||||
}
|
||||
val detail = session.latestQuestion ?: session.latestResult ?: session.latestError ?: session.latestTrace
|
||||
detail?.takeIf(String::isNotBlank)?.let {
|
||||
append("Latest: $it")
|
||||
}
|
||||
}.trimEnd()
|
||||
}
|
||||
|
||||
fun relatedSessionTitle(
|
||||
context: Context,
|
||||
session: AgentSessionDetails,
|
||||
): String {
|
||||
val targetLabel = AppLabelResolver.loadAppLabel(context, session.targetPackage)
|
||||
return buildString {
|
||||
append("Child")
|
||||
append(" • ")
|
||||
append(session.stateLabel)
|
||||
append(" • ")
|
||||
append(targetLabel)
|
||||
session.targetPackage?.let { append(" ($it)") }
|
||||
}
|
||||
}
|
||||
|
||||
fun relatedSessionSubtitle(session: AgentSessionDetails): String {
|
||||
val detail = summarizeListDetail(
|
||||
session.latestQuestion ?: session.latestResult ?: session.latestError ?: session.latestTrace,
|
||||
)
|
||||
return buildString {
|
||||
append("Tap to inspect")
|
||||
append(" • ")
|
||||
append(session.targetPresentationLabel)
|
||||
detail?.let {
|
||||
append(" • ")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun anchorLabel(anchor: Int): String {
|
||||
return when (anchor) {
|
||||
AgentSessionInfo.ANCHOR_HOME -> "HOME"
|
||||
AgentSessionInfo.ANCHOR_AGENT -> "AGENT"
|
||||
else -> anchor.toString()
|
||||
}
|
||||
}
|
||||
|
||||
private fun summarizeListDetail(detail: String?): String? {
|
||||
val trimmed = detail?.trim()?.takeIf(String::isNotEmpty) ?: return null
|
||||
return if (trimmed.length <= MAX_LIST_DETAIL_CHARS) {
|
||||
trimmed
|
||||
} else {
|
||||
trimmed.take(MAX_LIST_DETAIL_CHARS) + "…"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.view.View
|
||||
import android.widget.AdapterView
|
||||
|
||||
class SimpleItemSelectedListener(
|
||||
private val onItemSelected: () -> Unit,
|
||||
) : AdapterView.OnItemSelectedListener {
|
||||
override fun onItemSelected(
|
||||
parent: AdapterView<*>?,
|
||||
view: View?,
|
||||
position: Int,
|
||||
id: Long,
|
||||
) {
|
||||
onItemSelected()
|
||||
}
|
||||
|
||||
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import android.widget.ArrayAdapter
|
||||
import android.widget.TextView
|
||||
|
||||
class TopLevelSessionListAdapter(
|
||||
context: Context,
|
||||
) : ArrayAdapter<AgentSessionDetails>(context, android.R.layout.simple_list_item_2) {
|
||||
private val inflater = LayoutInflater.from(context)
|
||||
|
||||
fun replaceItems(items: List<AgentSessionDetails>) {
|
||||
clear()
|
||||
addAll(items)
|
||||
notifyDataSetChanged()
|
||||
}
|
||||
|
||||
override fun getView(
|
||||
position: Int,
|
||||
convertView: View?,
|
||||
parent: ViewGroup,
|
||||
): View {
|
||||
val view = convertView ?: inflater.inflate(android.R.layout.simple_list_item_2, parent, false)
|
||||
val item = getItem(position)
|
||||
val titleView = view.findViewById<TextView>(android.R.id.text1)
|
||||
val subtitleView = view.findViewById<TextView>(android.R.id.text2)
|
||||
if (item == null) {
|
||||
titleView.text = "Unknown session"
|
||||
subtitleView.text = ""
|
||||
return view
|
||||
}
|
||||
titleView.text = SessionUiFormatter.listRowTitle(context, item)
|
||||
subtitleView.text = SessionUiFormatter.listRowSubtitle(context, item)
|
||||
return view
|
||||
}
|
||||
}
|
||||
9
android/app/src/main/res/drawable/ic_stat_codex.xml
Normal file
@@ -0,0 +1,9 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="#FFFFFF"
|
||||
android:pathData="M4,4h16v16h-16z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFF4F6F8" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#FFD5D9DD" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFE3F2FD" />
|
||||
<stroke
|
||||
android:width="2dp"
|
||||
android:color="#FF1976D2" />
|
||||
<corners android:radius="12dp" />
|
||||
</shape>
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:shape="rectangle">
|
||||
<solid android:color="#FFFAFBFC" />
|
||||
<stroke
|
||||
android:width="1dp"
|
||||
android:color="#FFD5D9DD" />
|
||||
<corners android:radius="14dp" />
|
||||
</shape>
|
||||
118
android/app/src/main/res/layout/activity_create_session.xml
Normal file
@@ -0,0 +1,118 @@
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/create_session_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="New Session"
|
||||
android:textAppearance="?android:attr/textAppearanceLarge"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/create_session_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Loading session…"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Target app" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/create_session_target_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="4dp"
|
||||
android:text="No target app selected. This will start an Agent-anchored session." />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/create_session_pick_target_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Choose Target App" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/create_session_clear_target_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Clear" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Prompt" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/create_session_prompt"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top|start"
|
||||
android:inputType="textMultiLine|textCapSentences"
|
||||
android:minLines="4" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Model" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/create_session_model_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Thinking depth" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/create_session_effort_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:gravity="end"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/create_session_cancel_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Cancel" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/create_session_start_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:text="Start" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
78
android/app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,78 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<Button
|
||||
android:id="@+id/create_session_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Create New Session" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="Agent Authentication"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/auth_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Agent auth: probing..." />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/agent_runtime_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Agent runtime: probing..." />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/agent_status"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:text="Agent framework: probing..." />
|
||||
|
||||
<Button
|
||||
android:id="@+id/auth_action"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="Start sign-in" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/refresh_sessions_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Refresh Sessions" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="Sessions"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ListView
|
||||
android:id="@+id/session_list"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_weight="1"
|
||||
android:dividerHeight="1dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_list_empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_horizontal"
|
||||
android:paddingTop="12dp"
|
||||
android:text="No sessions yet"
|
||||
android:visibility="gone" />
|
||||
</LinearLayout>
|
||||
228
android/app/src/main/res/layout/activity_session_detail.xml
Normal file
@@ -0,0 +1,228 @@
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:fillViewport="true">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Top-Level Session"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Loading session..." />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_detail_cancel_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Cancel Session" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_detail_delete_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Delete Session" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_top_level_action_note"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_children_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="Child Sessions (Tap To Inspect)"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/session_detail_children_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="vertical" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_children_empty"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="No child sessions"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_child_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="Selected Child Session"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/session_detail_child_summary_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:maxHeight="220dp"
|
||||
android:background="@drawable/session_detail_panel_background"
|
||||
android:fadeScrollbars="false"
|
||||
android:overScrollMode="ifContentScrolls"
|
||||
android:scrollbarStyle="insideInset"
|
||||
android:scrollbars="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_child_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="14dp"
|
||||
android:text="Select a child session to inspect it."
|
||||
android:textIsSelectable="true" />
|
||||
</ScrollView>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/session_detail_child_actions"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_detail_child_cancel_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Cancel Child" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_detail_child_delete_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Delete Child" />
|
||||
</LinearLayout>
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_detail_attach_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Attach Target" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_question_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Question"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_question"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:visibility="gone" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/session_detail_answer_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:hint="Answer for the waiting Genie session"
|
||||
android:inputType="textCapSentences"
|
||||
android:visibility="gone" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_detail_answer_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Answer Question"
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_follow_up_label"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="Continue Session"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/session_detail_follow_up_prompt"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top|start"
|
||||
android:hint="Ask Codex to continue from here."
|
||||
android:inputType="textMultiLine|textCapSentences"
|
||||
android:minLines="3" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_detail_follow_up_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:text="Send Follow-up Prompt" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_follow_up_note"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="In-place continuation is currently available only for direct Agent sessions. App-scoped HOME sessions must start a new session."
|
||||
android:visibility="gone" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:text="Timeline"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ScrollView
|
||||
android:id="@+id/session_detail_timeline_container"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:maxHeight="280dp"
|
||||
android:background="@drawable/session_detail_panel_background"
|
||||
android:fadeScrollbars="false"
|
||||
android:overScrollMode="ifContentScrolls"
|
||||
android:scrollbarStyle="insideInset"
|
||||
android:scrollbars="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_detail_timeline"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:padding="14dp"
|
||||
android:text="No framework events yet."
|
||||
android:textIsSelectable="true"
|
||||
android:typeface="monospace" />
|
||||
</ScrollView>
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
80
android/app/src/main/res/layout/dialog_create_session.xml
Normal file
@@ -0,0 +1,80 @@
|
||||
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="16dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Target app" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/create_session_target_summary"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingTop="4dp"
|
||||
android:text="No target app selected. This will start an Agent-anchored session." />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/create_session_pick_target_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Choose Target App" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/create_session_clear_target_button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:text="Clear" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Prompt" />
|
||||
|
||||
<EditText
|
||||
android:id="@+id/create_session_prompt"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="top|start"
|
||||
android:inputType="textMultiLine|textCapSentences"
|
||||
android:minLines="4" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Model" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/create_session_model_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="Thinking depth" />
|
||||
|
||||
<Spinner
|
||||
android:id="@+id/create_session_effort_spinner"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content" />
|
||||
</LinearLayout>
|
||||
</ScrollView>
|
||||
37
android/app/src/main/res/layout/list_item_installed_app.xml
Normal file
@@ -0,0 +1,37 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:minHeight="56dp"
|
||||
android:orientation="horizontal"
|
||||
android:paddingHorizontal="20dp"
|
||||
android:paddingVertical="12dp">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/installed_app_icon"
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="40dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/installed_app_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textAppearance="?android:attr/textAppearanceMedium" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/installed_app_subtitle"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="2dp"
|
||||
android:textAppearance="?android:attr/textAppearanceSmall" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
@@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<background android:drawable="@color/ic_launcher_background" />
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground" />
|
||||
</adaptive-icon>
|
||||
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
android/app/src/main/res/mipmap-hdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 2.3 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 4.0 KiB |
BIN
android/app/src/main/res/mipmap-mdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
android/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
|
After Width: | Height: | Size: 21 KiB |
BIN
android/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 6.0 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
|
After Width: | Height: | Size: 32 KiB |
BIN
android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png
Normal file
|
After Width: | Height: | Size: 9.2 KiB |
3
android/app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#FFFFFFFF</color>
|
||||
</resources>
|
||||
3
android/app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Codex Agent</string>
|
||||
</resources>
|
||||
7
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<style name="CodexCreateSessionTheme" parent="@android:style/Theme.DeviceDefault.Light.Dialog.NoActionBar">
|
||||
<item name="android:windowCloseOnTouchOutside">true</item>
|
||||
<item name="android:windowMinWidthMajor">90%</item>
|
||||
<item name="android:windowMinWidthMinor">90%</item>
|
||||
</style>
|
||||
</resources>
|
||||
@@ -0,0 +1,127 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AgentFrameworkToolBridgeTest {
|
||||
@Test
|
||||
fun parseStartDirectSessionArgumentsExtractsTargetsReasonAndDetachedMode() {
|
||||
val request = AgentFrameworkToolBridge.parseStartDirectSessionArguments(
|
||||
arguments = JSONObject(
|
||||
"""
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"packageName": "com.android.deskclock",
|
||||
"objective": "Start the requested timer in Clock.",
|
||||
"finalPresentationPolicy": "ATTACHED"
|
||||
}
|
||||
],
|
||||
"reason": "Clock is the installed timer app.",
|
||||
"allowDetachedMode": false
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
userObjective = "Start a 5-minute timer.",
|
||||
isEligibleTargetPackage = linkedSetOf("com.android.deskclock", "com.android.contacts")::contains,
|
||||
)
|
||||
|
||||
assertEquals("Start a 5-minute timer.", request.plan.originalObjective)
|
||||
assertEquals("Clock is the installed timer app.", request.plan.rationale)
|
||||
assertEquals(false, request.plan.usedOverride)
|
||||
assertEquals(false, request.allowDetachedMode)
|
||||
assertEquals(1, request.plan.targets.size)
|
||||
assertEquals("com.android.deskclock", request.plan.targets.single().packageName)
|
||||
assertEquals("Start the requested timer in Clock.", request.plan.targets.single().objective)
|
||||
assertEquals(
|
||||
SessionFinalPresentationPolicy.ATTACHED,
|
||||
request.plan.targets.single().finalPresentationPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseStartDirectSessionArgumentsFallsBackToUserObjectiveWhenDelegatedObjectiveMissing() {
|
||||
val request = AgentFrameworkToolBridge.parseStartDirectSessionArguments(
|
||||
arguments = JSONObject(
|
||||
"""
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"packageName": "com.android.deskclock"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
userObjective = "Start a 5-minute timer.",
|
||||
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
|
||||
)
|
||||
|
||||
assertEquals("Start a 5-minute timer.", request.plan.targets.single().objective)
|
||||
assertEquals(
|
||||
SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
request.plan.targets.single().finalPresentationPolicy,
|
||||
)
|
||||
assertEquals(true, request.allowDetachedMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseStartDirectSessionArgumentsRejectsUnknownPackages() {
|
||||
val err = runCatching {
|
||||
AgentFrameworkToolBridge.parseStartDirectSessionArguments(
|
||||
arguments = JSONObject(
|
||||
"""
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"packageName": "com.unknown.app",
|
||||
"objective": "Do the task.",
|
||||
"finalPresentationPolicy": "AGENT_CHOICE"
|
||||
}
|
||||
]
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
userObjective = "Start a timer.",
|
||||
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertTrue(err is java.io.IOException)
|
||||
assertEquals(
|
||||
"Framework session tool selected missing or disallowed package(s): com.unknown.app",
|
||||
err?.message,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseStartDirectSessionArgumentsRejectsDetachedPresentationWithoutDetachedMode() {
|
||||
val err = runCatching {
|
||||
AgentFrameworkToolBridge.parseStartDirectSessionArguments(
|
||||
arguments = JSONObject(
|
||||
"""
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"packageName": "com.android.deskclock",
|
||||
"finalPresentationPolicy": "DETACHED_SHOWN"
|
||||
}
|
||||
],
|
||||
"allowDetachedMode": false
|
||||
}
|
||||
""".trimIndent(),
|
||||
),
|
||||
userObjective = "Keep Clock visible in detached mode.",
|
||||
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertTrue(err is java.io.IOException)
|
||||
assertEquals(
|
||||
"Framework session tool selected detached final presentation without allowDetachedMode: com.android.deskclock",
|
||||
err?.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
|
||||
class AgentParentSessionAggregatorTest {
|
||||
@Test
|
||||
fun rollupRequestsAttachWhenAttachedPresentationIsRequired() {
|
||||
val rollup = AgentParentSessionAggregator.rollup(
|
||||
listOf(
|
||||
ParentSessionChildSummary(
|
||||
sessionId = "child-1",
|
||||
targetPackage = "com.android.deskclock",
|
||||
state = AgentSessionStateValues.COMPLETED,
|
||||
targetPresentation = AgentTargetPresentationValues.DETACHED_SHOWN,
|
||||
requiredFinalPresentationPolicy = SessionFinalPresentationPolicy.ATTACHED,
|
||||
latestResult = "Started the stopwatch.",
|
||||
latestError = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(AgentSessionStateValues.RUNNING, rollup.state)
|
||||
assertEquals(listOf("child-1"), rollup.sessionsToAttach)
|
||||
assertEquals(null, rollup.resultMessage)
|
||||
assertEquals(null, rollup.errorMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rollupFailsWhenDetachedShownIsRequiredButTargetIsHidden() {
|
||||
val rollup = AgentParentSessionAggregator.rollup(
|
||||
listOf(
|
||||
ParentSessionChildSummary(
|
||||
sessionId = "child-1",
|
||||
targetPackage = "com.android.deskclock",
|
||||
state = AgentSessionStateValues.COMPLETED,
|
||||
targetPresentation = AgentTargetPresentationValues.DETACHED_HIDDEN,
|
||||
requiredFinalPresentationPolicy = SessionFinalPresentationPolicy.DETACHED_SHOWN,
|
||||
latestResult = "Started the stopwatch.",
|
||||
latestError = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(AgentSessionStateValues.FAILED, rollup.state)
|
||||
assertEquals(emptyList<String>(), rollup.sessionsToAttach)
|
||||
assertEquals(
|
||||
"Delegated session completed without the required final presentation; com.android.deskclock: required DETACHED_SHOWN, actual DETACHED_HIDDEN",
|
||||
rollup.errorMessage,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rollupCompletesWhenRequiredPresentationMatches() {
|
||||
val rollup = AgentParentSessionAggregator.rollup(
|
||||
listOf(
|
||||
ParentSessionChildSummary(
|
||||
sessionId = "child-1",
|
||||
targetPackage = "com.android.deskclock",
|
||||
state = AgentSessionStateValues.COMPLETED,
|
||||
targetPresentation = AgentTargetPresentationValues.ATTACHED,
|
||||
requiredFinalPresentationPolicy = SessionFinalPresentationPolicy.ATTACHED,
|
||||
latestResult = "Started the stopwatch.",
|
||||
latestError = null,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
assertEquals(AgentSessionStateValues.COMPLETED, rollup.state)
|
||||
assertEquals(emptyList<String>(), rollup.sessionsToAttach)
|
||||
assertEquals(
|
||||
"Completed delegated session; com.android.deskclock: Started the stopwatch.",
|
||||
rollup.resultMessage,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import java.io.File
|
||||
import java.net.SocketException
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class AgentResponsesProxyTest {
|
||||
@Test
|
||||
fun buildResponsesUrlUsesChatgptDefaultForProviderDefault() {
|
||||
assertEquals(
|
||||
"https://chatgpt.com/backend-api/codex/responses",
|
||||
AgentResponsesProxy.buildResponsesUrl(
|
||||
upstreamBaseUrl = "provider-default",
|
||||
authMode = "chatgpt",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildResponsesUrlAppendsResponsesToConfiguredBase() {
|
||||
assertEquals(
|
||||
"https://api.openai.com/v1/responses",
|
||||
AgentResponsesProxy.buildResponsesUrl(
|
||||
upstreamBaseUrl = "https://api.openai.com/v1/",
|
||||
authMode = "apiKey",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildResponsesBaseUrlKeepsConfiguredBaseWithoutTrailingSlash() {
|
||||
assertEquals(
|
||||
"https://example.invalid/custom",
|
||||
AgentResponsesProxy.buildResponsesBaseUrl(
|
||||
upstreamBaseUrl = "https://example.invalid/custom/",
|
||||
authMode = "chatgpt",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildResponsesBaseUrlTreatsNullStringAsProviderDefault() {
|
||||
assertEquals(
|
||||
"https://chatgpt.com/backend-api/codex",
|
||||
AgentResponsesProxy.buildResponsesBaseUrl(
|
||||
upstreamBaseUrl = "null",
|
||||
authMode = "chatgpt",
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildFrameworkTransportTargetSplitsChatgptBaseIntoOriginAndPath() {
|
||||
assertEquals(
|
||||
AgentResponsesProxy.FrameworkTransportTarget(
|
||||
baseUrl = "https://chatgpt.com",
|
||||
responsesPath = "/backend-api/codex/responses",
|
||||
),
|
||||
AgentResponsesProxy.buildFrameworkTransportTarget("https://chatgpt.com/backend-api/codex"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun buildFrameworkTransportTargetSplitsOpenAiBaseIntoOriginAndPath() {
|
||||
assertEquals(
|
||||
AgentResponsesProxy.FrameworkTransportTarget(
|
||||
baseUrl = "https://api.openai.com",
|
||||
responsesPath = "/v1/responses",
|
||||
),
|
||||
AgentResponsesProxy.buildFrameworkTransportTarget("https://api.openai.com/v1/"),
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadAuthSnapshotReadsChatgptTokens() {
|
||||
val authFile = writeTempAuthJson(
|
||||
"""
|
||||
{
|
||||
"auth_mode": "chatgpt",
|
||||
"OPENAI_API_KEY": null,
|
||||
"tokens": {
|
||||
"id_token": "header.payload.signature",
|
||||
"access_token": "access-token",
|
||||
"refresh_token": "refresh-token",
|
||||
"account_id": "acct-123"
|
||||
},
|
||||
"last_refresh": "2026-03-19T00:00:00Z"
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val snapshot = AgentResponsesProxy.loadAuthSnapshot(authFile)
|
||||
|
||||
assertEquals("chatgpt", snapshot.authMode)
|
||||
assertEquals("access-token", snapshot.bearerToken)
|
||||
assertEquals("acct-123", snapshot.accountId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun loadAuthSnapshotFallsBackToApiKeyModeWhenAuthModeIsMissing() {
|
||||
val authFile = writeTempAuthJson(
|
||||
"""
|
||||
{
|
||||
"OPENAI_API_KEY": "sk-test-key",
|
||||
"tokens": null,
|
||||
"last_refresh": null
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val snapshot = AgentResponsesProxy.loadAuthSnapshot(authFile)
|
||||
|
||||
assertEquals("apiKey", snapshot.authMode)
|
||||
assertEquals("sk-test-key", snapshot.bearerToken)
|
||||
assertNull(snapshot.accountId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun describeRequestFailureIncludesPhaseUrlAndCause() {
|
||||
val message = AgentResponsesProxy.describeRequestFailure(
|
||||
phase = "read response body",
|
||||
upstreamUrl = "https://chatgpt.com/backend-api/codex/responses",
|
||||
err = SocketException("Software caused connection abort"),
|
||||
)
|
||||
|
||||
assertEquals(
|
||||
"Responses proxy failed during read response body for https://chatgpt.com/backend-api/codex/responses: SocketException: Software caused connection abort",
|
||||
message,
|
||||
)
|
||||
}
|
||||
|
||||
private fun writeTempAuthJson(contents: String): File {
|
||||
return File.createTempFile("agent-auth", ".json").apply {
|
||||
writeText(contents)
|
||||
deleteOnExit()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AgentTaskPlannerTest {
|
||||
@Test
|
||||
fun parsePlannerResponseExtractsStructuredPlan() {
|
||||
val request = AgentTaskPlanner.parsePlannerResponse(
|
||||
responseText =
|
||||
"""
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"packageName": "com.android.deskclock",
|
||||
"objective": "Start the requested timer in Clock.",
|
||||
"finalPresentationPolicy": "ATTACHED"
|
||||
}
|
||||
],
|
||||
"reason": "DeskClock is the installed timer handler.",
|
||||
"allowDetachedMode": true
|
||||
}
|
||||
""".trimIndent(),
|
||||
userObjective = "Start a 5-minute timer.",
|
||||
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
|
||||
)
|
||||
|
||||
assertEquals("DeskClock is the installed timer handler.", request.plan.rationale)
|
||||
assertEquals(true, request.allowDetachedMode)
|
||||
assertEquals(1, request.plan.targets.size)
|
||||
assertEquals("com.android.deskclock", request.plan.targets.single().packageName)
|
||||
assertEquals("Start the requested timer in Clock.", request.plan.targets.single().objective)
|
||||
assertEquals(
|
||||
SessionFinalPresentationPolicy.ATTACHED,
|
||||
request.plan.targets.single().finalPresentationPolicy,
|
||||
)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsePlannerResponseAcceptsMarkdownFences() {
|
||||
val request = AgentTaskPlanner.parsePlannerResponse(
|
||||
responseText =
|
||||
"""
|
||||
```json
|
||||
{
|
||||
"targets": [
|
||||
{
|
||||
"packageName": "com.android.deskclock"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
""".trimIndent(),
|
||||
userObjective = "Start a 5-minute timer.",
|
||||
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
|
||||
)
|
||||
|
||||
assertEquals("Start a 5-minute timer.", request.plan.targets.single().objective)
|
||||
assertEquals(
|
||||
SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
request.plan.targets.single().finalPresentationPolicy,
|
||||
)
|
||||
assertEquals(true, request.allowDetachedMode)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parsePlannerResponseRejectsMissingJson() {
|
||||
val err = runCatching {
|
||||
AgentTaskPlanner.parsePlannerResponse(
|
||||
responseText = "DeskClock seems right.",
|
||||
userObjective = "Start a timer.",
|
||||
isEligibleTargetPackage = linkedSetOf("com.android.deskclock")::contains,
|
||||
)
|
||||
}.exceptionOrNull()
|
||||
|
||||
assertTrue(err is java.io.IOException)
|
||||
assertEquals("Planner did not return a valid JSON object", err?.message)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class AgentUserInputPrompterTest {
|
||||
@Test
|
||||
fun buildQuestionAnswersMapsSplitAnswersByQuestionId() {
|
||||
val questions = JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("id", "duration")
|
||||
.put("question", "How long should the timer last?"),
|
||||
)
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("id", "confirm")
|
||||
.put("question", "Should I start it now?"),
|
||||
)
|
||||
|
||||
val answers = AgentUserInputPrompter.buildQuestionAnswers(
|
||||
questions = questions,
|
||||
answer = "5 minutes\n\nYes",
|
||||
)
|
||||
|
||||
assertEquals("5 minutes", answers.getJSONObject("duration").getJSONArray("answers").getString(0))
|
||||
assertEquals("Yes", answers.getJSONObject("confirm").getJSONArray("answers").getString(0))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun renderQuestionsMentionsBlankLineSeparatorForMultipleQuestions() {
|
||||
val questions = JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("id", "duration")
|
||||
.put("question", "How long should the timer last?"),
|
||||
)
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("id", "confirm")
|
||||
.put("question", "Should I start it now?"),
|
||||
)
|
||||
|
||||
val rendered = AgentUserInputPrompter.renderQuestions(questions)
|
||||
|
||||
assertTrue(rendered.contains("How long should the timer last?"))
|
||||
assertTrue(rendered.contains("Should I start it now?"))
|
||||
assertTrue(rendered.contains("Reply with one answer per question"))
|
||||
}
|
||||
}
|
||||
61
android/bridge/build.gradle.kts
Normal file
@@ -0,0 +1,61 @@
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.tasks.Sync
|
||||
|
||||
plugins {
|
||||
id("com.android.library")
|
||||
}
|
||||
|
||||
val minAndroidJavaVersion = 17
|
||||
val maxAndroidJavaVersion = 21
|
||||
val hostJavaMajorVersion = JavaVersion.current().majorVersion.toIntOrNull()
|
||||
?: throw GradleException("Unable to determine Java version from ${JavaVersion.current()}.")
|
||||
if (hostJavaMajorVersion < minAndroidJavaVersion) {
|
||||
throw GradleException(
|
||||
"Android bridge build requires Java ${minAndroidJavaVersion}+ (tested through Java ${maxAndroidJavaVersion}). Found Java ${hostJavaMajorVersion}."
|
||||
)
|
||||
}
|
||||
val androidJavaTargetVersion = hostJavaMajorVersion.coerceAtMost(maxAndroidJavaVersion)
|
||||
val androidJavaVersion = JavaVersion.toVersion(androidJavaTargetVersion)
|
||||
val agentPlatformStubSdkZip = providers
|
||||
.gradleProperty("agentPlatformStubSdkZip")
|
||||
.orElse(providers.environmentVariable("ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP"))
|
||||
val extractedAgentPlatformJar = layout.buildDirectory.file(
|
||||
"generated/agent-platform/android-agent-platform-stub-sdk.jar"
|
||||
)
|
||||
|
||||
android {
|
||||
namespace = "com.openai.codex.bridge"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
minSdk = 26
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = androidJavaVersion
|
||||
targetCompatibility = androidJavaVersion
|
||||
}
|
||||
}
|
||||
|
||||
val extractAgentPlatformStubSdk = tasks.register<Sync>("extractAgentPlatformStubSdk") {
|
||||
val sdkZip = agentPlatformStubSdkZip.orNull
|
||||
?: throw GradleException(
|
||||
"Set ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP or -PagentPlatformStubSdkZip to the Android Agent Platform stub SDK zip."
|
||||
)
|
||||
val outputDir = extractedAgentPlatformJar.get().asFile.parentFile
|
||||
from(zipTree(sdkZip)) {
|
||||
include("payloads/compile_only/android-agent-platform-stub-sdk.jar")
|
||||
eachFile { path = name }
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
into(outputDir)
|
||||
}
|
||||
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn(extractAgentPlatformStubSdk)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
compileOnly(files(extractedAgentPlatformJar))
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
50
android/bridge/src/main/assets/AGENTS.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Android Agent/Genie Runtime Notes
|
||||
|
||||
This Codex runtime is operating on an Android device through the Agent Platform.
|
||||
|
||||
## If you are the Agent
|
||||
|
||||
- The user interacts only with the Agent.
|
||||
- Plan the work, choose the target package or packages, and start one Genie session per target app that needs to be driven.
|
||||
- Delegate objectives, not tool choices. Tell each Genie what outcome it must achieve in its paired app and let the Genie choose its own tools.
|
||||
- Answer Genie questions directly when you can. If the answer depends on user intent or missing constraints, ask the user.
|
||||
- Keep auth, upstream access, and any internet-facing model traffic on the Agent side.
|
||||
|
||||
## If you are a Genie
|
||||
|
||||
- You are paired with exactly one target app sandbox for this session.
|
||||
- Solve the delegated objective inside that sandbox by using the normal Codex tool path and the Android tools that are available on-device.
|
||||
- Ask the Agent a concise free-form question only when you are blocked on missing intent, missing constraints, or a framework-owned action.
|
||||
- Do not assume you can reach the internet directly. Live session model traffic is framework-owned, and auth material originates from the Agent.
|
||||
- Do not rely on direct cross-app `bindService(...)` or raw local sockets to reach the Agent. Use the framework-managed session bridge.
|
||||
|
||||
## Shell and device tooling
|
||||
|
||||
- Prefer standard Android shell tools first: `cmd`, `am`, `pm`, `input`, `uiautomator`, `dumpsys`, `wm`, `settings`, `content`, `logcat`.
|
||||
- Do not assume desktop/Linux extras such as `python3`, GNU `date -d`, or other non-stock userland tools are present.
|
||||
- When a command affects app launch or user-visible state, prefer an explicit `--user 0` when the tool supports it.
|
||||
- Keep temporary artifacts in app-private storage such as the current app `files/` or `cache/` directories, or under `$CODEX_HOME`. Do not rely on shared storage.
|
||||
|
||||
## UI inspection and files
|
||||
|
||||
- In self-target Genie mode, prefer `uiautomator dump /proc/self/fd/1` or `uiautomator dump /dev/stdout` when stdout capture is acceptable.
|
||||
- Plain `uiautomator dump` writes to the app-private dump directory.
|
||||
- Explicit shared-storage targets such as `/sdcard/...` are redirected back into app-private storage in self-target mode.
|
||||
- Do not assume `/sdcard` or `/data/local/tmp` are readable or writable from the paired app sandbox.
|
||||
|
||||
## Presentation semantics
|
||||
|
||||
- Detached launch, shown-detached, and attached are different states.
|
||||
- `targetDetached=true` means the target is still detached even if it is visible in a detached or mirrored presentation.
|
||||
- If the framework launched the target detached for you, treat that launch as authoritative. Do not relaunch the target package with plain shell launchers such as `am start`, `cmd activity start-activity`, or `monkey -p`; use framework target controls plus UI inspection/input instead.
|
||||
- If the detached target disappears or the framework reports a missing detached target, use the framework recovery primitive first (`android_target_ensure_hidden`) instead of ordinary app launch.
|
||||
- If the delegated objective specifies a required final target presentation such as `ATTACHED`, `DETACHED_HIDDEN`, or `DETACHED_SHOWN`, treat that as a hard completion requirement and do not claim success until the framework session matches it.
|
||||
- If the task says the app should be visible to the user, do not claim success until the target is attached unless the task explicitly allows detached presentation.
|
||||
- If the user asks to show an activity on the screen, the Genie must explicitly make its display visible. Launching hidden or leaving the target detached is not enough.
|
||||
- Treat framework session state as the source of truth for presentation state.
|
||||
- If the detached target disappears or the detached display looks empty, do not guess with ordinary relaunch commands. Use framework target controls first; if they do not restore a usable target, report the framework-state problem to the Agent.
|
||||
|
||||
## Working style
|
||||
|
||||
- Prefer solving tasks with normal shell/tool use before reverse-engineering APK contents.
|
||||
- When you need to ask a question, make it specific and short so the Agent can either answer directly or escalate it to the user.
|
||||
@@ -0,0 +1,473 @@
|
||||
package com.openai.codex.bridge
|
||||
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.app.agent.GenieService
|
||||
import android.window.ScreenCapture
|
||||
import java.lang.reflect.Field
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Modifier
|
||||
|
||||
object DetachedTargetCompat {
|
||||
private const val METHOD_GET_TARGET_RUNTIME = "getTargetRuntime"
|
||||
private const val METHOD_ENSURE_DETACHED_TARGET_HIDDEN = "ensureDetachedTargetHidden"
|
||||
private const val METHOD_SHOW_DETACHED_TARGET = "showDetachedTarget"
|
||||
private const val METHOD_HIDE_DETACHED_TARGET = "hideDetachedTarget"
|
||||
private const val METHOD_ATTACH_DETACHED_TARGET = "attachDetachedTarget"
|
||||
private const val METHOD_CLOSE_DETACHED_TARGET = "closeDetachedTarget"
|
||||
private const val METHOD_CAPTURE_DETACHED_TARGET_FRAME_RESULT = "captureDetachedTargetFrameResult"
|
||||
private const val METHOD_GET_STATUS = "getStatus"
|
||||
private const val METHOD_GET_DETACHED_DISPLAY_ID = "getDetachedDisplayId"
|
||||
private const val METHOD_GET_MESSAGE = "getMessage"
|
||||
|
||||
private const val TARGET_RUNTIME_NONE_LABEL = "TARGET_RUNTIME_NONE"
|
||||
private const val TARGET_RUNTIME_ATTACHED_LABEL = "TARGET_RUNTIME_ATTACHED"
|
||||
private const val TARGET_RUNTIME_DETACHED_LAUNCHING_LABEL = "TARGET_RUNTIME_DETACHED_LAUNCHING"
|
||||
private const val TARGET_RUNTIME_DETACHED_HIDDEN_LABEL = "TARGET_RUNTIME_DETACHED_HIDDEN"
|
||||
private const val TARGET_RUNTIME_DETACHED_SHOWN_LABEL = "TARGET_RUNTIME_DETACHED_SHOWN"
|
||||
private const val TARGET_RUNTIME_MISSING_LABEL = "TARGET_RUNTIME_MISSING"
|
||||
|
||||
private const val STATUS_OK_LABEL = "STATUS_OK"
|
||||
private const val STATUS_NO_DETACHED_DISPLAY_LABEL = "STATUS_NO_DETACHED_DISPLAY"
|
||||
private const val STATUS_NO_TARGET_TASK_LABEL = "STATUS_NO_TARGET_TASK"
|
||||
private const val STATUS_LAUNCH_FAILED_LABEL = "STATUS_LAUNCH_FAILED"
|
||||
private const val STATUS_INTERNAL_ERROR_LABEL = "STATUS_INTERNAL_ERROR"
|
||||
private const val STATUS_CAPTURE_FAILED_LABEL = "STATUS_CAPTURE_FAILED"
|
||||
|
||||
data class DetachedTargetState(
|
||||
val value: Int?,
|
||||
val label: String,
|
||||
) {
|
||||
fun isMissing(): Boolean = label == TARGET_RUNTIME_MISSING_LABEL
|
||||
}
|
||||
|
||||
data class DetachedTargetControlResult(
|
||||
val status: Int?,
|
||||
val statusLabel: String,
|
||||
val targetRuntime: DetachedTargetState,
|
||||
val detachedDisplayId: Int?,
|
||||
val message: String?,
|
||||
) {
|
||||
fun isOk(): Boolean = statusLabel == STATUS_OK_LABEL
|
||||
|
||||
fun needsRecovery(): Boolean {
|
||||
return statusLabel == STATUS_NO_DETACHED_DISPLAY_LABEL ||
|
||||
statusLabel == STATUS_NO_TARGET_TASK_LABEL ||
|
||||
targetRuntime.isMissing()
|
||||
}
|
||||
|
||||
fun summary(action: String): String {
|
||||
return buildString {
|
||||
append("Detached target ")
|
||||
append(action)
|
||||
append(" -> ")
|
||||
append(statusLabel)
|
||||
append(" (runtime=")
|
||||
append(targetRuntime.label)
|
||||
detachedDisplayId?.let { displayId ->
|
||||
append(", display=")
|
||||
append(displayId)
|
||||
}
|
||||
append(")")
|
||||
message?.takeIf(String::isNotBlank)?.let { detail ->
|
||||
append(": ")
|
||||
append(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class DetachedTargetCaptureResult(
|
||||
val status: Int?,
|
||||
val statusLabel: String,
|
||||
val targetRuntime: DetachedTargetState,
|
||||
val detachedDisplayId: Int?,
|
||||
val message: String?,
|
||||
val captureResult: ScreenCapture.ScreenCaptureResult?,
|
||||
) {
|
||||
fun isOk(): Boolean = statusLabel == STATUS_OK_LABEL && captureResult != null
|
||||
|
||||
fun needsRecovery(): Boolean {
|
||||
return statusLabel == STATUS_NO_DETACHED_DISPLAY_LABEL ||
|
||||
statusLabel == STATUS_NO_TARGET_TASK_LABEL ||
|
||||
targetRuntime.isMissing()
|
||||
}
|
||||
|
||||
fun summary(): String {
|
||||
return buildString {
|
||||
append("Detached target capture -> ")
|
||||
append(statusLabel)
|
||||
append(" (runtime=")
|
||||
append(targetRuntime.label)
|
||||
detachedDisplayId?.let { displayId ->
|
||||
append(", display=")
|
||||
append(displayId)
|
||||
}
|
||||
append(")")
|
||||
message?.takeIf(String::isNotBlank)?.let { detail ->
|
||||
append(": ")
|
||||
append(detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val targetRuntimeLabels: Map<Int, String> by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
staticIntFields(AgentSessionInfo::class.java, "TARGET_RUNTIME_")
|
||||
}
|
||||
|
||||
private val getTargetRuntimeMethod: Method? by lazy(LazyThreadSafetyMode.SYNCHRONIZED) {
|
||||
findOptionalMethod(AgentSessionInfo::class.java, METHOD_GET_TARGET_RUNTIME)
|
||||
}
|
||||
|
||||
fun getTargetRuntime(sessionInfo: AgentSessionInfo): DetachedTargetState {
|
||||
val runtimeValue = getTargetRuntimeMethod?.let { method ->
|
||||
invokeChecked { method.invoke(sessionInfo) as? Int }
|
||||
}
|
||||
if (runtimeValue != null) {
|
||||
return DetachedTargetState(
|
||||
value = runtimeValue,
|
||||
label = targetRuntimeLabels[runtimeValue] ?: runtimeValue.toString(),
|
||||
)
|
||||
}
|
||||
return when {
|
||||
sessionInfo.targetPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_HIDDEN -> {
|
||||
DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_DETACHED_HIDDEN_LABEL,
|
||||
)
|
||||
}
|
||||
sessionInfo.targetPresentation == AgentSessionInfo.TARGET_PRESENTATION_DETACHED_SHOWN -> {
|
||||
DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_DETACHED_SHOWN_LABEL,
|
||||
)
|
||||
}
|
||||
sessionInfo.isTargetDetached -> {
|
||||
DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_DETACHED_LAUNCHING_LABEL,
|
||||
)
|
||||
}
|
||||
sessionInfo.targetPackage != null -> {
|
||||
DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_ATTACHED_LABEL,
|
||||
)
|
||||
}
|
||||
else -> DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_NONE_LABEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureDetachedTargetHidden(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
): DetachedTargetControlResult {
|
||||
return invokeControl(
|
||||
callback = callback,
|
||||
sessionId = sessionId,
|
||||
methodName = METHOD_ENSURE_DETACHED_TARGET_HIDDEN,
|
||||
legacyFallback = {
|
||||
callback.requestLaunchDetachedTargetHidden(sessionId)
|
||||
DetachedTargetControlResult(
|
||||
status = null,
|
||||
statusLabel = STATUS_OK_LABEL,
|
||||
targetRuntime = DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_DETACHED_HIDDEN_LABEL,
|
||||
),
|
||||
detachedDisplayId = null,
|
||||
message = "Used legacy detached launch callback.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun showDetachedTarget(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
): DetachedTargetControlResult {
|
||||
return invokeControl(
|
||||
callback = callback,
|
||||
sessionId = sessionId,
|
||||
methodName = METHOD_SHOW_DETACHED_TARGET,
|
||||
legacyFallback = {
|
||||
callback.requestShowDetachedTarget(sessionId)
|
||||
DetachedTargetControlResult(
|
||||
status = null,
|
||||
statusLabel = STATUS_OK_LABEL,
|
||||
targetRuntime = DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_DETACHED_SHOWN_LABEL,
|
||||
),
|
||||
detachedDisplayId = null,
|
||||
message = "Used legacy detached show callback.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun hideDetachedTarget(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
): DetachedTargetControlResult {
|
||||
return invokeControl(
|
||||
callback = callback,
|
||||
sessionId = sessionId,
|
||||
methodName = METHOD_HIDE_DETACHED_TARGET,
|
||||
legacyFallback = {
|
||||
callback.requestHideDetachedTarget(sessionId)
|
||||
DetachedTargetControlResult(
|
||||
status = null,
|
||||
statusLabel = STATUS_OK_LABEL,
|
||||
targetRuntime = DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_DETACHED_HIDDEN_LABEL,
|
||||
),
|
||||
detachedDisplayId = null,
|
||||
message = "Used legacy detached hide callback.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun attachDetachedTarget(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
): DetachedTargetControlResult {
|
||||
return invokeControl(
|
||||
callback = callback,
|
||||
sessionId = sessionId,
|
||||
methodName = METHOD_ATTACH_DETACHED_TARGET,
|
||||
legacyFallback = {
|
||||
callback.requestAttachTarget(sessionId)
|
||||
DetachedTargetControlResult(
|
||||
status = null,
|
||||
statusLabel = STATUS_OK_LABEL,
|
||||
targetRuntime = DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_ATTACHED_LABEL,
|
||||
),
|
||||
detachedDisplayId = null,
|
||||
message = "Used legacy target attach callback.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun closeDetachedTarget(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
): DetachedTargetControlResult {
|
||||
return invokeControl(
|
||||
callback = callback,
|
||||
sessionId = sessionId,
|
||||
methodName = METHOD_CLOSE_DETACHED_TARGET,
|
||||
legacyFallback = {
|
||||
callback.requestCloseDetachedTarget(sessionId)
|
||||
DetachedTargetControlResult(
|
||||
status = null,
|
||||
statusLabel = STATUS_OK_LABEL,
|
||||
targetRuntime = DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_NONE_LABEL,
|
||||
),
|
||||
detachedDisplayId = null,
|
||||
message = "Used legacy detached close callback.",
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fun captureDetachedTargetFrameResult(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
): DetachedTargetCaptureResult {
|
||||
val method = findOptionalMethod(
|
||||
callback.javaClass,
|
||||
METHOD_CAPTURE_DETACHED_TARGET_FRAME_RESULT,
|
||||
String::class.java,
|
||||
)
|
||||
if (method == null) {
|
||||
val captureResult = callback.captureDetachedTargetFrame(sessionId)
|
||||
return DetachedTargetCaptureResult(
|
||||
status = null,
|
||||
statusLabel = if (captureResult != null) STATUS_OK_LABEL else STATUS_CAPTURE_FAILED_LABEL,
|
||||
targetRuntime = DetachedTargetState(
|
||||
value = null,
|
||||
label = if (captureResult != null) {
|
||||
TARGET_RUNTIME_DETACHED_HIDDEN_LABEL
|
||||
} else {
|
||||
TARGET_RUNTIME_NONE_LABEL
|
||||
},
|
||||
),
|
||||
detachedDisplayId = null,
|
||||
message = if (captureResult != null) {
|
||||
"Used legacy detached-frame capture callback."
|
||||
} else {
|
||||
"Legacy detached-frame capture returned null."
|
||||
},
|
||||
captureResult = captureResult,
|
||||
)
|
||||
}
|
||||
val resultObject = invokeChecked {
|
||||
method.invoke(callback, sessionId)
|
||||
} ?: return DetachedTargetCaptureResult(
|
||||
status = null,
|
||||
statusLabel = STATUS_CAPTURE_FAILED_LABEL,
|
||||
targetRuntime = DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_NONE_LABEL,
|
||||
),
|
||||
detachedDisplayId = null,
|
||||
message = "Detached target capture returned null result object.",
|
||||
captureResult = null,
|
||||
)
|
||||
return parseCaptureResult(resultObject)
|
||||
}
|
||||
|
||||
private fun invokeControl(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
methodName: String,
|
||||
legacyFallback: () -> DetachedTargetControlResult,
|
||||
): DetachedTargetControlResult {
|
||||
val method = findOptionalMethod(callback.javaClass, methodName, String::class.java)
|
||||
if (method == null) {
|
||||
return legacyFallback()
|
||||
}
|
||||
val resultObject = invokeChecked {
|
||||
method.invoke(callback, sessionId)
|
||||
} ?: return DetachedTargetControlResult(
|
||||
status = null,
|
||||
statusLabel = STATUS_INTERNAL_ERROR_LABEL,
|
||||
targetRuntime = DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_NONE_LABEL,
|
||||
),
|
||||
detachedDisplayId = null,
|
||||
message = "$methodName returned null result object.",
|
||||
)
|
||||
return parseControlResult(resultObject)
|
||||
}
|
||||
|
||||
private fun parseControlResult(resultObject: Any): DetachedTargetControlResult {
|
||||
val resultClass = resultObject.javaClass
|
||||
val status = invokeChecked {
|
||||
findRequiredMethod(resultClass, METHOD_GET_STATUS).invoke(resultObject) as? Int
|
||||
}
|
||||
return DetachedTargetControlResult(
|
||||
status = status,
|
||||
statusLabel = statusLabel(resultClass, status),
|
||||
targetRuntime = parseTargetRuntime(resultObject),
|
||||
detachedDisplayId = optionalInt(resultObject, METHOD_GET_DETACHED_DISPLAY_ID),
|
||||
message = optionalString(resultObject, METHOD_GET_MESSAGE),
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseCaptureResult(resultObject: Any): DetachedTargetCaptureResult {
|
||||
val resultClass = resultObject.javaClass
|
||||
val status = invokeChecked {
|
||||
findRequiredMethod(resultClass, METHOD_GET_STATUS).invoke(resultObject) as? Int
|
||||
}
|
||||
val captureGetter = findOptionalMethod(resultClass, "getCaptureResult")
|
||||
?: findOptionalMethod(resultClass, "getScreenCaptureResult")
|
||||
val captureResult = captureGetter?.let { method ->
|
||||
invokeChecked { method.invoke(resultObject) as? ScreenCapture.ScreenCaptureResult }
|
||||
}
|
||||
return DetachedTargetCaptureResult(
|
||||
status = status,
|
||||
statusLabel = statusLabel(resultClass, status),
|
||||
targetRuntime = parseTargetRuntime(resultObject),
|
||||
detachedDisplayId = optionalInt(resultObject, METHOD_GET_DETACHED_DISPLAY_ID),
|
||||
message = optionalString(resultObject, METHOD_GET_MESSAGE),
|
||||
captureResult = captureResult,
|
||||
)
|
||||
}
|
||||
|
||||
private fun parseTargetRuntime(resultObject: Any): DetachedTargetState {
|
||||
val runtime = optionalInt(resultObject, METHOD_GET_TARGET_RUNTIME)
|
||||
return if (runtime != null) {
|
||||
DetachedTargetState(
|
||||
value = runtime,
|
||||
label = targetRuntimeLabels[runtime] ?: runtime.toString(),
|
||||
)
|
||||
} else {
|
||||
DetachedTargetState(
|
||||
value = null,
|
||||
label = TARGET_RUNTIME_NONE_LABEL,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun statusLabel(
|
||||
resultClass: Class<*>,
|
||||
status: Int?,
|
||||
): String {
|
||||
if (status == null) {
|
||||
return STATUS_INTERNAL_ERROR_LABEL
|
||||
}
|
||||
return staticIntFields(resultClass, "STATUS_")[status] ?: status.toString()
|
||||
}
|
||||
|
||||
private fun optionalInt(
|
||||
target: Any,
|
||||
methodName: String,
|
||||
): Int? {
|
||||
val method = findOptionalMethod(target.javaClass, methodName) ?: return null
|
||||
return invokeChecked { method.invoke(target) as? Int }
|
||||
}
|
||||
|
||||
private fun optionalString(
|
||||
target: Any,
|
||||
methodName: String,
|
||||
): String? {
|
||||
val method = findOptionalMethod(target.javaClass, methodName) ?: return null
|
||||
return invokeChecked { method.invoke(target) as? String }?.ifBlank { null }
|
||||
}
|
||||
|
||||
private fun staticIntFields(
|
||||
clazz: Class<*>,
|
||||
prefix: String,
|
||||
): Map<Int, String> {
|
||||
return clazz.fields
|
||||
.filter(::isStaticIntField)
|
||||
.filter { field -> field.name.startsWith(prefix) }
|
||||
.associate { field ->
|
||||
field.getInt(null) to field.name
|
||||
}
|
||||
}
|
||||
|
||||
private fun isStaticIntField(field: Field): Boolean {
|
||||
return Modifier.isStatic(field.modifiers) && field.type == Int::class.javaPrimitiveType
|
||||
}
|
||||
|
||||
private fun findRequiredMethod(
|
||||
clazz: Class<*>,
|
||||
name: String,
|
||||
vararg parameterTypes: Class<*>,
|
||||
): Method {
|
||||
return clazz.getMethod(name, *parameterTypes)
|
||||
}
|
||||
|
||||
private fun findOptionalMethod(
|
||||
clazz: Class<*>,
|
||||
name: String,
|
||||
vararg parameterTypes: Class<*>,
|
||||
): Method? {
|
||||
return runCatching {
|
||||
clazz.getMethod(name, *parameterTypes)
|
||||
}.getOrNull()
|
||||
}
|
||||
|
||||
private fun <T> invokeChecked(block: () -> T): T {
|
||||
try {
|
||||
return block()
|
||||
} catch (err: InvocationTargetException) {
|
||||
throw err.targetException ?: err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,543 @@
|
||||
package com.openai.codex.bridge
|
||||
|
||||
import android.app.agent.AgentManager
|
||||
import android.app.agent.GenieService
|
||||
import android.os.Bundle
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import java.io.InputStream
|
||||
import java.io.OutputStream
|
||||
import java.lang.reflect.Constructor
|
||||
import java.lang.reflect.InvocationTargetException
|
||||
import java.lang.reflect.Method
|
||||
import java.lang.reflect.Modifier
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
object FrameworkSessionTransportCompat {
|
||||
private const val NETWORK_CONFIG_CLASS_NAME = "android.app.agent.AgentSessionNetworkConfig"
|
||||
private const val HTTP_BRIDGE_CLASS_NAME = "android.app.agent.FrameworkSessionHttpBridge"
|
||||
private const val HTTP_EXCHANGE_CLASS_NAME = "android.app.agent.FrameworkHttpExchange"
|
||||
private const val HTTP_REQUEST_HEAD_CLASS_NAME = "android.app.agent.FrameworkHttpRequestHead"
|
||||
private const val HTTP_RESPONSE_HEAD_CLASS_NAME = "android.app.agent.FrameworkHttpResponseHead"
|
||||
private const val HTTP_RESPONSE_HEAD_RESULT_CLASS_NAME = "android.app.agent.FrameworkHttpResponseHeadResult"
|
||||
private const val OPEN_EXCHANGE_METHOD = "openExchange"
|
||||
private const val OPEN_REQUEST_BODY_OUTPUT_STREAM_METHOD = "openRequestBodyOutputStream"
|
||||
private const val AWAIT_RESPONSE_HEAD_METHOD = "awaitResponseHead"
|
||||
private const val OPEN_RESPONSE_BODY_INPUT_STREAM_METHOD = "openResponseBodyInputStream"
|
||||
private const val CANCEL_METHOD = "cancel"
|
||||
private const val SET_SESSION_NETWORK_CONFIG_METHOD = "setSessionNetworkConfig"
|
||||
private const val AGENT_OPEN_EXCHANGE_METHOD = "openFrameworkHttpExchange"
|
||||
private const val AGENT_AWAIT_RESPONSE_HEAD_METHOD = "awaitFrameworkHttpResponseHead"
|
||||
private const val AGENT_CANCEL_EXCHANGE_METHOD = "cancelFrameworkHttpExchange"
|
||||
private const val STATUS_OK_FIELD_NAME = "STATUS_OK"
|
||||
private const val READ_BUFFER_BYTES = 8192
|
||||
private const val WRITE_BUFFER_BYTES = 8192
|
||||
|
||||
data class SessionNetworkConfig(
|
||||
val baseUrl: String,
|
||||
val defaultHeaders: Bundle,
|
||||
val connectTimeoutMillis: Int,
|
||||
val readTimeoutMillis: Int,
|
||||
)
|
||||
|
||||
data class HttpRequest(
|
||||
val method: String,
|
||||
val path: String,
|
||||
val headers: Bundle,
|
||||
val body: ByteArray,
|
||||
)
|
||||
|
||||
data class HttpResponse(
|
||||
val statusCode: Int,
|
||||
val headers: Bundle,
|
||||
val body: ByteArray,
|
||||
val bodyString: String,
|
||||
)
|
||||
|
||||
private data class HttpExchange(
|
||||
val runtimeValue: Any,
|
||||
)
|
||||
|
||||
private data class HttpResponseHead(
|
||||
val statusCode: Int,
|
||||
val headers: Bundle,
|
||||
)
|
||||
|
||||
private data class HttpResponseHeadResult(
|
||||
val status: Int,
|
||||
val statusName: String,
|
||||
val responseHead: HttpResponseHead?,
|
||||
val message: String?,
|
||||
)
|
||||
|
||||
private data class AvailableRuntimeApi(
|
||||
val setSessionNetworkConfigMethod: Method,
|
||||
val networkConfigConstructor: Constructor<*>,
|
||||
val requestHeadConstructor: Constructor<*>,
|
||||
val exchangeGetIdMethod: Method?,
|
||||
val agentOpenExchangeMethod: Method,
|
||||
val agentAwaitResponseHeadMethod: Method,
|
||||
val agentCancelMethod: Method,
|
||||
val openExchangeMethod: Method,
|
||||
val openRequestBodyOutputStreamMethod: Method,
|
||||
val awaitResponseHeadMethod: Method,
|
||||
val openResponseBodyInputStreamMethod: Method,
|
||||
val cancelMethod: Method,
|
||||
val responseHeadResultGetStatusMethod: Method,
|
||||
val responseHeadResultGetResponseHeadMethod: Method,
|
||||
val responseHeadResultGetMessageMethod: Method?,
|
||||
val responseHeadGetStatusCodeMethod: Method,
|
||||
val responseHeadGetHeadersMethod: Method,
|
||||
val statusNamesByValue: Map<Int, String>,
|
||||
val okStatus: Int,
|
||||
)
|
||||
|
||||
private val runtimeApi: AvailableRuntimeApi by lazy(LazyThreadSafetyMode.SYNCHRONIZED, ::resolveRuntimeApi)
|
||||
|
||||
fun setSessionNetworkConfig(
|
||||
agentManager: AgentManager,
|
||||
sessionId: String,
|
||||
config: SessionNetworkConfig,
|
||||
) {
|
||||
val platformConfig = invokeChecked {
|
||||
runtimeApi.networkConfigConstructor.newInstance(
|
||||
config.baseUrl,
|
||||
Bundle(config.defaultHeaders),
|
||||
config.connectTimeoutMillis,
|
||||
config.readTimeoutMillis,
|
||||
)
|
||||
}
|
||||
invokeChecked {
|
||||
runtimeApi.setSessionNetworkConfigMethod.invoke(agentManager, sessionId, platformConfig)
|
||||
}
|
||||
}
|
||||
|
||||
fun executeStreamingRequest(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
request: HttpRequest,
|
||||
): HttpResponse {
|
||||
val exchange = openExchange(callback, sessionId, request)
|
||||
var cancelExchange = true
|
||||
try {
|
||||
invokeChecked {
|
||||
runtimeApi.openRequestBodyOutputStreamMethod.invoke(null, exchange.runtimeValue) as OutputStream
|
||||
}.use { requestBody ->
|
||||
writeAll(requestBody, request.body)
|
||||
}
|
||||
val responseHeadResult = awaitResponseHead(callback, sessionId, exchange)
|
||||
if (responseHeadResult.status != runtimeApi.okStatus) {
|
||||
val details = responseHeadResult.message?.takeIf(String::isNotBlank)
|
||||
val suffix = if (details == null) "" else ": $details"
|
||||
throw IOException(
|
||||
"Framework HTTP exchange failed with ${responseHeadResult.statusName}$suffix",
|
||||
)
|
||||
}
|
||||
val responseHead = responseHeadResult.responseHead
|
||||
?: throw IOException("Framework HTTP exchange succeeded without a response head")
|
||||
val responseBody = invokeChecked {
|
||||
runtimeApi.openResponseBodyInputStreamMethod.invoke(null, exchange.runtimeValue) as InputStream
|
||||
}.use(::readFully)
|
||||
cancelExchange = false
|
||||
return HttpResponse(
|
||||
statusCode = responseHead.statusCode,
|
||||
headers = responseHead.headers,
|
||||
body = responseBody,
|
||||
bodyString = responseBody.toString(StandardCharsets.UTF_8),
|
||||
)
|
||||
} finally {
|
||||
if (cancelExchange) {
|
||||
runCatching {
|
||||
invokeChecked {
|
||||
runtimeApi.cancelMethod.invoke(null, callback, sessionId, exchange.runtimeValue)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun executeStreamingRequest(
|
||||
agentManager: AgentManager,
|
||||
sessionId: String,
|
||||
request: HttpRequest,
|
||||
): HttpResponse {
|
||||
val exchange = openExchange(agentManager, sessionId, request)
|
||||
var cancelExchange = true
|
||||
try {
|
||||
invokeChecked {
|
||||
runtimeApi.openRequestBodyOutputStreamMethod.invoke(null, exchange.runtimeValue) as OutputStream
|
||||
}.use { requestBody ->
|
||||
writeAll(requestBody, request.body)
|
||||
}
|
||||
val responseHeadResult = awaitResponseHead(agentManager, sessionId, exchange)
|
||||
if (responseHeadResult.status != runtimeApi.okStatus) {
|
||||
val details = responseHeadResult.message?.takeIf(String::isNotBlank)
|
||||
val suffix = if (details == null) "" else ": $details"
|
||||
throw IOException(
|
||||
"Framework HTTP exchange failed with ${responseHeadResult.statusName}$suffix",
|
||||
)
|
||||
}
|
||||
val responseHead = responseHeadResult.responseHead
|
||||
?: throw IOException("Framework HTTP exchange succeeded without a response head")
|
||||
val responseBody = invokeChecked {
|
||||
runtimeApi.openResponseBodyInputStreamMethod.invoke(null, exchange.runtimeValue) as InputStream
|
||||
}.use(::readFully)
|
||||
cancelExchange = false
|
||||
return HttpResponse(
|
||||
statusCode = responseHead.statusCode,
|
||||
headers = responseHead.headers,
|
||||
body = responseBody,
|
||||
bodyString = responseBody.toString(StandardCharsets.UTF_8),
|
||||
)
|
||||
} finally {
|
||||
if (cancelExchange) {
|
||||
runCatching {
|
||||
invokeChecked {
|
||||
runtimeApi.agentCancelMethod.invoke(
|
||||
agentManager,
|
||||
sessionId,
|
||||
agentExchangeArgument(runtimeApi.agentCancelMethod, exchange),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openExchange(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
request: HttpRequest,
|
||||
): HttpExchange {
|
||||
val requestHead = invokeChecked {
|
||||
runtimeApi.requestHeadConstructor.newInstance(
|
||||
request.method,
|
||||
request.path,
|
||||
Bundle(request.headers),
|
||||
)
|
||||
}
|
||||
val runtimeExchange = invokeChecked {
|
||||
runtimeApi.openExchangeMethod.invoke(null, callback, sessionId, requestHead)
|
||||
?: throw IOException("Framework HTTP exchange opened with no exchange handle")
|
||||
}
|
||||
return HttpExchange(runtimeExchange)
|
||||
}
|
||||
|
||||
private fun openExchange(
|
||||
agentManager: AgentManager,
|
||||
sessionId: String,
|
||||
request: HttpRequest,
|
||||
): HttpExchange {
|
||||
val requestHead = invokeChecked {
|
||||
runtimeApi.requestHeadConstructor.newInstance(
|
||||
request.method,
|
||||
request.path,
|
||||
Bundle(request.headers),
|
||||
)
|
||||
}
|
||||
val runtimeExchange = invokeChecked {
|
||||
runtimeApi.agentOpenExchangeMethod.invoke(agentManager, sessionId, requestHead)
|
||||
?: throw IOException("Framework HTTP exchange opened with no exchange handle")
|
||||
}
|
||||
return HttpExchange(runtimeExchange)
|
||||
}
|
||||
|
||||
private fun awaitResponseHead(
|
||||
callback: GenieService.Callback,
|
||||
sessionId: String,
|
||||
exchange: HttpExchange,
|
||||
): HttpResponseHeadResult {
|
||||
val resultObject = invokeChecked {
|
||||
runtimeApi.awaitResponseHeadMethod.invoke(null, callback, sessionId, exchange.runtimeValue)
|
||||
}
|
||||
val status = invokeChecked {
|
||||
runtimeApi.responseHeadResultGetStatusMethod.invoke(resultObject) as Int
|
||||
}
|
||||
val responseHeadObject = invokeChecked {
|
||||
runtimeApi.responseHeadResultGetResponseHeadMethod.invoke(resultObject)
|
||||
}
|
||||
val responseHead = if (responseHeadObject == null) {
|
||||
null
|
||||
} else {
|
||||
val statusCode = invokeChecked {
|
||||
runtimeApi.responseHeadGetStatusCodeMethod.invoke(responseHeadObject) as Int
|
||||
}
|
||||
val headers = invokeChecked {
|
||||
runtimeApi.responseHeadGetHeadersMethod.invoke(responseHeadObject) as? Bundle
|
||||
} ?: Bundle.EMPTY
|
||||
HttpResponseHead(
|
||||
statusCode = statusCode,
|
||||
headers = Bundle(headers),
|
||||
)
|
||||
}
|
||||
val message = runtimeApi.responseHeadResultGetMessageMethod?.let { method ->
|
||||
invokeChecked {
|
||||
method.invoke(resultObject) as? String
|
||||
}
|
||||
}?.ifBlank { null }
|
||||
return HttpResponseHeadResult(
|
||||
status = status,
|
||||
statusName = runtimeApi.statusNamesByValue[status] ?: "STATUS_$status",
|
||||
responseHead = responseHead,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
private fun awaitResponseHead(
|
||||
agentManager: AgentManager,
|
||||
sessionId: String,
|
||||
exchange: HttpExchange,
|
||||
): HttpResponseHeadResult {
|
||||
val resultObject = invokeChecked {
|
||||
runtimeApi.agentAwaitResponseHeadMethod.invoke(
|
||||
agentManager,
|
||||
sessionId,
|
||||
agentExchangeArgument(runtimeApi.agentAwaitResponseHeadMethod, exchange),
|
||||
)
|
||||
}
|
||||
val status = invokeChecked {
|
||||
runtimeApi.responseHeadResultGetStatusMethod.invoke(resultObject) as Int
|
||||
}
|
||||
val responseHeadObject = invokeChecked {
|
||||
runtimeApi.responseHeadResultGetResponseHeadMethod.invoke(resultObject)
|
||||
}
|
||||
val responseHead = if (responseHeadObject == null) {
|
||||
null
|
||||
} else {
|
||||
val statusCode = invokeChecked {
|
||||
runtimeApi.responseHeadGetStatusCodeMethod.invoke(responseHeadObject) as Int
|
||||
}
|
||||
val headers = invokeChecked {
|
||||
runtimeApi.responseHeadGetHeadersMethod.invoke(responseHeadObject) as? Bundle
|
||||
} ?: Bundle.EMPTY
|
||||
HttpResponseHead(
|
||||
statusCode = statusCode,
|
||||
headers = Bundle(headers),
|
||||
)
|
||||
}
|
||||
val message = runtimeApi.responseHeadResultGetMessageMethod?.let { method ->
|
||||
invokeChecked {
|
||||
method.invoke(resultObject) as? String
|
||||
}
|
||||
}?.ifBlank { null }
|
||||
return HttpResponseHeadResult(
|
||||
status = status,
|
||||
statusName = runtimeApi.statusNamesByValue[status] ?: "STATUS_$status",
|
||||
responseHead = responseHead,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveRuntimeApi(): AvailableRuntimeApi {
|
||||
return try {
|
||||
val networkConfigClass = Class.forName(NETWORK_CONFIG_CLASS_NAME)
|
||||
val httpBridgeClass = Class.forName(HTTP_BRIDGE_CLASS_NAME)
|
||||
val exchangeClass = Class.forName(HTTP_EXCHANGE_CLASS_NAME)
|
||||
val requestHeadClass = Class.forName(HTTP_REQUEST_HEAD_CLASS_NAME)
|
||||
val responseHeadClass = Class.forName(HTTP_RESPONSE_HEAD_CLASS_NAME)
|
||||
val responseHeadResultClass = Class.forName(HTTP_RESPONSE_HEAD_RESULT_CLASS_NAME)
|
||||
val statusNamesByValue = responseHeadResultClass.fields
|
||||
.filter { field ->
|
||||
Modifier.isStatic(field.modifiers) &&
|
||||
field.type == Int::class.javaPrimitiveType &&
|
||||
field.name.startsWith("STATUS_")
|
||||
}
|
||||
.associate { field ->
|
||||
field.getInt(null) to field.name
|
||||
}
|
||||
val okStatus = responseHeadResultClass.getField(STATUS_OK_FIELD_NAME).getInt(null)
|
||||
AvailableRuntimeApi(
|
||||
setSessionNetworkConfigMethod = AgentManager::class.java.getMethod(
|
||||
SET_SESSION_NETWORK_CONFIG_METHOD,
|
||||
String::class.java,
|
||||
networkConfigClass,
|
||||
),
|
||||
networkConfigConstructor = networkConfigClass.getConstructor(
|
||||
String::class.java,
|
||||
Bundle::class.java,
|
||||
Int::class.javaPrimitiveType,
|
||||
Int::class.javaPrimitiveType,
|
||||
),
|
||||
requestHeadConstructor = requestHeadClass.getConstructor(
|
||||
String::class.java,
|
||||
String::class.java,
|
||||
Bundle::class.java,
|
||||
),
|
||||
exchangeGetIdMethod = exchangeClass.methods.firstOrNull { method ->
|
||||
method.parameterCount == 0 &&
|
||||
method.returnType == String::class.java &&
|
||||
(method.name == "getExchangeId" || method.name == "getId")
|
||||
},
|
||||
agentOpenExchangeMethod = requireMethod(
|
||||
owner = AgentManager::class.java,
|
||||
name = AGENT_OPEN_EXCHANGE_METHOD,
|
||||
String::class.java,
|
||||
requestHeadClass,
|
||||
),
|
||||
agentAwaitResponseHeadMethod = requireOneOfMethods(
|
||||
owner = AgentManager::class.java,
|
||||
name = AGENT_AWAIT_RESPONSE_HEAD_METHOD,
|
||||
listOf(
|
||||
arrayOf(String::class.java, exchangeClass),
|
||||
arrayOf(String::class.java, String::class.java),
|
||||
),
|
||||
),
|
||||
agentCancelMethod = requireOneOfMethods(
|
||||
owner = AgentManager::class.java,
|
||||
name = AGENT_CANCEL_EXCHANGE_METHOD,
|
||||
listOf(
|
||||
arrayOf(String::class.java, exchangeClass),
|
||||
arrayOf(String::class.java, String::class.java),
|
||||
),
|
||||
),
|
||||
openExchangeMethod = requireMethod(
|
||||
owner = httpBridgeClass,
|
||||
name = OPEN_EXCHANGE_METHOD,
|
||||
GenieService.Callback::class.java,
|
||||
String::class.java,
|
||||
requestHeadClass,
|
||||
),
|
||||
openRequestBodyOutputStreamMethod = requireMethod(
|
||||
owner = httpBridgeClass,
|
||||
name = OPEN_REQUEST_BODY_OUTPUT_STREAM_METHOD,
|
||||
exchangeClass,
|
||||
),
|
||||
awaitResponseHeadMethod = requireMethod(
|
||||
owner = httpBridgeClass,
|
||||
name = AWAIT_RESPONSE_HEAD_METHOD,
|
||||
GenieService.Callback::class.java,
|
||||
String::class.java,
|
||||
exchangeClass,
|
||||
),
|
||||
openResponseBodyInputStreamMethod = requireMethod(
|
||||
owner = httpBridgeClass,
|
||||
name = OPEN_RESPONSE_BODY_INPUT_STREAM_METHOD,
|
||||
exchangeClass,
|
||||
),
|
||||
cancelMethod = requireMethod(
|
||||
owner = httpBridgeClass,
|
||||
name = CANCEL_METHOD,
|
||||
GenieService.Callback::class.java,
|
||||
String::class.java,
|
||||
exchangeClass,
|
||||
),
|
||||
responseHeadResultGetStatusMethod = requireMethod(
|
||||
owner = responseHeadResultClass,
|
||||
name = "getStatus",
|
||||
),
|
||||
responseHeadResultGetResponseHeadMethod = requireMethod(
|
||||
owner = responseHeadResultClass,
|
||||
name = "getResponseHead",
|
||||
),
|
||||
responseHeadResultGetMessageMethod = responseHeadResultClass.methods.firstOrNull { method ->
|
||||
method.name == "getMessage" && method.parameterCount == 0
|
||||
},
|
||||
responseHeadGetStatusCodeMethod = requireMethod(
|
||||
owner = responseHeadClass,
|
||||
name = "getStatusCode",
|
||||
),
|
||||
responseHeadGetHeadersMethod = requireMethod(
|
||||
owner = responseHeadClass,
|
||||
name = "getHeaders",
|
||||
),
|
||||
statusNamesByValue = statusNamesByValue,
|
||||
okStatus = okStatus,
|
||||
)
|
||||
} catch (err: ReflectiveOperationException) {
|
||||
throw IllegalStateException(
|
||||
"Framework-owned HTTP streaming APIs are unavailable. The device runtime and AgentSDK are out of sync.",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun requireMethod(
|
||||
owner: Class<*>,
|
||||
name: String,
|
||||
vararg parameterTypes: Class<*>,
|
||||
): Method {
|
||||
return owner.methods.firstOrNull { method ->
|
||||
method.name == name &&
|
||||
method.parameterCount == parameterTypes.size &&
|
||||
method.parameterTypes.contentEquals(parameterTypes)
|
||||
} ?: throw NoSuchMethodException(
|
||||
"${owner.name}#$name(${parameterTypes.joinToString { it.name }})",
|
||||
)
|
||||
}
|
||||
|
||||
private fun requireOneOfMethods(
|
||||
owner: Class<*>,
|
||||
name: String,
|
||||
parameterTypeOptions: List<Array<Class<*>>>,
|
||||
): Method {
|
||||
return owner.methods.firstOrNull { method ->
|
||||
method.name == name &&
|
||||
parameterTypeOptions.any { option ->
|
||||
method.parameterCount == option.size &&
|
||||
method.parameterTypes.contentEquals(option)
|
||||
}
|
||||
} ?: throw NoSuchMethodException(
|
||||
buildString {
|
||||
append(owner.name)
|
||||
append('#')
|
||||
append(name)
|
||||
append('(')
|
||||
append(
|
||||
parameterTypeOptions.joinToString(" | ") { option ->
|
||||
option.joinToString(", ") { it.name }
|
||||
},
|
||||
)
|
||||
append(')')
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun agentExchangeArgument(
|
||||
agentMethod: Method,
|
||||
exchange: HttpExchange,
|
||||
): Any {
|
||||
return if (agentMethod.parameterTypes[1] == String::class.java) {
|
||||
val exchangeIdMethod = runtimeApi.exchangeGetIdMethod
|
||||
?: throw IOException("Framework HTTP exchange does not expose an exchange id")
|
||||
invokeChecked {
|
||||
exchangeIdMethod.invoke(exchange.runtimeValue) as? String
|
||||
}?.takeIf(String::isNotBlank)
|
||||
?: throw IOException("Framework HTTP exchange returned a blank exchange id")
|
||||
} else {
|
||||
exchange.runtimeValue
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeAll(
|
||||
output: OutputStream,
|
||||
bytes: ByteArray,
|
||||
) {
|
||||
var offset = 0
|
||||
while (offset < bytes.size) {
|
||||
val chunkSize = minOf(WRITE_BUFFER_BYTES, bytes.size - offset)
|
||||
output.write(bytes, offset, chunkSize)
|
||||
offset += chunkSize
|
||||
}
|
||||
output.flush()
|
||||
}
|
||||
|
||||
private fun readFully(input: InputStream): ByteArray {
|
||||
val buffer = ByteArray(READ_BUFFER_BYTES)
|
||||
val bytes = ByteArrayOutputStream()
|
||||
while (true) {
|
||||
val read = input.read(buffer)
|
||||
if (read == -1) {
|
||||
return bytes.toByteArray()
|
||||
}
|
||||
bytes.write(buffer, 0, read)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <T> invokeChecked(block: () -> T): T {
|
||||
try {
|
||||
return block()
|
||||
} catch (err: InvocationTargetException) {
|
||||
throw err.targetException ?: err
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package com.openai.codex.bridge;
|
||||
|
||||
import android.content.Context;
|
||||
import java.io.File;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
|
||||
public final class HostedCodexConfig {
|
||||
public static final String ANDROID_HTTP_PROVIDER_ID = "android-openai-http";
|
||||
public static final String AGENTS_FILENAME = "AGENTS.md";
|
||||
private static final String BUNDLED_AGENTS_ASSET_PATH = AGENTS_FILENAME;
|
||||
|
||||
private HostedCodexConfig() {}
|
||||
|
||||
public static void write(Context context, File codexHome, String baseUrl) throws IOException {
|
||||
ensureCodexHome(codexHome);
|
||||
installBundledAgentsFile(context, codexHome);
|
||||
|
||||
String escapedBaseUrl = baseUrl
|
||||
.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"");
|
||||
String configToml = "model_provider = \"" + ANDROID_HTTP_PROVIDER_ID + "\"\n\n"
|
||||
+ "[model_providers." + ANDROID_HTTP_PROVIDER_ID + "]\n"
|
||||
+ "name = \"Android OpenAI HTTP\"\n"
|
||||
+ "base_url = \"" + escapedBaseUrl + "\"\n"
|
||||
+ "wire_api = \"responses\"\n"
|
||||
+ "requires_openai_auth = true\n"
|
||||
+ "supports_websockets = false\n";
|
||||
Files.write(
|
||||
new File(codexHome, "config.toml").toPath(),
|
||||
configToml.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public static void installBundledAgentsFile(Context context, File codexHome) throws IOException {
|
||||
installAgentsFile(codexHome, readBundledAgentsMarkdown(context));
|
||||
}
|
||||
|
||||
public static void installAgentsFile(File codexHome, String agentsMarkdown) throws IOException {
|
||||
ensureCodexHome(codexHome);
|
||||
Files.write(
|
||||
new File(codexHome, AGENTS_FILENAME).toPath(),
|
||||
agentsMarkdown.getBytes(StandardCharsets.UTF_8));
|
||||
}
|
||||
|
||||
public static String readBundledAgentsMarkdown(Context context) throws IOException {
|
||||
try (InputStream inputStream = context.getAssets().open(BUNDLED_AGENTS_ASSET_PATH)) {
|
||||
return new String(inputStream.readAllBytes(), StandardCharsets.UTF_8);
|
||||
}
|
||||
}
|
||||
|
||||
public static String readInstalledAgentsMarkdown(File codexHome) throws IOException {
|
||||
return new String(
|
||||
Files.readAllBytes(new File(codexHome, AGENTS_FILENAME).toPath()),
|
||||
StandardCharsets.UTF_8);
|
||||
}
|
||||
|
||||
private static void ensureCodexHome(File codexHome) throws IOException {
|
||||
if (!codexHome.isDirectory() && !codexHome.mkdirs()) {
|
||||
throw new IOException("failed to create codex home at " + codexHome.getAbsolutePath());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.openai.codex.bridge
|
||||
|
||||
data class SessionExecutionSettings(
|
||||
val model: String?,
|
||||
val reasoningEffort: String?,
|
||||
) {
|
||||
companion object {
|
||||
val default = SessionExecutionSettings(
|
||||
model = null,
|
||||
reasoningEffort = null,
|
||||
)
|
||||
}
|
||||
|
||||
fun isDefault(): Boolean {
|
||||
return model.isNullOrBlank() && reasoningEffort.isNullOrBlank()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.openai.codex.bridge;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import org.junit.Test;
|
||||
|
||||
public final class HostedCodexConfigTest {
|
||||
@Test
|
||||
public void installAgentsFileWritesExpectedGuidance() throws Exception {
|
||||
File codexHome = Files.createTempDirectory("hosted-codex-home").toFile();
|
||||
String agentsMarkdown = "# Runtime Notes\n\n- prefer `cmd`\n";
|
||||
|
||||
HostedCodexConfig.installAgentsFile(codexHome, agentsMarkdown);
|
||||
|
||||
String installedMarkdown =
|
||||
new String(
|
||||
Files.readAllBytes(new File(codexHome, "AGENTS.md").toPath()),
|
||||
StandardCharsets.UTF_8);
|
||||
|
||||
assertEquals(agentsMarkdown, installedMarkdown);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void readInstalledAgentsMarkdownReadsExistingFile() throws Exception {
|
||||
File codexHome = Files.createTempDirectory("hosted-codex-agents").toFile();
|
||||
HostedCodexConfig.installAgentsFile(codexHome, "# Agent file\n");
|
||||
|
||||
String installedMarkdown = HostedCodexConfig.readInstalledAgentsMarkdown(codexHome);
|
||||
|
||||
assertEquals("# Agent file\n", installedMarkdown);
|
||||
}
|
||||
}
|
||||
91
android/build-agent-genie-apks.sh
Executable file
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
usage() {
|
||||
cat <<'EOF'
|
||||
Build the packaged Codex binary plus the Android Agent and Genie APKs.
|
||||
|
||||
Usage:
|
||||
build-agent-genie-apks.sh [--agent-sdk-zip PATH] [--variant debug|release] [--skip-lto]
|
||||
|
||||
Options:
|
||||
--agent-sdk-zip PATH Path to android-agent-platform-stub-sdk.zip.
|
||||
Defaults to $ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP.
|
||||
--variant VALUE APK variant to build: debug or release. Defaults to debug.
|
||||
--skip-lto Set CODEX_ANDROID_SKIP_LTO=1 for faster local builds.
|
||||
-h, --help Show this help text.
|
||||
EOF
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "error: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
repo_root="$(cd -- "$script_dir/.." && pwd)"
|
||||
stub_sdk_zip="${ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP:-}"
|
||||
variant="debug"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--agent-sdk-zip)
|
||||
shift
|
||||
[[ $# -gt 0 ]] || fail "--agent-sdk-zip requires a path"
|
||||
stub_sdk_zip="$1"
|
||||
;;
|
||||
--variant)
|
||||
shift
|
||||
[[ $# -gt 0 ]] || fail "--variant requires a value"
|
||||
variant="$1"
|
||||
;;
|
||||
--skip-lto)
|
||||
export CODEX_ANDROID_SKIP_LTO=1
|
||||
;;
|
||||
-h|--help)
|
||||
usage
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
fail "unknown argument: $1"
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
[[ -n "$stub_sdk_zip" ]] || fail "set ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP or pass --agent-sdk-zip"
|
||||
[[ -f "$stub_sdk_zip" ]] || fail "stub SDK zip not found: $stub_sdk_zip"
|
||||
[[ "$variant" == "debug" || "$variant" == "release" ]] || fail "--variant must be debug or release"
|
||||
|
||||
case "$variant" in
|
||||
debug)
|
||||
gradle_task_variant="Debug"
|
||||
;;
|
||||
release)
|
||||
gradle_task_variant="Release"
|
||||
;;
|
||||
esac
|
||||
agent_apk="$script_dir/app/build/outputs/apk/$variant/app-$variant.apk"
|
||||
genie_apk="$script_dir/genie/build/outputs/apk/$variant/genie-$variant.apk"
|
||||
if [[ "$variant" == "release" ]]; then
|
||||
agent_apk="$script_dir/app/build/outputs/apk/$variant/app-$variant-unsigned.apk"
|
||||
genie_apk="$script_dir/genie/build/outputs/apk/$variant/genie-$variant-unsigned.apk"
|
||||
fi
|
||||
|
||||
echo "Building Android Agent and Genie APKs"
|
||||
(
|
||||
cd "$script_dir"
|
||||
ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP="$stub_sdk_zip" \
|
||||
./gradlew ":app:assemble$gradle_task_variant" ":genie:assemble$gradle_task_variant" \
|
||||
-PagentPlatformStubSdkZip="$stub_sdk_zip"
|
||||
)
|
||||
|
||||
cat <<EOF
|
||||
Build complete.
|
||||
|
||||
Agent APK:
|
||||
$agent_apk
|
||||
|
||||
Genie APK:
|
||||
$genie_apk
|
||||
EOF
|
||||
41
android/build.gradle.kts
Normal file
@@ -0,0 +1,41 @@
|
||||
import org.gradle.api.tasks.Exec
|
||||
import org.gradle.api.tasks.PathSensitivity
|
||||
|
||||
plugins {
|
||||
id("com.android.application") version "9.0.0" apply false
|
||||
}
|
||||
|
||||
val repoRoot = rootProject.projectDir.parentFile
|
||||
val skipAndroidLto = providers
|
||||
.gradleProperty("codexAndroidSkipLto")
|
||||
.orElse(providers.environmentVariable("CODEX_ANDROID_SKIP_LTO"))
|
||||
.orNull
|
||||
?.let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||
?: false
|
||||
val codexCargoProfileDir = if (skipAndroidLto) "android-release-no-lto" else "release"
|
||||
val codexTargets = mapOf(
|
||||
"arm64-v8a" to "aarch64-linux-android",
|
||||
"x86_64" to "x86_64-linux-android",
|
||||
)
|
||||
|
||||
tasks.register<Exec>("buildCodexCliNative") {
|
||||
group = "build"
|
||||
description = "Build the Android codex binary packaged into the Agent and Genie APKs."
|
||||
workingDir = repoRoot
|
||||
commandLine("just", "android-build")
|
||||
if (skipAndroidLto) {
|
||||
environment("CODEX_ANDROID_SKIP_LTO", "1")
|
||||
}
|
||||
inputs.files(
|
||||
fileTree(repoRoot.resolve("codex-rs")) {
|
||||
exclude("target/**")
|
||||
},
|
||||
).withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
inputs.file(repoRoot.resolve("justfile"))
|
||||
.withPathSensitivity(PathSensitivity.RELATIVE)
|
||||
outputs.files(
|
||||
codexTargets.values.map { triple ->
|
||||
repoRoot.resolve("codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
|
||||
},
|
||||
)
|
||||
}
|
||||
122
android/genie/build.gradle.kts
Normal file
@@ -0,0 +1,122 @@
|
||||
import org.gradle.api.GradleException
|
||||
import org.gradle.api.tasks.Sync
|
||||
|
||||
plugins {
|
||||
id("com.android.application")
|
||||
}
|
||||
|
||||
val minAndroidJavaVersion = 17
|
||||
val maxAndroidJavaVersion = 21
|
||||
val hostJavaMajorVersion = JavaVersion.current().majorVersion.toIntOrNull()
|
||||
?: throw GradleException("Unable to determine Java version from ${JavaVersion.current()}.")
|
||||
if (hostJavaMajorVersion < minAndroidJavaVersion) {
|
||||
throw GradleException(
|
||||
"Android Genie build requires Java ${minAndroidJavaVersion}+ (tested through Java ${maxAndroidJavaVersion}). Found Java ${hostJavaMajorVersion}."
|
||||
)
|
||||
}
|
||||
val androidJavaTargetVersion = hostJavaMajorVersion.coerceAtMost(maxAndroidJavaVersion)
|
||||
val androidJavaVersion = JavaVersion.toVersion(androidJavaTargetVersion)
|
||||
val agentPlatformStubSdkZip = providers
|
||||
.gradleProperty("agentPlatformStubSdkZip")
|
||||
.orElse(providers.environmentVariable("ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP"))
|
||||
val skipAndroidLto = providers
|
||||
.gradleProperty("codexAndroidSkipLto")
|
||||
.orElse(providers.environmentVariable("CODEX_ANDROID_SKIP_LTO"))
|
||||
.orNull
|
||||
?.let { it == "1" || it.equals("true", ignoreCase = true) }
|
||||
?: false
|
||||
val codexCargoProfileDir = if (skipAndroidLto) "android-release-no-lto" else "release"
|
||||
val extractedAgentPlatformJar = layout.buildDirectory.file(
|
||||
"generated/agent-platform/android-agent-platform-stub-sdk.jar"
|
||||
)
|
||||
val repoRoot = rootProject.projectDir.parentFile
|
||||
val codexTargets = mapOf(
|
||||
"arm64-v8a" to "aarch64-linux-android",
|
||||
"x86_64" to "x86_64-linux-android",
|
||||
)
|
||||
val codexJniDir = layout.buildDirectory.dir("generated/codex-jni")
|
||||
|
||||
android {
|
||||
namespace = "com.openai.codex.genie"
|
||||
compileSdk = 34
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "com.openai.codex.genie"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 1
|
||||
versionName = "0.1.0"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
isMinifyEnabled = false
|
||||
proguardFiles(
|
||||
getDefaultProguardFile("proguard-android-optimize.txt"),
|
||||
"proguard-rules.pro",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
compileOptions {
|
||||
sourceCompatibility = androidJavaVersion
|
||||
targetCompatibility = androidJavaVersion
|
||||
}
|
||||
|
||||
packaging {
|
||||
jniLibs.useLegacyPackaging = true
|
||||
}
|
||||
}
|
||||
|
||||
val extractAgentPlatformStubSdk = tasks.register<Sync>("extractAgentPlatformStubSdk") {
|
||||
val sdkZip = agentPlatformStubSdkZip.orNull
|
||||
?: throw GradleException(
|
||||
"Set ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP or -PagentPlatformStubSdkZip to the Android Agent Platform stub SDK zip."
|
||||
)
|
||||
val outputDir = extractedAgentPlatformJar.get().asFile.parentFile
|
||||
from(zipTree(sdkZip)) {
|
||||
include("payloads/compile_only/android-agent-platform-stub-sdk.jar")
|
||||
eachFile { path = name }
|
||||
includeEmptyDirs = false
|
||||
}
|
||||
into(outputDir)
|
||||
}
|
||||
|
||||
val syncCodexCliJniLibs = tasks.register<Sync>("syncCodexCliJniLibs") {
|
||||
val outputDir = codexJniDir
|
||||
into(outputDir)
|
||||
dependsOn(rootProject.tasks.named("buildCodexCliNative"))
|
||||
|
||||
codexTargets.forEach { (abi, triple) ->
|
||||
val binary = file("${repoRoot}/codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
|
||||
from(binary) {
|
||||
into(abi)
|
||||
rename { "libcodex.so" }
|
||||
}
|
||||
}
|
||||
|
||||
doFirst {
|
||||
codexTargets.forEach { (abi, triple) ->
|
||||
val binary = file("${repoRoot}/codex-rs/target/android/${triple}/${codexCargoProfileDir}/codex")
|
||||
if (!binary.exists()) {
|
||||
throw GradleException(
|
||||
"Missing codex binary for ${abi} at ${binary}. The Gradle native build task should have produced it."
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
android.sourceSets["main"].jniLibs.srcDir(codexJniDir.get().asFile)
|
||||
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn(extractAgentPlatformStubSdk)
|
||||
dependsOn(syncCodexCliJniLibs)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(project(":bridge"))
|
||||
compileOnly(files(extractedAgentPlatformJar))
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
testImplementation("org.json:json:20240303")
|
||||
}
|
||||
1
android/genie/proguard-rules.pro
vendored
Normal file
@@ -0,0 +1 @@
|
||||
# No custom rules yet.
|
||||
15
android/genie/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,15 @@
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<application
|
||||
android:allowBackup="false"
|
||||
android:label="@string/app_name">
|
||||
|
||||
<service
|
||||
android:name=".CodexGenieService"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.BIND_GENIE_SERVICE">
|
||||
<intent-filter>
|
||||
<action android:name="android.app.agent.GenieService" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,164 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
import android.app.agent.GenieService
|
||||
import android.os.Bundle
|
||||
import android.os.ParcelFileDescriptor
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.FrameworkSessionTransportCompat
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import java.io.BufferedInputStream
|
||||
import java.io.BufferedOutputStream
|
||||
import java.io.Closeable
|
||||
import java.io.DataInputStream
|
||||
import java.io.DataOutputStream
|
||||
import java.io.FileInputStream
|
||||
import java.io.FileOutputStream
|
||||
import java.io.IOException
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.util.UUID
|
||||
import org.json.JSONObject
|
||||
|
||||
class AgentBridgeClient(
|
||||
callback: GenieService.Callback,
|
||||
private val sessionId: String,
|
||||
) : Closeable {
|
||||
companion object {
|
||||
private const val TAG = "AgentBridgeClient"
|
||||
private const val OP_GET_RUNTIME_STATUS = "getRuntimeStatus"
|
||||
private const val OP_READ_INSTALLED_AGENTS_FILE = "readInstalledAgentsFile"
|
||||
private const val OP_READ_SESSION_EXECUTION_SETTINGS = "readSessionExecutionSettings"
|
||||
private const val WRITE_CHUNK_BYTES = 4096
|
||||
private const val RESPONSES_METHOD = "POST"
|
||||
private const val DEFAULT_RESPONSES_PATH = "/responses"
|
||||
private const val HEADER_CONTENT_TYPE = "Content-Type"
|
||||
private const val HEADER_ACCEPT = "Accept"
|
||||
private const val HEADER_ACCEPT_ENCODING = "Accept-Encoding"
|
||||
private const val HEADER_VALUE_APPLICATION_JSON = "application/json"
|
||||
private const val HEADER_VALUE_TEXT_EVENT_STREAM = "text/event-stream"
|
||||
private const val HEADER_VALUE_IDENTITY = "identity"
|
||||
}
|
||||
|
||||
private val frameworkCallback = callback
|
||||
private val bridgeFd: ParcelFileDescriptor = callback.openSessionBridge(sessionId)
|
||||
private val input = DataInputStream(BufferedInputStream(FileInputStream(bridgeFd.fileDescriptor)))
|
||||
private val output = DataOutputStream(BufferedOutputStream(FileOutputStream(bridgeFd.fileDescriptor)))
|
||||
private val ioLock = Any()
|
||||
private var frameworkResponsesPath: String = DEFAULT_RESPONSES_PATH
|
||||
|
||||
init {
|
||||
Log.i(TAG, "Using framework session bridge transport for $sessionId")
|
||||
Log.i(TAG, "Using framework-owned HTTP bridge for $sessionId")
|
||||
}
|
||||
|
||||
fun getRuntimeStatus(): CodexAgentBridge.RuntimeStatus {
|
||||
val status = request(
|
||||
JSONObject().put("method", OP_GET_RUNTIME_STATUS),
|
||||
).getJSONObject("runtimeStatus")
|
||||
frameworkResponsesPath = status.optString("frameworkResponsesPath").ifBlank { DEFAULT_RESPONSES_PATH }
|
||||
return CodexAgentBridge.RuntimeStatus(
|
||||
authenticated = status.getBoolean("authenticated"),
|
||||
accountEmail = status.optNullableString("accountEmail"),
|
||||
clientCount = status.optInt("clientCount"),
|
||||
modelProviderId = status.optString("modelProviderId"),
|
||||
configuredModel = status.optNullableString("configuredModel"),
|
||||
effectiveModel = status.optNullableString("effectiveModel"),
|
||||
upstreamBaseUrl = status.optString("upstreamBaseUrl"),
|
||||
frameworkResponsesPath = frameworkResponsesPath,
|
||||
)
|
||||
}
|
||||
|
||||
fun readInstalledAgentsMarkdown(): String {
|
||||
return request(
|
||||
JSONObject().put("method", OP_READ_INSTALLED_AGENTS_FILE),
|
||||
).getString("agentsMarkdown")
|
||||
}
|
||||
|
||||
fun readSessionExecutionSettings(): SessionExecutionSettings {
|
||||
val settings = request(
|
||||
JSONObject().put("method", OP_READ_SESSION_EXECUTION_SETTINGS),
|
||||
).getJSONObject("executionSettings")
|
||||
return SessionExecutionSettings(
|
||||
model = settings.optNullableString("model"),
|
||||
reasoningEffort = settings.optNullableString("reasoningEffort"),
|
||||
)
|
||||
}
|
||||
|
||||
fun sendResponsesRequest(body: String): AgentResponsesHttpResponse {
|
||||
val response = FrameworkSessionTransportCompat.executeStreamingRequest(
|
||||
callback = frameworkCallback,
|
||||
sessionId = sessionId,
|
||||
request = FrameworkSessionTransportCompat.HttpRequest(
|
||||
method = RESPONSES_METHOD,
|
||||
path = frameworkResponsesPath,
|
||||
headers = Bundle().apply {
|
||||
putString(HEADER_CONTENT_TYPE, HEADER_VALUE_APPLICATION_JSON)
|
||||
putString(HEADER_ACCEPT, HEADER_VALUE_TEXT_EVENT_STREAM)
|
||||
putString(HEADER_ACCEPT_ENCODING, HEADER_VALUE_IDENTITY)
|
||||
},
|
||||
body = body.toByteArray(StandardCharsets.UTF_8),
|
||||
),
|
||||
)
|
||||
return AgentResponsesHttpResponse(
|
||||
statusCode = response.statusCode,
|
||||
body = response.bodyString,
|
||||
)
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
synchronized(ioLock) {
|
||||
runCatching { input.close() }
|
||||
runCatching { output.close() }
|
||||
runCatching { bridgeFd.close() }
|
||||
}
|
||||
}
|
||||
|
||||
private fun request(request: JSONObject): JSONObject {
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
synchronized(ioLock) {
|
||||
writeMessage(request.put("requestId", requestId))
|
||||
val response = readMessage()
|
||||
if (response.optString("requestId") != requestId) {
|
||||
throw IOException("Mismatched Agent bridge response id")
|
||||
}
|
||||
if (!response.optBoolean("ok")) {
|
||||
throw IOException(response.optString("error").ifBlank { "Agent bridge request failed" })
|
||||
}
|
||||
return response
|
||||
}
|
||||
}
|
||||
|
||||
private fun writeMessage(message: JSONObject) {
|
||||
val payload = message.toString().toByteArray(StandardCharsets.UTF_8)
|
||||
output.writeInt(payload.size)
|
||||
output.flush()
|
||||
var offset = 0
|
||||
while (offset < payload.size) {
|
||||
val chunkSize = minOf(WRITE_CHUNK_BYTES, payload.size - offset)
|
||||
output.write(payload, offset, chunkSize)
|
||||
output.flush()
|
||||
offset += chunkSize
|
||||
}
|
||||
}
|
||||
|
||||
private fun readMessage(): JSONObject {
|
||||
val size = input.readInt()
|
||||
if (size <= 0) {
|
||||
throw IOException("Invalid Agent bridge message length: $size")
|
||||
}
|
||||
val payload = ByteArray(size)
|
||||
input.readFully(payload)
|
||||
return JSONObject(payload.toString(StandardCharsets.UTF_8))
|
||||
}
|
||||
|
||||
private fun JSONObject.optNullableString(key: String): String? {
|
||||
if (!has(key) || isNull(key)) {
|
||||
return null
|
||||
}
|
||||
return optString(key).ifBlank { null }
|
||||
}
|
||||
|
||||
data class AgentResponsesHttpResponse(
|
||||
val statusCode: Int,
|
||||
val body: String,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
import android.app.agent.GenieService
|
||||
import android.graphics.Bitmap
|
||||
import android.util.Base64
|
||||
import com.openai.codex.bridge.DetachedTargetCompat
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.io.IOException
|
||||
import kotlin.math.max
|
||||
import kotlin.math.roundToInt
|
||||
import org.json.JSONObject
|
||||
|
||||
class AndroidGenieToolExecutor(
|
||||
private val callback: GenieService.Callback,
|
||||
private val sessionId: String,
|
||||
) {
|
||||
companion object {
|
||||
private const val MAX_CAPTURE_LONG_EDGE = 480
|
||||
private const val MAX_CAPTURE_JPEG_BYTES = 48 * 1024
|
||||
private const val INITIAL_JPEG_QUALITY = 65
|
||||
private const val MIN_CAPTURE_JPEG_QUALITY = 38
|
||||
|
||||
const val ENSURE_HIDDEN_TARGET_TOOL = "android_target_ensure_hidden"
|
||||
const val SHOW_TARGET_TOOL = "android_target_show"
|
||||
const val HIDE_TARGET_TOOL = "android_target_hide"
|
||||
const val ATTACH_TARGET_TOOL = "android_target_attach"
|
||||
const val CLOSE_TARGET_TOOL = "android_target_close"
|
||||
const val CAPTURE_TARGET_FRAME_TOOL = "android_target_capture_frame"
|
||||
}
|
||||
|
||||
fun execute(
|
||||
toolName: String,
|
||||
@Suppress("UNUSED_PARAMETER") arguments: JSONObject,
|
||||
): GenieToolObservation {
|
||||
return when (toolName) {
|
||||
ENSURE_HIDDEN_TARGET_TOOL -> requestTargetVisibility(
|
||||
action = "ensure hidden",
|
||||
request = {
|
||||
DetachedTargetCompat.ensureDetachedTargetHidden(callback, sessionId)
|
||||
},
|
||||
attemptRecovery = false,
|
||||
)
|
||||
SHOW_TARGET_TOOL -> requestTargetVisibility(
|
||||
action = "show",
|
||||
request = {
|
||||
DetachedTargetCompat.showDetachedTarget(callback, sessionId)
|
||||
},
|
||||
)
|
||||
HIDE_TARGET_TOOL -> requestTargetVisibility(
|
||||
action = "hide",
|
||||
request = {
|
||||
DetachedTargetCompat.hideDetachedTarget(callback, sessionId)
|
||||
},
|
||||
)
|
||||
ATTACH_TARGET_TOOL -> requestTargetVisibility(
|
||||
action = "attach",
|
||||
request = {
|
||||
DetachedTargetCompat.attachDetachedTarget(callback, sessionId)
|
||||
},
|
||||
)
|
||||
CLOSE_TARGET_TOOL -> requestTargetVisibility(
|
||||
action = "close",
|
||||
request = {
|
||||
DetachedTargetCompat.closeDetachedTarget(callback, sessionId)
|
||||
},
|
||||
attemptRecovery = false,
|
||||
)
|
||||
CAPTURE_TARGET_FRAME_TOOL -> captureDetachedTargetFrame()
|
||||
else -> throw IOException("Unknown tool: $toolName")
|
||||
}
|
||||
}
|
||||
|
||||
private fun requestTargetVisibility(
|
||||
action: String,
|
||||
request: () -> DetachedTargetCompat.DetachedTargetControlResult,
|
||||
attemptRecovery: Boolean = true,
|
||||
): GenieToolObservation {
|
||||
val recoveryDetails = mutableListOf<String>()
|
||||
var result = request()
|
||||
if (attemptRecovery && result.needsRecovery()) {
|
||||
val recovery = DetachedTargetCompat.ensureDetachedTargetHidden(callback, sessionId)
|
||||
recoveryDetails += recovery.summary("ensure hidden")
|
||||
if (recovery.isOk()) {
|
||||
result = request()
|
||||
} else {
|
||||
throw IOException(
|
||||
"${result.summary(action)} Recovery failed: ${recovery.summary("ensure hidden")}",
|
||||
)
|
||||
}
|
||||
}
|
||||
if (!result.isOk()) {
|
||||
throw IOException(result.summary(action))
|
||||
}
|
||||
val promptDetails = buildString {
|
||||
append(result.summary(action))
|
||||
recoveryDetails.forEach { detail ->
|
||||
append("\n")
|
||||
append(detail)
|
||||
}
|
||||
}
|
||||
return GenieToolObservation(
|
||||
name = "android_target_" + action.replace(' ', '_'),
|
||||
summary = promptDetails.lineSequence().first(),
|
||||
promptDetails = promptDetails,
|
||||
)
|
||||
}
|
||||
|
||||
private fun captureDetachedTargetFrame(): GenieToolObservation {
|
||||
val recoveryDetails = mutableListOf<String>()
|
||||
var capture = DetachedTargetCompat.captureDetachedTargetFrameResult(callback, sessionId)
|
||||
if (capture.needsRecovery()) {
|
||||
val recovery = DetachedTargetCompat.ensureDetachedTargetHidden(callback, sessionId)
|
||||
recoveryDetails += recovery.summary("ensure hidden")
|
||||
if (recovery.isOk()) {
|
||||
capture = DetachedTargetCompat.captureDetachedTargetFrameResult(callback, sessionId)
|
||||
} else {
|
||||
throw IOException("${capture.summary()} Recovery failed: ${recovery.summary("ensure hidden")}")
|
||||
}
|
||||
}
|
||||
if (!capture.isOk()) {
|
||||
throw IOException(capture.summary())
|
||||
}
|
||||
val result = checkNotNull(capture.captureResult)
|
||||
val hardwareBuffer = result.hardwareBuffer ?: throw IOException("Detached frame missing hardware buffer")
|
||||
val bitmap = Bitmap.wrapHardwareBuffer(hardwareBuffer, result.colorSpace)
|
||||
?: throw IOException("Failed to wrap detached frame")
|
||||
val copy = bitmap.copy(Bitmap.Config.ARGB_8888, false)
|
||||
?: throw IOException("Failed to copy detached frame")
|
||||
val (encodedBitmap, jpeg) = encodeDetachedFrame(copy)
|
||||
return GenieToolObservation(
|
||||
name = CAPTURE_TARGET_FRAME_TOOL,
|
||||
summary = "Captured detached target frame ${encodedBitmap.width}x${encodedBitmap.height} (${capture.targetRuntime.label}).",
|
||||
promptDetails = buildString {
|
||||
append(
|
||||
"Captured detached target frame ${encodedBitmap.width}x${encodedBitmap.height}. Runtime=${capture.targetRuntime.label}. JPEG=${jpeg.size} bytes.",
|
||||
)
|
||||
recoveryDetails.forEach { detail ->
|
||||
append("\n")
|
||||
append(detail)
|
||||
}
|
||||
append("\nUse the attached image to inspect the current UI.")
|
||||
},
|
||||
imageDataUrls = listOf(
|
||||
"data:image/jpeg;base64," + Base64.encodeToString(jpeg, Base64.NO_WRAP),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun encodeDetachedFrame(bitmap: Bitmap): Pair<Bitmap, ByteArray> {
|
||||
var encodedBitmap = bitmap.downscaleIfNeeded(MAX_CAPTURE_LONG_EDGE)
|
||||
var quality = INITIAL_JPEG_QUALITY
|
||||
var jpeg = encodedBitmap.encodeJpeg(quality)
|
||||
while (jpeg.size > MAX_CAPTURE_JPEG_BYTES && quality > MIN_CAPTURE_JPEG_QUALITY) {
|
||||
quality -= 7
|
||||
jpeg = encodedBitmap.encodeJpeg(quality)
|
||||
}
|
||||
while (jpeg.size > MAX_CAPTURE_JPEG_BYTES) {
|
||||
val nextWidth = max((encodedBitmap.width * 0.8f).roundToInt(), 1)
|
||||
val nextHeight = max((encodedBitmap.height * 0.8f).roundToInt(), 1)
|
||||
if (nextWidth == encodedBitmap.width && nextHeight == encodedBitmap.height) {
|
||||
break
|
||||
}
|
||||
val scaled = Bitmap.createScaledBitmap(encodedBitmap, nextWidth, nextHeight, true)
|
||||
if (encodedBitmap !== bitmap) {
|
||||
encodedBitmap.recycle()
|
||||
}
|
||||
encodedBitmap = scaled
|
||||
quality = INITIAL_JPEG_QUALITY
|
||||
jpeg = encodedBitmap.encodeJpeg(quality)
|
||||
while (jpeg.size > MAX_CAPTURE_JPEG_BYTES && quality > MIN_CAPTURE_JPEG_QUALITY) {
|
||||
quality -= 7
|
||||
jpeg = encodedBitmap.encodeJpeg(quality)
|
||||
}
|
||||
}
|
||||
return encodedBitmap to jpeg
|
||||
}
|
||||
|
||||
private fun Bitmap.downscaleIfNeeded(maxLongEdge: Int): Bitmap {
|
||||
val longEdge = max(width, height)
|
||||
if (longEdge <= maxLongEdge) {
|
||||
return this
|
||||
}
|
||||
val scale = maxLongEdge.toFloat() / longEdge.toFloat()
|
||||
val scaledWidth = max((width * scale).roundToInt(), 1)
|
||||
val scaledHeight = max((height * scale).roundToInt(), 1)
|
||||
return Bitmap.createScaledBitmap(this, scaledWidth, scaledHeight, true)
|
||||
}
|
||||
|
||||
private fun Bitmap.encodeJpeg(quality: Int): ByteArray {
|
||||
return ByteArrayOutputStream().use { output ->
|
||||
if (!compress(Bitmap.CompressFormat.JPEG, quality, output)) {
|
||||
throw IOException("Failed to encode detached frame")
|
||||
}
|
||||
output.toByteArray()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
import java.io.IOException
|
||||
|
||||
object CodexAgentBridge {
|
||||
fun buildResponsesRequest(
|
||||
model: String,
|
||||
instructions: String,
|
||||
prompt: String,
|
||||
imageDataUrls: List<String> = emptyList(),
|
||||
): JSONObject {
|
||||
val content = JSONArray().put(
|
||||
JSONObject()
|
||||
.put("type", "input_text")
|
||||
.put("text", prompt),
|
||||
)
|
||||
imageDataUrls.forEach { imageDataUrl ->
|
||||
content.put(
|
||||
JSONObject()
|
||||
.put("type", "input_image")
|
||||
.put("image_url", imageDataUrl),
|
||||
)
|
||||
}
|
||||
return JSONObject()
|
||||
.put("model", model)
|
||||
.put("store", false)
|
||||
.put("stream", true)
|
||||
.put("instructions", instructions)
|
||||
.put(
|
||||
"input",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("role", "user")
|
||||
.put("content", content),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
data class RuntimeStatus(
|
||||
val authenticated: Boolean,
|
||||
val accountEmail: String?,
|
||||
val clientCount: Int,
|
||||
val modelProviderId: String,
|
||||
val configuredModel: String?,
|
||||
val effectiveModel: String?,
|
||||
val upstreamBaseUrl: String,
|
||||
val frameworkResponsesPath: String,
|
||||
)
|
||||
|
||||
data class HttpResponse(
|
||||
val statusCode: Int,
|
||||
val body: String,
|
||||
)
|
||||
|
||||
fun parseResponsesOutputText(httpResponse: HttpResponse): String {
|
||||
if (httpResponse.statusCode != 200) {
|
||||
throw IOException("HTTP ${httpResponse.statusCode}: ${httpResponse.body}")
|
||||
}
|
||||
val body = httpResponse.body.trim()
|
||||
if (body.startsWith("event:") || body.startsWith("data:")) {
|
||||
return parseResponsesStreamOutputText(body)
|
||||
}
|
||||
val data = JSONObject(body)
|
||||
return parseResponsesJsonOutputText(data)
|
||||
}
|
||||
|
||||
private fun parseResponsesJsonOutputText(data: JSONObject): String {
|
||||
val directOutput = data.optString("output_text")
|
||||
if (directOutput.isNotBlank()) {
|
||||
return directOutput
|
||||
}
|
||||
val output = data.optJSONArray("output")
|
||||
?: throw IOException("Responses payload missing output")
|
||||
val combined = buildString {
|
||||
for (outputIndex in 0 until output.length()) {
|
||||
val item = output.optJSONObject(outputIndex) ?: continue
|
||||
val content = item.optJSONArray("content") ?: continue
|
||||
for (contentIndex in 0 until content.length()) {
|
||||
val part = content.optJSONObject(contentIndex) ?: continue
|
||||
if (part.optString("type") == "output_text") {
|
||||
append(part.optString("text"))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (combined.isBlank()) {
|
||||
throw IOException("Responses payload missing output_text content")
|
||||
}
|
||||
return combined
|
||||
}
|
||||
|
||||
private fun parseResponsesStreamOutputText(body: String): String {
|
||||
val deltaText = StringBuilder()
|
||||
val completedItems = mutableListOf<String>()
|
||||
body.split("\n\n").forEach { rawEvent ->
|
||||
val lines = rawEvent.lineSequence().map(String::trimEnd).toList()
|
||||
if (lines.isEmpty()) {
|
||||
return@forEach
|
||||
}
|
||||
val dataPayload = lines
|
||||
.filter { it.startsWith("data:") }
|
||||
.joinToString("\n") { it.removePrefix("data:").trimStart() }
|
||||
.trim()
|
||||
if (dataPayload.isEmpty() || dataPayload == "[DONE]") {
|
||||
return@forEach
|
||||
}
|
||||
val event = JSONObject(dataPayload)
|
||||
when (event.optString("type")) {
|
||||
"response.output_text.delta" -> deltaText.append(event.optString("delta"))
|
||||
"response.output_item.done" -> {
|
||||
val item = event.optJSONObject("item") ?: return@forEach
|
||||
val content = item.optJSONArray("content") ?: return@forEach
|
||||
val text = buildString {
|
||||
for (index in 0 until content.length()) {
|
||||
val part = content.optJSONObject(index) ?: continue
|
||||
if (part.optString("type") == "output_text") {
|
||||
append(part.optString("text"))
|
||||
}
|
||||
}
|
||||
}
|
||||
if (text.isNotBlank()) {
|
||||
completedItems += text
|
||||
}
|
||||
}
|
||||
"response.failed" -> {
|
||||
throw IOException(event.toString())
|
||||
}
|
||||
}
|
||||
}
|
||||
if (deltaText.isNotBlank()) {
|
||||
return deltaText.toString()
|
||||
}
|
||||
val completedText = completedItems.joinToString("")
|
||||
if (completedText.isNotBlank()) {
|
||||
return completedText
|
||||
}
|
||||
throw IOException("Responses stream missing output_text content")
|
||||
}
|
||||
}
|
||||
|
||||
internal fun JSONObject.optNullableString(name: String): String? = when {
|
||||
isNull(name) -> null
|
||||
else -> optString(name).ifBlank { null }
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.app.agent.GenieRequest
|
||||
import android.app.agent.GenieService
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.HostedCodexConfig
|
||||
import com.openai.codex.bridge.SessionExecutionSettings
|
||||
import java.io.BufferedWriter
|
||||
import java.io.Closeable
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
import java.io.InterruptedIOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
class CodexAppServerHost(
|
||||
private val context: Context,
|
||||
private val request: GenieRequest,
|
||||
private val callback: GenieService.Callback,
|
||||
private val control: GenieSessionControl,
|
||||
private val bridgeClient: AgentBridgeClient,
|
||||
private val runtimeStatus: CodexAgentBridge.RuntimeStatus,
|
||||
) : Closeable {
|
||||
companion object {
|
||||
private const val TAG = "CodexAppServerHost"
|
||||
private const val APP_SERVER_BRIDGE_ENV_VAR = "CODEX_OPENAI_APP_SERVER_BRIDGE"
|
||||
private const val REQUEST_TIMEOUT_MS = 30_000L
|
||||
private const val POLL_TIMEOUT_MS = 250L
|
||||
private const val DEFAULT_HOSTED_MODEL = "gpt-5.3-codex"
|
||||
}
|
||||
|
||||
private val requestIdSequence = AtomicInteger(1)
|
||||
private val pendingResponses = ConcurrentHashMap<String, LinkedBlockingQueue<JSONObject>>()
|
||||
private val inboundMessages = LinkedBlockingQueue<JSONObject>()
|
||||
private val writerLock = Any()
|
||||
private val streamedAgentMessages = mutableMapOf<String, StringBuilder>()
|
||||
|
||||
private lateinit var process: Process
|
||||
private lateinit var writer: BufferedWriter
|
||||
private lateinit var codexHome: File
|
||||
private lateinit var executionSettings: SessionExecutionSettings
|
||||
private var stdoutThread: Thread? = null
|
||||
private var stderrThread: Thread? = null
|
||||
private var finalAgentMessage: String? = null
|
||||
private var resultPublished = false
|
||||
fun run() {
|
||||
startProcess()
|
||||
initialize()
|
||||
executionSettings = bridgeClient.readSessionExecutionSettings()
|
||||
val model = resolveModel()
|
||||
val threadId = startThread(model)
|
||||
startTurn(threadId, model)
|
||||
callback.publishTrace(request.sessionId, "Hosted codex app-server thread $threadId for ${request.targetPackage}.")
|
||||
eventLoop()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
stdoutThread?.interrupt()
|
||||
stderrThread?.interrupt()
|
||||
synchronized(writerLock) {
|
||||
runCatching { writer.close() }
|
||||
}
|
||||
if (::codexHome.isInitialized) {
|
||||
runCatching { codexHome.deleteRecursively() }
|
||||
}
|
||||
if (::process.isInitialized) {
|
||||
process.destroy()
|
||||
}
|
||||
control.process = null
|
||||
}
|
||||
|
||||
private fun startProcess() {
|
||||
codexHome = File(context.cacheDir, "codex-home/${request.sessionId}").apply {
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
HostedCodexConfig.installAgentsFile(codexHome, bridgeClient.readInstalledAgentsMarkdown())
|
||||
val processBuilder = ProcessBuilder(
|
||||
listOf(
|
||||
CodexBinaryLocator.resolve(context).absolutePath,
|
||||
"-c",
|
||||
"enable_request_compression=false",
|
||||
"app-server",
|
||||
"--listen",
|
||||
"stdio://",
|
||||
),
|
||||
)
|
||||
val env = processBuilder.environment()
|
||||
env["CODEX_HOME"] = codexHome.absolutePath
|
||||
env[APP_SERVER_BRIDGE_ENV_VAR] = "1"
|
||||
env["RUST_LOG"] = "warn"
|
||||
process = processBuilder.start()
|
||||
control.process = process
|
||||
writer = process.outputStream.bufferedWriter()
|
||||
startStdoutPump()
|
||||
startStderrPump()
|
||||
}
|
||||
|
||||
private fun startStdoutPump() {
|
||||
stdoutThread = Thread {
|
||||
try {
|
||||
process.inputStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
if (line.isBlank()) {
|
||||
return@forEach
|
||||
}
|
||||
val message = runCatching { JSONObject(line) }
|
||||
.getOrElse { err ->
|
||||
Log.w(TAG, "Failed to parse codex app-server stdout line", err)
|
||||
return@forEach
|
||||
}
|
||||
routeInbound(message)
|
||||
}
|
||||
}
|
||||
} catch (_: InterruptedIOException) {
|
||||
// Expected when the hosted app-server exits and the stream closes underneath the reader.
|
||||
} catch (err: IOException) {
|
||||
if (!control.cancelled && process.isAlive) {
|
||||
Log.w(TAG, "Stdout pump failed for ${request.sessionId}", err)
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
it.name = "CodexAppServerStdout-${request.sessionId}"
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startStderrPump() {
|
||||
stderrThread = Thread {
|
||||
try {
|
||||
process.errorStream.bufferedReader().useLines { lines ->
|
||||
lines.forEach { line ->
|
||||
if (line.isBlank()) {
|
||||
return@forEach
|
||||
}
|
||||
when {
|
||||
line.contains(" ERROR ") -> Log.e(TAG, line)
|
||||
line.contains(" WARN ") || line.startsWith("WARNING:") -> Log.w(TAG, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (_: InterruptedIOException) {
|
||||
// Expected when the hosted app-server exits and the stream closes underneath the reader.
|
||||
} catch (err: IOException) {
|
||||
if (!control.cancelled && process.isAlive) {
|
||||
Log.w(TAG, "Stderr pump failed for ${request.sessionId}", err)
|
||||
}
|
||||
}
|
||||
}.also {
|
||||
it.name = "CodexAppServerStderr-${request.sessionId}"
|
||||
it.start()
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeInbound(message: JSONObject) {
|
||||
if (message.has("id") && !message.has("method")) {
|
||||
pendingResponses[message.get("id").toString()]?.offer(message)
|
||||
return
|
||||
}
|
||||
inboundMessages.offer(message)
|
||||
}
|
||||
|
||||
private fun initialize() {
|
||||
request(
|
||||
method = "initialize",
|
||||
params = JSONObject()
|
||||
.put(
|
||||
"clientInfo",
|
||||
JSONObject()
|
||||
.put("name", "android_genie")
|
||||
.put("title", "Android Genie")
|
||||
.put("version", "0.1.0"),
|
||||
)
|
||||
.put(
|
||||
"capabilities",
|
||||
JSONObject().put("experimentalApi", true),
|
||||
),
|
||||
)
|
||||
notify("initialized", JSONObject())
|
||||
}
|
||||
|
||||
private fun startThread(model: String): String {
|
||||
val params = JSONObject()
|
||||
.put("approvalPolicy", "never")
|
||||
.put("sandbox", "read-only")
|
||||
.put("ephemeral", true)
|
||||
.put("cwd", context.filesDir.absolutePath)
|
||||
.put("serviceName", "android_genie")
|
||||
.put("baseInstructions", buildBaseInstructions())
|
||||
.put("dynamicTools", buildDynamicToolSpecs())
|
||||
params.put("model", model)
|
||||
val result = request(
|
||||
method = "thread/start",
|
||||
params = params,
|
||||
)
|
||||
return result.getJSONObject("thread").getString("id")
|
||||
}
|
||||
|
||||
private fun startTurn(
|
||||
threadId: String,
|
||||
model: String,
|
||||
) {
|
||||
Log.i(TAG, "Starting hosted turn for ${request.sessionId} with model=$model")
|
||||
request(
|
||||
method = "turn/start",
|
||||
params = JSONObject()
|
||||
.put("threadId", threadId)
|
||||
.put("model", model)
|
||||
.apply {
|
||||
executionSettings.reasoningEffort
|
||||
?.takeIf(String::isNotBlank)
|
||||
?.let { put("effort", it) }
|
||||
}
|
||||
.put(
|
||||
"input",
|
||||
JSONArray().put(
|
||||
JSONObject()
|
||||
.put("type", "text")
|
||||
.put("text", buildDelegatedPrompt()),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun resolveModel(): String = executionSettings.model
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: runtimeStatus.configuredModel
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: runtimeStatus.effectiveModel
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: DEFAULT_HOSTED_MODEL
|
||||
|
||||
private fun eventLoop() {
|
||||
while (!control.cancelled) {
|
||||
val message = inboundMessages.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
if (message == null) {
|
||||
if (!process.isAlive) {
|
||||
throw IOException("codex app-server exited with code ${process.exitValue()}")
|
||||
}
|
||||
continue
|
||||
}
|
||||
if (message.has("method") && message.has("id")) {
|
||||
handleServerRequest(message)
|
||||
continue
|
||||
}
|
||||
if (message.has("method") && handleNotification(message)) {
|
||||
return
|
||||
}
|
||||
}
|
||||
throw IOException("Cancelled")
|
||||
}
|
||||
|
||||
private fun handleServerRequest(message: JSONObject) {
|
||||
val method = message.getString("method")
|
||||
val requestId = message.get("id")
|
||||
val params = message.optJSONObject("params") ?: JSONObject()
|
||||
Log.i(TAG, "Handling app-server request method=$method session=${request.sessionId}")
|
||||
when (method) {
|
||||
"item/tool/call" -> handleDynamicToolCall(requestId, params)
|
||||
"item/tool/requestUserInput" -> handleRequestUserInput(requestId, params)
|
||||
"response/send" -> handleResponsesBridgeRequest(requestId, params)
|
||||
else -> {
|
||||
callback.publishTrace(request.sessionId, "Unsupported codex app-server request: $method")
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32601,
|
||||
message = "Unsupported app-server request: $method",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleDynamicToolCall(
|
||||
requestId: Any,
|
||||
params: JSONObject,
|
||||
) {
|
||||
val toolName = params.optString("tool").trim()
|
||||
val arguments = params.optJSONObject("arguments") ?: JSONObject()
|
||||
Log.i(TAG, "Executing dynamic tool $toolName arguments=$arguments")
|
||||
val toolExecutor = AndroidGenieToolExecutor(
|
||||
callback = callback,
|
||||
sessionId = request.sessionId,
|
||||
)
|
||||
val observation = runCatching {
|
||||
toolExecutor.execute(toolName, arguments)
|
||||
}.getOrElse { err ->
|
||||
GenieToolObservation(
|
||||
name = toolName.ifBlank { "unknown" },
|
||||
summary = "Tool $toolName failed: ${err.message}",
|
||||
promptDetails = "Tool $toolName failed.\nError: ${err.message ?: err::class.java.simpleName}",
|
||||
)
|
||||
}
|
||||
callback.publishTrace(request.sessionId, observation.summary)
|
||||
sendResult(
|
||||
requestId = requestId,
|
||||
result = JSONObject()
|
||||
.put("success", !observation.summary.contains(" failed:"))
|
||||
.put("contentItems", buildDynamicToolContentItems(observation)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleRequestUserInput(
|
||||
requestId: Any,
|
||||
params: JSONObject,
|
||||
) {
|
||||
val questions = params.optJSONArray("questions") ?: JSONArray()
|
||||
val renderedQuestion = renderAgentQuestion(questions)
|
||||
Log.i(TAG, "Requesting Agent input for ${request.sessionId}: $renderedQuestion")
|
||||
callback.publishQuestion(request.sessionId, renderedQuestion)
|
||||
callback.updateState(request.sessionId, AgentSessionInfo.STATE_WAITING_FOR_USER)
|
||||
val answer = control.waitForUserResponse()
|
||||
callback.updateState(request.sessionId, AgentSessionInfo.STATE_RUNNING)
|
||||
callback.publishTrace(request.sessionId, "Received Agent answer for ${request.targetPackage}.")
|
||||
Log.i(TAG, "Received Agent input for ${request.sessionId}: ${answer.take(160)}")
|
||||
sendResult(
|
||||
requestId = requestId,
|
||||
result = JSONObject().put("answers", buildQuestionAnswers(questions, answer)),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleResponsesBridgeRequest(
|
||||
requestId: Any,
|
||||
params: JSONObject,
|
||||
) {
|
||||
val requestBody = params.optString("requestBody")
|
||||
val httpResponse = bridgeClient.sendResponsesRequest(requestBody)
|
||||
sendResult(
|
||||
requestId = requestId,
|
||||
result = JSONObject()
|
||||
.put("statusCode", httpResponse.statusCode)
|
||||
.put("body", httpResponse.body),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleNotification(message: JSONObject): Boolean {
|
||||
val method = message.getString("method")
|
||||
val params = message.optJSONObject("params") ?: JSONObject()
|
||||
return when (method) {
|
||||
"turn/started" -> {
|
||||
callback.publishTrace(request.sessionId, "codex turn started for ${request.targetPackage}.")
|
||||
false
|
||||
}
|
||||
"item/agentMessage/delta" -> {
|
||||
val itemId = params.optString("itemId")
|
||||
if (itemId.isNotBlank()) {
|
||||
streamedAgentMessages.getOrPut(itemId, ::StringBuilder)
|
||||
.append(params.optString("delta"))
|
||||
}
|
||||
false
|
||||
}
|
||||
"item/started" -> {
|
||||
publishItemStartedTrace(params.optJSONObject("item"))
|
||||
false
|
||||
}
|
||||
"item/completed" -> {
|
||||
captureCompletedItem(params.optJSONObject("item"))
|
||||
false
|
||||
}
|
||||
"turn/completed" -> {
|
||||
finishTurn(params)
|
||||
true
|
||||
}
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishItemStartedTrace(item: JSONObject?) {
|
||||
if (item == null) {
|
||||
return
|
||||
}
|
||||
val command = commandForItem(item)
|
||||
Log.i(
|
||||
TAG,
|
||||
"item/started type=${item.optString("type")} tool=${item.optString("tool")} command=${command ?: ""}",
|
||||
)
|
||||
when (item.optString("type")) {
|
||||
"dynamicToolCall" -> {
|
||||
val tool = item.optString("tool")
|
||||
callback.publishTrace(request.sessionId, "Codex requested dynamic tool $tool.")
|
||||
}
|
||||
"commandExecution" -> {
|
||||
if (request.isDetachedModeAllowed && command != null) {
|
||||
check(!DetachedSessionGuard.isForbiddenTargetLaunchCommand(command, request.targetPackage)) {
|
||||
DetachedSessionGuard.violationMessage(request.targetPackage, command)
|
||||
}
|
||||
}
|
||||
callback.publishTrace(
|
||||
request.sessionId,
|
||||
"Codex started command execution: ${command ?: "command"}",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun captureCompletedItem(item: JSONObject?) {
|
||||
if (item == null) {
|
||||
return
|
||||
}
|
||||
val command = commandForItem(item)
|
||||
val errorDetail = item.optString("aggregatedOutput").ifBlank {
|
||||
item.optString("stderr").ifBlank {
|
||||
item.optString("output").ifBlank {
|
||||
item.optString("error")
|
||||
}
|
||||
}
|
||||
}.trim()
|
||||
Log.i(
|
||||
TAG,
|
||||
"item/completed type=${item.optString("type")} status=${item.optString("status")} tool=${item.optString("tool")} command=${command ?: ""} error=${errorDetail.take(200)}",
|
||||
)
|
||||
when (item.optString("type")) {
|
||||
"agentMessage" -> {
|
||||
val itemId = item.optString("id")
|
||||
val text = item.optString("text").ifBlank {
|
||||
streamedAgentMessages[itemId]?.toString().orEmpty()
|
||||
}
|
||||
if (text.isNotBlank()) {
|
||||
finalAgentMessage = text
|
||||
}
|
||||
}
|
||||
"commandExecution" -> {
|
||||
val status = item.optString("status")
|
||||
val exitCode = if (item.has("exitCode")) item.opt("exitCode") else null
|
||||
val resolvedCommand = command ?: "command"
|
||||
if (status == "failed") {
|
||||
Log.i(TAG, "Failed command item=${item}")
|
||||
val detailSuffix = errorDetail
|
||||
.takeIf(String::isNotBlank)
|
||||
?.let { " Details: ${it.take(240)}" }
|
||||
.orEmpty()
|
||||
callback.publishTrace(
|
||||
request.sessionId,
|
||||
"Command failed: $resolvedCommand (status=$status, exitCode=${exitCode ?: "unknown"}).$detailSuffix",
|
||||
)
|
||||
if (errorDetail.contains("package=com.android.shell does not belong to uid=")) {
|
||||
callback.publishTrace(
|
||||
request.sessionId,
|
||||
"This shell command requires com.android.shell privileges. The target is already running hidden; use detached-target dynamic tools to show or inspect it instead of retrying the same shell launch surface.",
|
||||
)
|
||||
}
|
||||
} else {
|
||||
callback.publishTrace(
|
||||
request.sessionId,
|
||||
"Command completed: $resolvedCommand (status=$status, exitCode=${exitCode ?: "unknown"}).",
|
||||
)
|
||||
}
|
||||
}
|
||||
"dynamicToolCall" -> {
|
||||
val tool = item.optString("tool")
|
||||
val status = item.optString("status")
|
||||
callback.publishTrace(request.sessionId, "Dynamic tool $tool completed with status=$status.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun commandForItem(item: JSONObject): String? {
|
||||
return item.optString("command")
|
||||
.takeIf(String::isNotBlank)
|
||||
?: item.optJSONArray("command")?.join(" ")
|
||||
}
|
||||
|
||||
private fun finishTurn(params: JSONObject) {
|
||||
val turn = params.optJSONObject("turn") ?: JSONObject()
|
||||
Log.i(TAG, "turn/completed status=${turn.optString("status")} error=${turn.opt("error")}")
|
||||
when (turn.optString("status")) {
|
||||
"completed" -> {
|
||||
val resultText = finalAgentMessage?.takeIf(String::isNotBlank)
|
||||
?: "Genie completed without a final assistant message."
|
||||
publishResultOnce(resultText)
|
||||
callback.updateState(request.sessionId, AgentSessionInfo.STATE_COMPLETED)
|
||||
}
|
||||
"interrupted" -> {
|
||||
callback.publishError(request.sessionId, "Genie turn interrupted")
|
||||
callback.updateState(request.sessionId, AgentSessionInfo.STATE_CANCELLED)
|
||||
}
|
||||
else -> {
|
||||
val errorDetail = turn.opt("error")?.toString()
|
||||
?: "Genie turn failed with status ${turn.optString("status", "unknown")}"
|
||||
callback.publishError(request.sessionId, errorDetail)
|
||||
callback.updateState(request.sessionId, AgentSessionInfo.STATE_FAILED)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun publishResultOnce(text: String) {
|
||||
if (resultPublished) {
|
||||
return
|
||||
}
|
||||
resultPublished = true
|
||||
callback.publishResult(request.sessionId, text)
|
||||
}
|
||||
|
||||
private fun request(
|
||||
method: String,
|
||||
params: JSONObject,
|
||||
): JSONObject {
|
||||
val requestId = requestIdSequence.getAndIncrement().toString()
|
||||
val responseQueue = LinkedBlockingQueue<JSONObject>(1)
|
||||
pendingResponses[requestId] = responseQueue
|
||||
try {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("id", requestId)
|
||||
.put("method", method)
|
||||
.put("params", params),
|
||||
)
|
||||
val response = responseQueue.poll(REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
?: throw IOException("Timed out waiting for $method response")
|
||||
val error = response.optJSONObject("error")
|
||||
if (error != null) {
|
||||
throw IOException("$method failed: ${error.optString("message", error.toString())}")
|
||||
}
|
||||
return response.optJSONObject("result") ?: JSONObject()
|
||||
} finally {
|
||||
pendingResponses.remove(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notify(
|
||||
method: String,
|
||||
params: JSONObject,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("method", method)
|
||||
.put("params", params),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendResult(
|
||||
requestId: Any,
|
||||
result: JSONObject,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("id", requestId)
|
||||
.put("result", result),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendError(
|
||||
requestId: Any,
|
||||
code: Int,
|
||||
message: String,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("id", requestId)
|
||||
.put(
|
||||
"error",
|
||||
JSONObject()
|
||||
.put("code", code)
|
||||
.put("message", message),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendMessage(message: JSONObject) {
|
||||
synchronized(writerLock) {
|
||||
writer.write(message.toString())
|
||||
writer.newLine()
|
||||
writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildBaseInstructions(): String {
|
||||
val detachedSessionInstructions = if (request.isDetachedModeAllowed) {
|
||||
DetachedSessionGuard.instructions(request.targetPackage)
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return """
|
||||
You are Codex acting as a child Android Genie bound to ${request.targetPackage}.
|
||||
The user interacts only with the supervising Agent.
|
||||
Decide your own local plan and choose tools yourself.
|
||||
Prefer direct self-targeted Android shell commands and intents first when they can satisfy the objective without UI-driving.
|
||||
In this platform build, an active Genie session may use self-targeted shell surfaces such as `am start --user 0`, `cmd activity start-activity --user 0`, `cmd package resolve-activity`, `cmd package query-activities --user 0`, `input`, `uiautomator dump`, `screencap`, and `screenrecord`.
|
||||
When using `am start`, `cmd activity start-activity`, or `cmd package query-activities`, pass `--user 0`; omitting it can fail with cross-user permission errors.
|
||||
Android shell `date` is not GNU coreutils `date`; do not rely on `date -d "+5 minutes"` or similar relative-date parsing because it fails on this platform.
|
||||
When you must convert a relative request like “in 5 minutes” into wall-clock alarm fields, compute it with shell arithmetic from `date +%H` and `date +%M`, for example: `h=$(date +%H); m=$(date +%M); total=$((10#${'$'}h * 60 + 10#${'$'}m + 5)); hour=$(((total / 60) % 24)); minute=$((total % 60))`, then pass `hour` and `minute` as integers to `am start`.
|
||||
When the objective is a timer duration rather than a wall-clock alarm, prefer direct duration-based intents like `android.intent.action.SET_TIMER` with a length in seconds instead of computing a future clock time.
|
||||
Avoid `dumpsys` and `cmd package dump` for package/activity inspection because they require `android.permission.DUMP` in the paired app UID and will not help you complete the task.
|
||||
If a direct command or intent clearly accomplishes the objective, stop and report success instead of continuing exploratory UI actions.
|
||||
The Genie may request detached target launch through the framework callback, and after that it should treat the target as already launched by the framework.
|
||||
Use detached-target tools to show or inspect the target, then continue with supported shell input and inspection surfaces rather than relaunching the target package.
|
||||
If detached recovery is needed because the target disappeared, use android_target_ensure_hidden before retrying UI inspection.
|
||||
Use Android dynamic tools only for framework-only detached target operations that do not have a working shell equivalent in the paired app sandbox.
|
||||
$detachedSessionInstructions
|
||||
The delegated objective may include a required final target presentation such as ATTACHED, DETACHED_HIDDEN, or DETACHED_SHOWN. Treat that as a hard completion requirement and do not report success until the framework session actually matches it.
|
||||
If you need clarification or a decision from the supervising Agent, call request_user_input with concise free-form question text.
|
||||
Do not use hidden control protocols.
|
||||
Finish with a normal assistant message describing what you accomplished or what blocked you.
|
||||
Detached target mode allowed: ${request.isDetachedModeAllowed}.
|
||||
Agent-owned runtime provider: ${runtimeStatus.modelProviderId}.
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun buildDelegatedPrompt(): String {
|
||||
val detachedSessionPrompt = if (request.isDetachedModeAllowed) {
|
||||
"""
|
||||
|
||||
Detached-session requirement:
|
||||
- The framework already launched ${request.targetPackage} hidden for this session.
|
||||
- Do not relaunch ${request.targetPackage} with shell launch commands. Use framework target controls plus UI inspection and input instead.
|
||||
- If the detached target disappears or looks empty, use android_target_ensure_hidden to request framework-owned recovery.
|
||||
""".trimIndent()
|
||||
} else {
|
||||
""
|
||||
}
|
||||
return """
|
||||
Target package:
|
||||
${request.targetPackage}
|
||||
|
||||
Delegated objective:
|
||||
${request.prompt}
|
||||
$detachedSessionPrompt
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
private fun buildDynamicToolSpecs(): JSONArray {
|
||||
return JSONArray()
|
||||
.put(dynamicToolSpec(AndroidGenieToolExecutor.ENSURE_HIDDEN_TARGET_TOOL, "Ensure the detached target exists and remains hidden. Use this to restore a missing detached target.", emptyObjectSchema()))
|
||||
.put(dynamicToolSpec(AndroidGenieToolExecutor.SHOW_TARGET_TOOL, "Show the detached target window.", emptyObjectSchema()))
|
||||
.put(dynamicToolSpec(AndroidGenieToolExecutor.HIDE_TARGET_TOOL, "Hide the detached target window.", emptyObjectSchema()))
|
||||
.put(dynamicToolSpec(AndroidGenieToolExecutor.ATTACH_TARGET_TOOL, "Reattach the detached target back to the main display.", emptyObjectSchema()))
|
||||
.put(dynamicToolSpec(AndroidGenieToolExecutor.CLOSE_TARGET_TOOL, "Close the detached target window.", emptyObjectSchema()))
|
||||
.put(dynamicToolSpec(AndroidGenieToolExecutor.CAPTURE_TARGET_FRAME_TOOL, "Capture the detached target window as an image.", emptyObjectSchema()))
|
||||
}
|
||||
|
||||
private fun dynamicToolSpec(
|
||||
name: String,
|
||||
description: String,
|
||||
inputSchema: JSONObject,
|
||||
): JSONObject {
|
||||
return JSONObject()
|
||||
.put("name", name)
|
||||
.put("description", description)
|
||||
.put("inputSchema", inputSchema)
|
||||
}
|
||||
|
||||
private fun emptyObjectSchema(): JSONObject {
|
||||
return objectSchema(emptyMap())
|
||||
}
|
||||
|
||||
private fun objectSchema(
|
||||
properties: Map<String, JSONObject>,
|
||||
required: List<String> = emptyList(),
|
||||
): JSONObject {
|
||||
val propertiesJson = JSONObject()
|
||||
properties.forEach { (name, schema) -> propertiesJson.put(name, schema) }
|
||||
return JSONObject()
|
||||
.put("type", "object")
|
||||
.put("properties", propertiesJson)
|
||||
.put("required", JSONArray(required))
|
||||
.put("additionalProperties", false)
|
||||
}
|
||||
|
||||
private fun buildDynamicToolContentItems(observation: GenieToolObservation): JSONArray {
|
||||
val items = JSONArray().put(
|
||||
JSONObject()
|
||||
.put("type", "inputText")
|
||||
.put("text", observation.promptDetails),
|
||||
)
|
||||
observation.imageDataUrls.forEach { imageUrl ->
|
||||
items.put(
|
||||
JSONObject()
|
||||
.put("type", "inputImage")
|
||||
.put("imageUrl", imageUrl),
|
||||
)
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
private fun renderAgentQuestion(questions: JSONArray): String {
|
||||
if (questions.length() == 0) {
|
||||
return "Genie requested input but did not provide a question."
|
||||
}
|
||||
val rendered = buildString {
|
||||
for (index in 0 until questions.length()) {
|
||||
val question = questions.optJSONObject(index) ?: continue
|
||||
if (length > 0) {
|
||||
append("\n\n")
|
||||
}
|
||||
val header = question.optString("header").takeIf(String::isNotBlank)
|
||||
if (header != null) {
|
||||
append(header)
|
||||
append(":\n")
|
||||
}
|
||||
append(question.optString("question"))
|
||||
val options = question.optJSONArray("options")
|
||||
if (options != null && options.length() > 0) {
|
||||
append("\nOptions:")
|
||||
for (optionIndex in 0 until options.length()) {
|
||||
val option = options.optJSONObject(optionIndex) ?: continue
|
||||
append("\n- ")
|
||||
append(option.optString("label"))
|
||||
val description = option.optString("description")
|
||||
if (description.isNotBlank()) {
|
||||
append(": ")
|
||||
append(description)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return if (questions.length() == 1) {
|
||||
rendered
|
||||
} else {
|
||||
"$rendered\n\nReply with one answer per question, separated by a blank line."
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildQuestionAnswers(
|
||||
questions: JSONArray,
|
||||
answer: String,
|
||||
): JSONObject {
|
||||
val splitAnswers = answer
|
||||
.split(Regex("\\n\\s*\\n"))
|
||||
.map(String::trim)
|
||||
.filter(String::isNotEmpty)
|
||||
val answersJson = JSONObject()
|
||||
for (index in 0 until questions.length()) {
|
||||
val question = questions.optJSONObject(index) ?: continue
|
||||
val questionId = question.optString("id")
|
||||
if (questionId.isBlank()) {
|
||||
continue
|
||||
}
|
||||
val responseText = splitAnswers.getOrNull(index)
|
||||
?: if (index == 0) answer.trim() else ""
|
||||
answersJson.put(
|
||||
questionId,
|
||||
JSONObject().put(
|
||||
"answers",
|
||||
JSONArray().put(responseText),
|
||||
),
|
||||
)
|
||||
}
|
||||
return answersJson
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
import android.content.Context
|
||||
import java.io.File
|
||||
import java.io.IOException
|
||||
|
||||
object CodexBinaryLocator {
|
||||
fun resolve(context: Context): File {
|
||||
val binary = File(context.applicationInfo.nativeLibraryDir, "libcodex.so")
|
||||
if (!binary.exists()) {
|
||||
throw IOException("codex binary missing at ${binary.absolutePath}")
|
||||
}
|
||||
return binary
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.app.agent.GenieRequest
|
||||
import android.app.agent.GenieService
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.DetachedTargetCompat
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
class CodexGenieService : GenieService() {
|
||||
companion object {
|
||||
private const val TAG = "CodexGenieService"
|
||||
}
|
||||
|
||||
private val sessionControls = ConcurrentHashMap<String, GenieSessionControl>()
|
||||
|
||||
override fun onStartGenieSession(request: GenieRequest, callback: Callback) {
|
||||
val control = GenieSessionControl()
|
||||
sessionControls[request.sessionId] = control
|
||||
Thread {
|
||||
runSession(request, callback, control)
|
||||
}.apply {
|
||||
name = "CodexGenie-${request.sessionId}"
|
||||
start()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCancelGenieSession(sessionId: String) {
|
||||
sessionControls.remove(sessionId)?.cancel()
|
||||
Log.i(TAG, "Cancelled session $sessionId")
|
||||
}
|
||||
|
||||
override fun onUserResponse(sessionId: String, response: String) {
|
||||
sessionControls[sessionId]?.recordResponse(response)
|
||||
Log.i(TAG, "Received Agent response for $sessionId")
|
||||
}
|
||||
|
||||
private fun runSession(
|
||||
request: GenieRequest,
|
||||
callback: Callback,
|
||||
control: GenieSessionControl,
|
||||
) {
|
||||
val sessionId = request.sessionId
|
||||
try {
|
||||
callback.updateState(sessionId, AgentSessionInfo.STATE_RUNNING)
|
||||
callback.publishTrace(
|
||||
sessionId,
|
||||
"Codex Genie started for target=${request.targetPackage} prompt=${request.prompt}",
|
||||
)
|
||||
callback.publishTrace(
|
||||
sessionId,
|
||||
"Genie is headless. It hosts codex app-server locally, routes model traffic through the Agent bridge, uses normal Android shell commands for package/app driving, and reserves dynamic tools for framework-only target controls.",
|
||||
)
|
||||
|
||||
if (request.isDetachedModeAllowed) {
|
||||
val detachedLaunch = DetachedTargetCompat.ensureDetachedTargetHidden(callback, sessionId)
|
||||
callback.publishTrace(sessionId, detachedLaunch.summary("ensure hidden"))
|
||||
check(detachedLaunch.isOk()) {
|
||||
"Failed to prepare detached target for ${request.targetPackage}: ${detachedLaunch.summary("ensure hidden")}"
|
||||
}
|
||||
callback.publishTrace(
|
||||
sessionId,
|
||||
"Detached-session contract active for ${request.targetPackage}: the framework owns detached launch and recovery. Codex must use framework target controls plus UI inspection/input, not plain shell relaunches of the target package.",
|
||||
)
|
||||
}
|
||||
|
||||
AgentBridgeClient(
|
||||
callback = callback,
|
||||
sessionId = sessionId,
|
||||
).use { bridgeClient ->
|
||||
val runtimeStatus = bridgeClient.getRuntimeStatus()
|
||||
val accountSuffix = runtimeStatus.accountEmail?.let { " ($it)" } ?: ""
|
||||
callback.publishTrace(
|
||||
sessionId,
|
||||
"Reached Agent bridge; authenticated=${runtimeStatus.authenticated}${accountSuffix}, provider=${runtimeStatus.modelProviderId}, model=${runtimeStatus.effectiveModel ?: "unknown"}, clients=${runtimeStatus.clientCount}.",
|
||||
)
|
||||
if (!runtimeStatus.authenticated) {
|
||||
callback.publishResult(
|
||||
sessionId,
|
||||
"Reached the Agent bridge, but the Agent runtime was not authenticated for ${request.targetPackage}.",
|
||||
)
|
||||
callback.updateState(sessionId, AgentSessionInfo.STATE_COMPLETED)
|
||||
return
|
||||
}
|
||||
|
||||
CodexAppServerHost(
|
||||
context = this,
|
||||
request = request,
|
||||
callback = callback,
|
||||
control = control,
|
||||
bridgeClient = bridgeClient,
|
||||
runtimeStatus = runtimeStatus,
|
||||
).use { host ->
|
||||
host.run()
|
||||
}
|
||||
}
|
||||
} catch (err: InterruptedException) {
|
||||
Thread.currentThread().interrupt()
|
||||
Log.w(TAG, "Interrupted Genie session $sessionId", err)
|
||||
safeCallback("publish interrupted error") {
|
||||
callback.publishError(sessionId, "Interrupted: ${err.message}")
|
||||
}
|
||||
safeCallback("publish interrupted state") {
|
||||
callback.updateState(sessionId, AgentSessionInfo.STATE_FAILED)
|
||||
}
|
||||
} catch (err: IOException) {
|
||||
Log.w(TAG, "I/O failure in Genie session $sessionId", err)
|
||||
if (control.cancelled) {
|
||||
safeCallback("publish cancelled error") {
|
||||
callback.publishError(sessionId, "Cancelled")
|
||||
}
|
||||
safeCallback("publish cancelled state") {
|
||||
callback.updateState(sessionId, AgentSessionInfo.STATE_CANCELLED)
|
||||
}
|
||||
} else {
|
||||
safeCallback("publish I/O error") {
|
||||
callback.publishError(sessionId, err.message ?: err::class.java.simpleName)
|
||||
}
|
||||
safeCallback("publish failed state") {
|
||||
callback.updateState(sessionId, AgentSessionInfo.STATE_FAILED)
|
||||
}
|
||||
}
|
||||
} catch (err: RuntimeException) {
|
||||
Log.w(TAG, "Runtime failure in Genie session $sessionId", err)
|
||||
safeCallback("publish runtime error") {
|
||||
callback.publishError(sessionId, "${err::class.java.simpleName}: ${err.message}")
|
||||
}
|
||||
safeCallback("publish runtime failed state") {
|
||||
callback.updateState(sessionId, AgentSessionInfo.STATE_FAILED)
|
||||
}
|
||||
} finally {
|
||||
sessionControls.remove(sessionId)
|
||||
control.cancel()
|
||||
}
|
||||
}
|
||||
|
||||
private fun safeCallback(
|
||||
operation: String,
|
||||
block: () -> Unit,
|
||||
) {
|
||||
runCatching(block).onFailure { err ->
|
||||
Log.w(TAG, "Ignoring Genie callback failure during $operation", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
internal object DetachedSessionGuard {
|
||||
fun instructions(
|
||||
targetPackage: String,
|
||||
): String {
|
||||
return """
|
||||
Detached-session contract for $targetPackage:
|
||||
- The framework already launched $targetPackage hidden before your turn started.
|
||||
- Do not relaunch $targetPackage with `am start`, `cmd activity start-activity`, `monkey -p`, or similar shell launch surfaces. That bypasses detached hosting and can be blocked by Android background-activity-launch policy.
|
||||
- To surface the running target, use `android_target_show`.
|
||||
- If the detached target disappears or the framework reports it missing, use `android_target_ensure_hidden` to request framework-owned recovery.
|
||||
- To inspect the running detached target, use `android_target_capture_frame` and UI-inspection commands such as `uiautomator dump`.
|
||||
- Do not infer missing-target state from a blank launcher badge or a null frame alone. Use framework target controls first; if they still do not expose a usable target, report the framework-state problem instead of guessing.
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
fun isForbiddenTargetLaunchCommand(
|
||||
command: String,
|
||||
targetPackage: String,
|
||||
): Boolean {
|
||||
val normalized = command.trim()
|
||||
val launchPatterns = listOf(
|
||||
"/bin/sh -lc 'am start",
|
||||
"/bin/sh -lc 'am start-activity",
|
||||
"/bin/sh -lc 'cmd activity start-activity",
|
||||
"/bin/sh -lc 'monkey ",
|
||||
)
|
||||
if (launchPatterns.none(normalized::startsWith)) {
|
||||
return false
|
||||
}
|
||||
return normalized.contains("-n $targetPackage/")
|
||||
|| normalized.contains("-p $targetPackage")
|
||||
|| normalized.contains("--package $targetPackage")
|
||||
}
|
||||
|
||||
fun violationMessage(
|
||||
targetPackage: String,
|
||||
command: String,
|
||||
): String {
|
||||
return "Detached session contract violated: attempted to relaunch $targetPackage with shell command `$command`. The framework already launched the target hidden; use android_target_ensure_hidden/android_target_show/android_target_capture_frame plus UI inspection/input instead."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class GenieSessionControl {
|
||||
@Volatile
|
||||
var cancelled = false
|
||||
|
||||
@Volatile
|
||||
var process: Process? = null
|
||||
|
||||
val userResponses = LinkedBlockingQueue<String>()
|
||||
|
||||
fun cancel() {
|
||||
cancelled = true
|
||||
process?.destroy()
|
||||
process = null
|
||||
}
|
||||
|
||||
fun waitForUserResponse(): String {
|
||||
while (!cancelled) {
|
||||
val response = userResponses.poll(100, TimeUnit.MILLISECONDS)
|
||||
if (response != null) {
|
||||
return response
|
||||
}
|
||||
}
|
||||
throw IOException("Cancelled while waiting for Agent response")
|
||||
}
|
||||
|
||||
fun recordResponse(response: String) {
|
||||
userResponses.offer(response)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
data class GenieToolObservation(
|
||||
val name: String,
|
||||
val summary: String,
|
||||
val promptDetails: String,
|
||||
val imageDataUrls: List<String> = emptyList(),
|
||||
) {
|
||||
fun renderForPrompt(): String {
|
||||
return """
|
||||
Tool: $name
|
||||
Observation:
|
||||
$promptDetails
|
||||
""".trimIndent()
|
||||
}
|
||||
}
|
||||
3
android/genie/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Codex Genie</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,104 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
import org.json.JSONObject
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertNull
|
||||
import org.junit.Test
|
||||
|
||||
class CodexAgentBridgeTest {
|
||||
@Test
|
||||
fun buildResponsesRequestUsesListInputPayload() {
|
||||
val request = CodexAgentBridge.buildResponsesRequest(
|
||||
model = "gpt-5.1-codex",
|
||||
instructions = "reply",
|
||||
prompt = "inspect the target app",
|
||||
imageDataUrls = listOf("data:image/jpeg;base64,AAA"),
|
||||
)
|
||||
|
||||
assertEquals("gpt-5.1-codex", request.getString("model"))
|
||||
assertFalse(request.getBoolean("store"))
|
||||
assertEquals(true, request.getBoolean("stream"))
|
||||
val input = request.getJSONArray("input")
|
||||
assertEquals(1, input.length())
|
||||
val message = input.getJSONObject(0)
|
||||
assertEquals("user", message.getString("role"))
|
||||
val content = message.getJSONArray("content")
|
||||
assertEquals(2, content.length())
|
||||
assertEquals("input_text", content.getJSONObject(0).getString("type"))
|
||||
assertEquals("inspect the target app", content.getJSONObject(0).getString("text"))
|
||||
assertEquals("input_image", content.getJSONObject(1).getString("type"))
|
||||
assertEquals("data:image/jpeg;base64,AAA", content.getJSONObject(1).getString("image_url"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseResponsesOutputTextCombinesOutputItems() {
|
||||
val response = CodexAgentBridge.HttpResponse(
|
||||
statusCode = 200,
|
||||
body = JSONObject()
|
||||
.put(
|
||||
"output",
|
||||
org.json.JSONArray().put(
|
||||
JSONObject()
|
||||
.put("type", "message")
|
||||
.put("role", "assistant")
|
||||
.put(
|
||||
"content",
|
||||
org.json.JSONArray()
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("type", "output_text")
|
||||
.put("text", "Open the clock app. "),
|
||||
)
|
||||
.put(
|
||||
JSONObject()
|
||||
.put("type", "output_text")
|
||||
.put("text", "Set the requested timer."),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toString(),
|
||||
)
|
||||
|
||||
val outputText = CodexAgentBridge.parseResponsesOutputText(response)
|
||||
|
||||
assertEquals("Open the clock app. Set the requested timer.", outputText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun parseResponsesOutputTextReadsSseDeltaPayloads() {
|
||||
val response = CodexAgentBridge.HttpResponse(
|
||||
statusCode = 200,
|
||||
body = """
|
||||
event: response.output_text.delta
|
||||
data: {"type":"response.output_text.delta","delta":"Open Clock. "}
|
||||
|
||||
event: response.output_text.delta
|
||||
data: {"type":"response.output_text.delta","delta":"Start the timer."}
|
||||
|
||||
event: response.completed
|
||||
data: {"type":"response.completed","response":{"id":"resp-1"}}
|
||||
|
||||
data: [DONE]
|
||||
""".trimIndent(),
|
||||
)
|
||||
|
||||
val outputText = CodexAgentBridge.parseResponsesOutputText(response)
|
||||
|
||||
assertEquals("Open Clock. Start the timer.", outputText)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun optNullableStringTreatsJsonNullAsNull() {
|
||||
val json = JSONObject()
|
||||
.put("present", "gpt-5.3-codex")
|
||||
.put("blank", "")
|
||||
.put("missingViaNull", JSONObject.NULL)
|
||||
|
||||
assertEquals("gpt-5.3-codex", json.optNullableString("present"))
|
||||
assertNull(json.optNullableString("blank"))
|
||||
assertNull(json.optNullableString("missingViaNull"))
|
||||
assertNull(json.optNullableString("missing"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package com.openai.codex.genie
|
||||
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class DetachedSessionGuardTest {
|
||||
@Test
|
||||
fun instructionsBanTargetRelaunches() {
|
||||
val instructions = DetachedSessionGuard.instructions("com.aurora.store")
|
||||
|
||||
assertTrue(instructions.contains("com.aurora.store"))
|
||||
assertTrue(instructions.contains("Do not relaunch"))
|
||||
assertTrue(instructions.contains("android_target_ensure_hidden"))
|
||||
assertTrue(instructions.contains("android_target_show"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detectsForbiddenAmTargetLaunch() {
|
||||
val forbidden = DetachedSessionGuard.isForbiddenTargetLaunchCommand(
|
||||
command = "/bin/sh -lc 'am start --user 0 -n com.aurora.store/.MainActivity'",
|
||||
targetPackage = "com.aurora.store",
|
||||
)
|
||||
|
||||
assertTrue(forbidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detectsForbiddenCmdActivityTargetLaunch() {
|
||||
val forbidden = DetachedSessionGuard.isForbiddenTargetLaunchCommand(
|
||||
command = "/bin/sh -lc 'cmd activity start-activity --user 0 -p com.aurora.store'",
|
||||
targetPackage = "com.aurora.store",
|
||||
)
|
||||
|
||||
assertTrue(forbidden)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun allowsNonLaunchPackageInspectionCommands() {
|
||||
val forbidden = DetachedSessionGuard.isForbiddenTargetLaunchCommand(
|
||||
command = "/bin/sh -lc 'cmd package query-activities --user 0 -a android.intent.action.MAIN -c android.intent.category.LAUNCHER com.aurora.store'",
|
||||
targetPackage = "com.aurora.store",
|
||||
)
|
||||
|
||||
assertFalse(forbidden)
|
||||
}
|
||||
}
|
||||
3
android/gradle.properties
Normal file
@@ -0,0 +1,3 @@
|
||||
org.gradle.jvmargs=-Xmx2g -Dfile.encoding=UTF-8
|
||||
android.useAndroidX=true
|
||||
kotlin.code.style=official
|
||||