Compare commits
158 Commits
codex-appl
...
dev/imalch
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a74dd520c | ||
|
|
c487e22b6d | ||
|
|
98cbb10acd | ||
|
|
67d677b254 | ||
|
|
528887dff8 | ||
|
|
5f6636734a | ||
|
|
743cd859f4 | ||
|
|
060789d5ca | ||
|
|
640cbcf152 | ||
|
|
b5025b59b5 | ||
|
|
b20afa97b4 | ||
|
|
2daa343711 | ||
|
|
eb91c96518 | ||
|
|
40b635e24f | ||
|
|
c122ff283f | ||
|
|
08b3c19b8c | ||
|
|
bf9dcb7560 | ||
|
|
2456260f1c | ||
|
|
89152756f7 | ||
|
|
6b57b70b2c | ||
|
|
9afddb665f | ||
|
|
0f4ddd4ba2 | ||
|
|
e2ae1d588b | ||
|
|
9218662e6c | ||
|
|
b0f34a5a3c | ||
|
|
64de95c1e9 | ||
|
|
ef28c7a648 | ||
|
|
79696c734b | ||
|
|
545e30f2c5 | ||
|
|
aa310977a7 | ||
|
|
8c4afd7516 | ||
|
|
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 |
9
MODULE.bazel.lock
generated
4
android/.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.gradle/
|
||||
local.properties
|
||||
**/build/
|
||||
*.iml
|
||||
183
android/AGENTS.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# Android Agent/Genie
|
||||
|
||||
This file applies to the Android subtree under `android/`. It is developer-facing
|
||||
context for working on the Android Agent/Genie implementation in this repo.
|
||||
|
||||
Do not confuse this with the runtime guidance asset at
|
||||
`android/bridge/src/main/assets/AGENTS.md`, which is copied into Codex homes on
|
||||
device for live Agent/Genie sessions.
|
||||
|
||||
## Module layout
|
||||
|
||||
- `android/app`: Agent app
|
||||
- `android/genie`: Genie app
|
||||
- `android/bridge`: shared Android bridge/runtime compatibility layer
|
||||
- `android/build-agent-genie-apks.sh`: helper for building both APKs
|
||||
- `android/install-and-provision-agent-genie.sh`: helper for adb install, role
|
||||
assignment, and auth seeding
|
||||
|
||||
## Default SDK input
|
||||
|
||||
When building the Android APKs, use the branch-local Android Agent Platform stub
|
||||
SDK at:
|
||||
|
||||
`$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip`
|
||||
|
||||
The Android build already accepts this through either:
|
||||
|
||||
- environment: `ANDROID_AGENT_PLATFORM_STUB_SDK_ZIP`
|
||||
- script flag: `android/build-agent-genie-apks.sh --agent-sdk-zip ...`
|
||||
- Gradle property: `-PagentPlatformStubSdkZip=...`
|
||||
|
||||
Treat this path as the default SDK source unless the user explicitly says
|
||||
otherwise.
|
||||
|
||||
## Authoritative design references
|
||||
|
||||
Read these first when recovering Android Agent/Genie context:
|
||||
|
||||
- local architecture/status doc:
|
||||
`docs/android-agent-genie-refactor.md`
|
||||
- SDK docs inside the stub SDK zip:
|
||||
- `README.md`
|
||||
- `AGENT_GENIE_DESIGN.md`
|
||||
- `CONSUMER_GUIDE.md`
|
||||
|
||||
Useful inspection commands:
|
||||
|
||||
```bash
|
||||
unzip -p "$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip" README.md | sed -n '1,220p'
|
||||
unzip -p "$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip" AGENT_GENIE_DESIGN.md | sed -n '1,260p'
|
||||
unzip -p "$HOME/code/io/ci/aosp/artifacts/aosp/android16-qpr2-release/android-agent-platform-stub-sdk.zip" CONSUMER_GUIDE.md | sed -n '1,260p'
|
||||
```
|
||||
|
||||
## Key platform contract to preserve
|
||||
|
||||
The current Android work in this repo assumes the same contract described by the
|
||||
stub SDK docs and the local refactor doc:
|
||||
|
||||
- Agent and Genie are separate APKs.
|
||||
- The framework-managed per-session bridge is the app-private Agent<->Genie
|
||||
control plane.
|
||||
- The framework-owned streaming HTTP exchange is the transport for active
|
||||
`/responses` traffic in both top-level Agent planner sessions and live Genie
|
||||
child sessions.
|
||||
- Genie is headless and should not depend on direct internet access.
|
||||
- Detached target handling must use framework-authoritative presentation/runtime
|
||||
state and typed detached-target recovery APIs rather than guessed relaunches.
|
||||
- Genies should keep the paired app hidden by default. Prefer
|
||||
`DETACHED_HIDDEN` unless the user explicitly asks to bring the app to the
|
||||
front, asks to leave it visibly shown, or the task clearly implies a visible
|
||||
app handoff.
|
||||
- App-scoped HOME drafts are real framework `STATE_CREATED` sessions created
|
||||
before `startGenieSession(...)`; if you expose that flow outside the on-device
|
||||
UI, remember that provisional HOME sessions are expected to hold a
|
||||
session-UI lease until they are started or cancelled.
|
||||
- Desktop draft attach is now implemented by bootstrapping an idle app-server
|
||||
runtime before the first turn:
|
||||
- HOME drafts start Genie with an internal idle-bootstrap sentinel and become
|
||||
attachable immediately after bridge bootstrap.
|
||||
- direct AGENT drafts spin up an idle planner app-server host inside the
|
||||
Agent process.
|
||||
- the first prompt can then be typed in the attached desktop TUI instead of
|
||||
being supplied to `sessions start`.
|
||||
- Attached runtime completion semantics are intentionally non-terminal:
|
||||
- attached Genie turns remain live after `turn/completed` so the same desktop
|
||||
TUI can send follow-up prompts.
|
||||
- attached direct AGENT planner sessions stay live after planning completes.
|
||||
- child Genie sessions spawned by an attached planner are launched in idle
|
||||
desktop-attach mode instead of immediately consuming their delegated prompt.
|
||||
- those idle child sessions still receive Agent-provisioned bridge state
|
||||
first, stage the delegated objective as runtime context, and remain
|
||||
attachable while the planner stays attached.
|
||||
- if the planner detaches before the user manually starts the child, the
|
||||
staged delegated objective is released automatically as a fallback.
|
||||
- after a child turn completes, planner-held child sessions remain attachable
|
||||
until the planner attach detaches.
|
||||
- once the planner detaches, those held child sessions are allowed to settle
|
||||
to their terminal framework state and the parent roll-up can complete.
|
||||
- Recoverable hosted-runtime failures are also intentionally non-terminal when a
|
||||
fresh app-server thread can still be bootstrapped:
|
||||
- recoverable app-server / bridge I/O failures during an attached Genie turn
|
||||
close only the current desktop attach, then restart the Genie into a fresh
|
||||
attachable idle thread with staged recovery context
|
||||
- recoverable I/O failures during an unattached Genie run first retry
|
||||
automatically with staged recovery context, then pause into an attachable
|
||||
idle recovery thread if automatic retries are exhausted
|
||||
- only failures that prevent bootstrapping any new hosted runtime at all
|
||||
should still terminate the Genie session
|
||||
- Parent-session cancellation is tree-scoped for direct AGENT sessions:
|
||||
cancelling the parent from the desktop bridge, framework tool bridge, or the
|
||||
detail UI must cancel the parent and all child Genie sessions through the
|
||||
framework `cancelSession(...)` path, even when some of those sessions are
|
||||
already terminal.
|
||||
- Framework-owned session notifications now support delegated AGENT rendering:
|
||||
- user-facing question/result/error notifications should be rendered by the
|
||||
Agent app when the framework calls `onShowOrUpdateSessionNotification(...)`
|
||||
and cancelled when it calls `onCancelSessionNotification(...)`
|
||||
- RUNNING/CREATED/QUEUED callbacks should be ACKed but suppressed so the
|
||||
Agent only notifies for user action or terminal outcomes
|
||||
- the Agent must ACK a posted notification with `ackSessionNotification(...)`
|
||||
and route inline replies through `answerQuestionFromNotification(...)`
|
||||
- if delegated rendering is unavailable, the framework may post a generic
|
||||
fallback notification, so app-side notification code must remain
|
||||
token-aware and idempotent
|
||||
- HOME icon / notification taps for question or final-result states should route
|
||||
to `SessionPopupActivity`, which uses one dialog-style popup shape for both
|
||||
question answering and result follow-up.
|
||||
For top-level HOME `RUNNING` states with a detached target, the same handler
|
||||
should immediately call `showDetachedTarget(sessionId)` so a red-badged live
|
||||
icon brings the paired app on screen without attaching and ending the Genie
|
||||
session.
|
||||
Launcher may dispatch HOME-anchored icon taps through either
|
||||
`ACTION_HANDLE_AGENT_SESSION` or `ACTION_HANDLE_HOME_AGENT_SESSION`; both
|
||||
should resolve to the same Agent-owned popup flow.
|
||||
The full `SessionDetailActivity` remains the fallback inspector for non-popup
|
||||
states and Agent-app initiated navigation.
|
||||
- HOME completion and cancellation are AGENT-owned policy decisions:
|
||||
- pressing OK in the final result popup should call
|
||||
`consumeHomeSessionPresentation(sessionId)` and close a still-detached
|
||||
target instead of relying on Launcher to consume/open the target directly
|
||||
- pressing Send in the final result popup for a HOME session should launch a
|
||||
fresh HOME continuation with previous-result context, then consume the old
|
||||
result presentation
|
||||
- pressing Send in the final result popup for an AGENT session should continue
|
||||
the direct parent session in place
|
||||
- Codex Agent is an AGENT-role app, not a HOME-role surface, so its
|
||||
user-driven cancellation flows should still call `cancelSession(sessionId)`;
|
||||
`cancelHomeSession(sessionId)` is for Launcher/HOME callers
|
||||
|
||||
## External reference implementations
|
||||
|
||||
There are standalone stub apps outside this repo that are useful for
|
||||
understanding the intended Android API usage:
|
||||
|
||||
- Agent stub root:
|
||||
`$HOME/code/omix/AgentStub`
|
||||
- Genie stub root:
|
||||
`$HOME/code/omix/GenieStub`
|
||||
|
||||
Especially useful files:
|
||||
|
||||
- `$HOME/code/omix/AgentStub/src/com/example/agentstub/ValidationAgentService.java`
|
||||
- `$HOME/code/omix/AgentStub/src/com/example/agentstub/AgentOrchestrationService.java`
|
||||
- `$HOME/code/omix/AgentStub/src/com/example/agentstub/SessionActivity.java`
|
||||
- `$HOME/code/omix/AgentStub/README-standalone.md`
|
||||
- `$HOME/code/omix/GenieStub/src/com/example/geniestub/ValidationGenieService.java`
|
||||
- `$HOME/code/omix/GenieStub/README-standalone.md`
|
||||
|
||||
Use these as contract/reference implementations for session lifecycle, detached
|
||||
target control, question flow, and framework HTTP exchange usage.
|
||||
|
||||
## Recovery checklist
|
||||
|
||||
When returning to Android Agent/Genie work after interruption:
|
||||
|
||||
1. Read `docs/android-agent-genie-refactor.md` for the current architecture and
|
||||
recent implementation status.
|
||||
2. Re-read the three markdown files inside the stub SDK zip if the framework
|
||||
contract matters for the change.
|
||||
3. Check `git log --oneline -- android docs/android-agent-genie-refactor.md` to
|
||||
see the latest Android-specific changes.
|
||||
4. If behavior is ambiguous, compare against the AgentStub/GenieStub reference
|
||||
implementations before changing repo code.
|
||||
26
android/OAI_Codex-Blossom_Primary.svg
Normal file
|
After Width: | Height: | Size: 791 KiB |
123
android/app/build.gradle.kts
Normal file
@@ -0,0 +1,123 @@
|
||||
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"))
|
||||
implementation("org.java-websocket:Java-WebSocket:1.5.7")
|
||||
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.
|
||||
98
android/app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,98 @@
|
||||
<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>
|
||||
|
||||
<service
|
||||
android:name=".DesktopAttachKeepAliveService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="dataSync" />
|
||||
|
||||
<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:documentLaunchMode="always"
|
||||
android:exported="true"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="standard"
|
||||
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>
|
||||
<intent-filter>
|
||||
<action android:name="android.app.agent.action.HANDLE_HOME_SESSION" />
|
||||
<category android:name="android.intent.category.DEFAULT" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<activity
|
||||
android:name=".SessionDetailActivity"
|
||||
android:exported="false"
|
||||
android:launchMode="singleTop" />
|
||||
|
||||
<activity
|
||||
android:name=".SessionPopupActivity"
|
||||
android:documentLaunchMode="always"
|
||||
android:exported="false"
|
||||
android:excludeFromRecents="true"
|
||||
android:launchMode="standard"
|
||||
android:taskAffinity="com.openai.codex.agent.popup"
|
||||
android:theme="@style/CodexSessionPopupTheme" />
|
||||
|
||||
<receiver
|
||||
android:name=".DesktopBridgeBootstrapReceiver"
|
||||
android:exported="true">
|
||||
<intent-filter>
|
||||
<action android:name="com.openai.codex.agent.action.BOOTSTRAP_DESKTOP_BRIDGE" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
<receiver
|
||||
android:name=".AgentNotificationReplyReceiver"
|
||||
android:exported="false" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,780 @@
|
||||
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("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.cancelSessionTree(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,54 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.RemoteInput
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class AgentNotificationReplyReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
if (intent.action != AgentQuestionNotifier.ACTION_REPLY_FROM_NOTIFICATION) {
|
||||
return
|
||||
}
|
||||
val sessionId = intent.getStringExtra(AgentQuestionNotifier.EXTRA_SESSION_ID)?.trim().orEmpty()
|
||||
val notificationToken = intent.getStringExtra(
|
||||
AgentQuestionNotifier.EXTRA_NOTIFICATION_TOKEN,
|
||||
)?.trim().orEmpty()
|
||||
val answer = RemoteInput.getResultsFromIntent(intent)
|
||||
?.getCharSequence(AgentQuestionNotifier.REMOTE_INPUT_KEY)
|
||||
?.toString()
|
||||
?.trim()
|
||||
.orEmpty()
|
||||
if (sessionId.isEmpty() || answer.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val pendingResult = goAsync()
|
||||
thread(name = "CodexAgentNotificationReply-$sessionId") {
|
||||
try {
|
||||
AgentQuestionNotifier.suppress(
|
||||
context = context,
|
||||
sessionId = sessionId,
|
||||
notificationToken = notificationToken,
|
||||
)
|
||||
runCatching {
|
||||
AgentSessionController(context).answerQuestionFromNotification(
|
||||
sessionId = sessionId,
|
||||
notificationToken = notificationToken,
|
||||
answer = answer,
|
||||
parentSessionId = null,
|
||||
)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Failed to answer notification question for $sessionId", err)
|
||||
}
|
||||
} finally {
|
||||
pendingResult.finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private companion object {
|
||||
private const val TAG = "CodexAgentReply"
|
||||
}
|
||||
}
|
||||
@@ -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,865 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentManager
|
||||
import android.content.Context
|
||||
import android.util.Log
|
||||
import com.openai.codex.bridge.FrameworkEventBridge
|
||||
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.AtomicBoolean
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.concurrent.thread
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
internal class AgentPlannerDesktopSessionHost(
|
||||
private val context: Context,
|
||||
private val sessionController: AgentSessionController,
|
||||
private val sessionId: String,
|
||||
private val onClosed: () -> Unit,
|
||||
) : Closeable {
|
||||
companion object {
|
||||
private const val TAG = "AgentPlannerDesktop"
|
||||
private const val REQUEST_TIMEOUT_MS = 30_000L
|
||||
private const val POLL_TIMEOUT_MS = 250L
|
||||
private const val REMOTE_REQUEST_ID_PREFIX = "remote:"
|
||||
private const val REMOTE_SERVER_VERSION = "0.1.0"
|
||||
private const val DEFAULT_HOSTED_MODEL = "gpt-5.3-codex"
|
||||
private val DISALLOWED_TARGET_PACKAGES = setOf(
|
||||
"com.android.shell",
|
||||
"com.android.systemui",
|
||||
"com.openai.codex.agent",
|
||||
"com.openai.codex.genie",
|
||||
)
|
||||
}
|
||||
|
||||
private data class DesktopProxy(
|
||||
val connectionId: String,
|
||||
val onMessage: (String) -> Unit,
|
||||
val onClosed: (String?) -> Unit,
|
||||
)
|
||||
|
||||
private data class RemoteProxyState(
|
||||
val connectionId: String,
|
||||
val optOutNotificationMethods: Set<String>,
|
||||
)
|
||||
|
||||
private data class RemotePendingRequest(
|
||||
val connectionId: String,
|
||||
val remoteRequestId: Any,
|
||||
)
|
||||
|
||||
private data class PendingDesktopMessage(
|
||||
val connectionId: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
private val requestIdSequence = AtomicInteger(1)
|
||||
private val pendingResponses = ConcurrentHashMap<String, LinkedBlockingQueue<JSONObject>>()
|
||||
private val remotePendingRequests = ConcurrentHashMap<String, RemotePendingRequest>()
|
||||
private val inboundMessages = LinkedBlockingQueue<JSONObject>()
|
||||
private val pendingDesktopMessages = LinkedBlockingQueue<PendingDesktopMessage>()
|
||||
private val writerLock = Any()
|
||||
private val proxyLock = Any()
|
||||
private val streamedAgentMessages = mutableMapOf<String, StringBuilder>()
|
||||
private val closing = AtomicBoolean(false)
|
||||
|
||||
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 eventLoopThread: Thread? = null
|
||||
private var desktopDispatchThread: Thread? = null
|
||||
private var localProxy: AgentLocalCodexProxy? = null
|
||||
private var runtimeStatus: AgentCodexAppServerClient.RuntimeStatus? = null
|
||||
private var finalAgentMessage: String? = null
|
||||
private var currentObjective: String? = null
|
||||
private var pendingDirectSessionStart: PendingDirectSessionStart? = null
|
||||
@Volatile
|
||||
private var currentThreadId: String? = null
|
||||
@Volatile
|
||||
private var currentDesktopProxy: DesktopProxy? = null
|
||||
@Volatile
|
||||
private var remoteProxyState: RemoteProxyState? = null
|
||||
@Volatile
|
||||
private var lastReportedFrameworkEventCount = 0
|
||||
|
||||
fun start() {
|
||||
executionSettings = sessionController.executionSettingsForSession(sessionId)
|
||||
runtimeStatus = runCatching {
|
||||
AgentCodexAppServerClient.readRuntimeStatus(context)
|
||||
}.getOrNull()
|
||||
startProcess()
|
||||
initialize()
|
||||
currentThreadId = startThread()
|
||||
desktopDispatchThread = thread(
|
||||
start = true,
|
||||
name = "AgentPlannerDesktopDispatch-$sessionId",
|
||||
) {
|
||||
dispatchDesktopMessages()
|
||||
}
|
||||
eventLoopThread = thread(
|
||||
start = true,
|
||||
name = "AgentPlannerDesktopEventLoop-$sessionId",
|
||||
) {
|
||||
eventLoop()
|
||||
}
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!closing.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
DesktopInspectionRegistry.markPlannerDetached(sessionId)
|
||||
val proxy = synchronized(proxyLock) {
|
||||
currentDesktopProxy.also {
|
||||
currentDesktopProxy = null
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
proxy?.onClosed("Planner desktop session closed")
|
||||
}
|
||||
stdoutThread?.interrupt()
|
||||
stderrThread?.interrupt()
|
||||
eventLoopThread?.interrupt()
|
||||
desktopDispatchThread?.interrupt()
|
||||
synchronized(writerLock) {
|
||||
runCatching { writer.close() }
|
||||
}
|
||||
localProxy?.close()
|
||||
if (::codexHome.isInitialized) {
|
||||
runCatching { codexHome.deleteRecursively() }
|
||||
}
|
||||
if (::process.isInitialized) {
|
||||
process.destroy()
|
||||
}
|
||||
onClosed()
|
||||
}
|
||||
|
||||
fun activeThreadId(): String? = currentThreadId
|
||||
|
||||
fun openDesktopProxy(
|
||||
onMessage: (String) -> Unit,
|
||||
onClosed: (String?) -> Unit,
|
||||
): String? {
|
||||
val threadId = currentThreadId ?: return null
|
||||
if (threadId.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val connectionId = java.util.UUID.randomUUID().toString()
|
||||
val replacement = synchronized(proxyLock) {
|
||||
currentDesktopProxy.also {
|
||||
currentDesktopProxy = DesktopProxy(connectionId, onMessage, onClosed)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
replacement?.onClosed("Replaced by a newer desktop attach")
|
||||
}
|
||||
DesktopInspectionRegistry.markPlannerAttached(sessionId)
|
||||
return connectionId
|
||||
}
|
||||
|
||||
fun sendDesktopProxyInput(
|
||||
connectionId: String,
|
||||
message: String,
|
||||
): Boolean {
|
||||
val proxy = currentDesktopProxy
|
||||
if (proxy?.connectionId != connectionId) {
|
||||
return false
|
||||
}
|
||||
handleRemoteProxyMessage(message)
|
||||
return true
|
||||
}
|
||||
|
||||
fun closeDesktopProxy(
|
||||
connectionId: String,
|
||||
reason: String? = null,
|
||||
detachPlanner: Boolean = false,
|
||||
) {
|
||||
val proxy = synchronized(proxyLock) {
|
||||
currentDesktopProxy?.takeIf { it.connectionId == connectionId }?.also {
|
||||
currentDesktopProxy = null
|
||||
}
|
||||
} ?: return
|
||||
if (remoteProxyState?.connectionId == connectionId) {
|
||||
remoteProxyState = null
|
||||
lastReportedFrameworkEventCount = 0
|
||||
}
|
||||
if (detachPlanner) {
|
||||
DesktopInspectionRegistry.markPlannerDetached(sessionId)
|
||||
}
|
||||
runCatching {
|
||||
proxy.onClosed(reason)
|
||||
}
|
||||
}
|
||||
|
||||
private fun startProcess() {
|
||||
codexHome = File(context.cacheDir, "planner-desktop-codex-home/$sessionId").apply {
|
||||
deleteRecursively()
|
||||
mkdirs()
|
||||
}
|
||||
localProxy = AgentLocalCodexProxy { requestBody ->
|
||||
forwardResponsesRequest(requestBody)
|
||||
}.also(AgentLocalCodexProxy::start)
|
||||
HostedCodexConfig.write(
|
||||
context,
|
||||
codexHome,
|
||||
localProxy?.baseUrl ?: throw IOException("planner desktop 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"] = "warn"
|
||||
}.start()
|
||||
writer = process.outputStream.bufferedWriter()
|
||||
startStdoutPump()
|
||||
startStderrPump()
|
||||
}
|
||||
|
||||
private fun initialize() {
|
||||
request(
|
||||
method = "initialize",
|
||||
params = JSONObject()
|
||||
.put(
|
||||
"clientInfo",
|
||||
JSONObject()
|
||||
.put("name", "android_agent_planner_desktop")
|
||||
.put("title", "Android Agent Planner Desktop")
|
||||
.put("version", "0.1.0"),
|
||||
)
|
||||
.put("capabilities", JSONObject().put("experimentalApi", true)),
|
||||
)
|
||||
notify("initialized", JSONObject())
|
||||
}
|
||||
|
||||
private fun startThread(): String {
|
||||
val params = JSONObject()
|
||||
.put("approvalPolicy", "never")
|
||||
.put("sandbox", "read-only")
|
||||
.put("cwd", context.filesDir.absolutePath)
|
||||
.put("serviceName", "android_agent_planner")
|
||||
.put("baseInstructions", AgentTaskPlanner.plannerInstructions())
|
||||
.put(
|
||||
"model",
|
||||
executionSettings.model
|
||||
?.takeIf(String::isNotBlank)
|
||||
?: DEFAULT_HOSTED_MODEL,
|
||||
)
|
||||
val result = request(
|
||||
method = "thread/start",
|
||||
params = params,
|
||||
)
|
||||
return result.getJSONObject("thread").getString("id")
|
||||
}
|
||||
|
||||
private fun eventLoop() {
|
||||
try {
|
||||
while (!closing.get()) {
|
||||
val message = inboundMessages.poll(POLL_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
if (message == null) {
|
||||
maybeEmitFrameworkEvents()
|
||||
if (!process.isAlive) {
|
||||
throw IOException("Planner 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
|
||||
}
|
||||
}
|
||||
} catch (err: Exception) {
|
||||
if (!closing.get()) {
|
||||
Log.w(TAG, "Planner desktop runtime failed for $sessionId", err)
|
||||
}
|
||||
} finally {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleServerRequest(message: JSONObject) {
|
||||
val requestId = message.opt("id") ?: return
|
||||
val method = message.optString("method")
|
||||
when (method) {
|
||||
"item/tool/requestUserInput" -> {
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32601,
|
||||
message = "Planner desktop attach does not support request_user_input yet",
|
||||
)
|
||||
}
|
||||
else -> {
|
||||
sendError(
|
||||
requestId = requestId,
|
||||
code = -32601,
|
||||
message = "Unsupported planner app-server request: $method",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleNotification(message: JSONObject): Boolean {
|
||||
val method = message.optString("method")
|
||||
val params = message.optJSONObject("params") ?: JSONObject()
|
||||
return when (method) {
|
||||
"turn/started" -> {
|
||||
finalAgentMessage = null
|
||||
streamedAgentMessages.clear()
|
||||
if (pendingDirectSessionStart == null) {
|
||||
val objective = currentObjective?.takeIf(String::isNotBlank)
|
||||
if (objective != null) {
|
||||
pendingDirectSessionStart = sessionController.prepareDirectSessionDraftForStart(
|
||||
sessionId = sessionId,
|
||||
objective = objective,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
"item/agentMessage/delta" -> {
|
||||
val itemId = params.optString("itemId")
|
||||
if (itemId.isNotBlank()) {
|
||||
streamedAgentMessages.getOrPut(itemId, ::StringBuilder)
|
||||
.append(params.optString("delta"))
|
||||
}
|
||||
false
|
||||
}
|
||||
"item/completed" -> {
|
||||
val item = params.optJSONObject("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
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
"turn/completed" -> handleTurnCompleted(params.optJSONObject("turn") ?: JSONObject())
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleTurnCompleted(turn: JSONObject): Boolean {
|
||||
return when (turn.optString("status")) {
|
||||
"completed" -> {
|
||||
val objective = currentObjective?.takeIf(String::isNotBlank)
|
||||
?: run {
|
||||
publishTrace("Planner turn completed without a captured objective.")
|
||||
return false
|
||||
}
|
||||
val pending = pendingDirectSessionStart
|
||||
?: run {
|
||||
publishTrace("Planner turn completed before the direct session moved to RUNNING.")
|
||||
return false
|
||||
}
|
||||
val plannerText = finalAgentMessage?.takeIf(String::isNotBlank)
|
||||
?: run {
|
||||
publishTrace("Planner turn completed without an assistant message.")
|
||||
return false
|
||||
}
|
||||
val plannerRequest = runCatching {
|
||||
AgentTaskPlanner.parsePlannerResponse(
|
||||
responseText = plannerText,
|
||||
userObjective = objective,
|
||||
isEligibleTargetPackage = ::isEligibleTargetPackage,
|
||||
)
|
||||
}.getOrElse { err ->
|
||||
publishTrace("Planner output rejected: ${err.message ?: err::class.java.simpleName}")
|
||||
return false
|
||||
}
|
||||
runCatching {
|
||||
sessionController.startDirectSessionChildren(
|
||||
parentSessionId = sessionId,
|
||||
geniePackage = pending.geniePackage,
|
||||
plan = plannerRequest.plan,
|
||||
allowDetachedMode = plannerRequest.allowDetachedMode,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
}.onFailure { err ->
|
||||
sessionController.failDirectSession(
|
||||
sessionId,
|
||||
"Failed to start planned child session: ${err.message ?: err::class.java.simpleName}",
|
||||
)
|
||||
publishTrace("Planner child start failed: ${err.message ?: err::class.java.simpleName}")
|
||||
}.onSuccess { result ->
|
||||
val heldForInspection = currentDesktopProxy != null &&
|
||||
DesktopInspectionRegistry.holdChildrenForAttachedPlanner(
|
||||
parentSessionId = sessionId,
|
||||
childSessionIds = result.childSessionIds,
|
||||
)
|
||||
if (heldForInspection) {
|
||||
val childSummary = if (result.childSessionIds.size == 1) {
|
||||
"child session ${result.childSessionIds.single()} is paused and attachable"
|
||||
} else {
|
||||
"child sessions ${result.childSessionIds.joinToString(", ")} are paused and attachable"
|
||||
}
|
||||
publishTrace(
|
||||
"Planner completed; $childSummary. Attach ${if (result.childSessionIds.size == 1) "it" else "one of them"} from the desktop to continue while this planner remains attached.",
|
||||
)
|
||||
} else {
|
||||
publishTrace(
|
||||
"Planner completed; started child sessions ${result.childSessionIds.joinToString(", ")}.",
|
||||
)
|
||||
}
|
||||
return false
|
||||
}
|
||||
false
|
||||
}
|
||||
"interrupted" -> {
|
||||
publishTrace("Planner turn interrupted; desktop attach remains active.")
|
||||
false
|
||||
}
|
||||
else -> {
|
||||
publishTrace(
|
||||
turn.opt("error")?.toString()
|
||||
?: "Planner turn failed with status ${turn.optString("status", "unknown")}",
|
||||
)
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isEligibleTargetPackage(packageName: String): Boolean {
|
||||
return sessionController.canStartSessionForTarget(packageName) && packageName !in DISALLOWED_TARGET_PACKAGES
|
||||
}
|
||||
|
||||
private fun publishTrace(message: String) {
|
||||
val agentManager = context.getSystemService(AgentManager::class.java) ?: return
|
||||
runCatching {
|
||||
agentManager.publishTrace(sessionId, message)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Failed to publish planner desktop trace for $sessionId", err)
|
||||
}
|
||||
}
|
||||
|
||||
private fun forwardResponsesRequest(requestBody: String): AgentResponsesProxy.HttpResponse {
|
||||
val agentManager = context.getSystemService(AgentManager::class.java)
|
||||
?: throw IOException("AgentManager unavailable for framework session transport")
|
||||
return AgentResponsesProxy.sendResponsesRequestThroughFramework(
|
||||
agentManager = agentManager,
|
||||
sessionId = sessionId,
|
||||
context = context,
|
||||
requestBody = requestBody,
|
||||
)
|
||||
}
|
||||
|
||||
private fun request(
|
||||
method: String,
|
||||
params: JSONObject,
|
||||
): JSONObject {
|
||||
val requestId = "host-${requestIdSequence.getAndIncrement()}"
|
||||
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 startStdoutPump() {
|
||||
stdoutThread = thread(name = "AgentPlannerDesktopStdout-$sessionId") {
|
||||
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 desktop stdout line", err)
|
||||
return@forEach
|
||||
}
|
||||
routeInbound(line, message)
|
||||
}
|
||||
}
|
||||
} catch (err: InterruptedIOException) {
|
||||
if (!closing.get()) {
|
||||
Log.w(TAG, "Planner desktop stdout interrupted unexpectedly", err)
|
||||
}
|
||||
} catch (err: IOException) {
|
||||
if (!closing.get()) {
|
||||
Log.w(TAG, "Planner desktop stdout failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startStderrPump() {
|
||||
stderrThread = thread(name = "AgentPlannerDesktopStderr-$sessionId") {
|
||||
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 desktop stderr interrupted unexpectedly", err)
|
||||
}
|
||||
} catch (err: IOException) {
|
||||
if (!closing.get()) {
|
||||
Log.w(TAG, "Planner desktop stderr failed", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun routeInbound(
|
||||
rawMessage: String,
|
||||
message: JSONObject,
|
||||
) {
|
||||
if (message.has("id") && !message.has("method")) {
|
||||
val requestId = message.get("id").toString()
|
||||
pendingResponses[requestId]?.offer(message)
|
||||
val remoteRequest = remotePendingRequests.remove(requestId)
|
||||
if (remoteRequest != null) {
|
||||
sendDesktopMessage(
|
||||
JSONObject(message.toString())
|
||||
.put("id", remoteRequest.remoteRequestId)
|
||||
.toString(),
|
||||
remoteRequest.connectionId,
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (message.has("method") && !message.has("id")) {
|
||||
forwardRemoteNotification(rawMessage, message)
|
||||
}
|
||||
inboundMessages.offer(message)
|
||||
}
|
||||
|
||||
private fun handleRemoteProxyMessage(message: String) {
|
||||
val json = runCatching { JSONObject(message) }
|
||||
.getOrElse { err ->
|
||||
sendDesktopMessage(
|
||||
errorResponse(
|
||||
requestId = null,
|
||||
code = -32700,
|
||||
message = err.message ?: "Invalid remote JSON-RPC message",
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
when {
|
||||
json.has("method") && json.has("id") -> handleRemoteProxyRequest(json)
|
||||
json.has("method") -> handleRemoteProxyNotification(json)
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRemoteProxyRequest(message: JSONObject) {
|
||||
val method = message.optString("method")
|
||||
val remoteRequestId = message.opt("id") ?: return
|
||||
when (method) {
|
||||
"initialize" -> {
|
||||
val params = message.optJSONObject("params") ?: JSONObject()
|
||||
val optOut = params
|
||||
.optJSONObject("capabilities")
|
||||
?.optJSONArray("optOutNotificationMethods")
|
||||
?.toStringSet()
|
||||
.orEmpty()
|
||||
val connectionId = checkNotNull(currentDesktopProxy?.connectionId) {
|
||||
"Desktop proxy is unavailable during initialize"
|
||||
}
|
||||
remoteProxyState = RemoteProxyState(
|
||||
connectionId = connectionId,
|
||||
optOutNotificationMethods = optOut,
|
||||
)
|
||||
lastReportedFrameworkEventCount = 0
|
||||
sendDesktopMessage(
|
||||
JSONObject()
|
||||
.put("id", remoteRequestId)
|
||||
.put(
|
||||
"result",
|
||||
JSONObject()
|
||||
.put("userAgent", "android_agent_planner_bridge/$REMOTE_SERVER_VERSION")
|
||||
.put("codexHome", codexHome.absolutePath)
|
||||
.put("platformFamily", "unix")
|
||||
.put("platformOs", "android"),
|
||||
)
|
||||
.toString(),
|
||||
connectionId,
|
||||
)
|
||||
}
|
||||
"account/read" -> {
|
||||
sendDesktopMessage(
|
||||
JSONObject()
|
||||
.put("id", remoteRequestId)
|
||||
.put("result", buildRemoteAccountReadResult())
|
||||
.toString(),
|
||||
)
|
||||
}
|
||||
"turn/start" -> {
|
||||
val params = message.optJSONObject("params") ?: JSONObject()
|
||||
currentObjective = extractTurnPrompt(params)
|
||||
forwardRemoteRequest(message, remoteRequestId)
|
||||
}
|
||||
else -> {
|
||||
forwardRemoteRequest(message, remoteRequestId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun forwardRemoteRequest(
|
||||
message: JSONObject,
|
||||
remoteRequestId: Any,
|
||||
) {
|
||||
val connectionId = currentDesktopProxy?.connectionId
|
||||
if (connectionId.isNullOrBlank()) {
|
||||
sendDesktopMessage(
|
||||
errorResponse(remoteRequestId, -32000, "Remote desktop session is not attached"),
|
||||
)
|
||||
return
|
||||
}
|
||||
val forwardedRequestId = "$REMOTE_REQUEST_ID_PREFIX$connectionId:${message.get("id")}"
|
||||
remotePendingRequests[forwardedRequestId] = RemotePendingRequest(
|
||||
connectionId = connectionId,
|
||||
remoteRequestId = remoteRequestId,
|
||||
)
|
||||
sendMessage(
|
||||
JSONObject(message.toString())
|
||||
.put("id", forwardedRequestId),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleRemoteProxyNotification(message: JSONObject) {
|
||||
when (message.optString("method")) {
|
||||
"initialized" -> maybeEmitFrameworkEvents()
|
||||
else -> sendMessage(JSONObject(message.toString()))
|
||||
}
|
||||
}
|
||||
|
||||
private fun maybeEmitFrameworkEvents() {
|
||||
val proxyState = remoteProxyState ?: return
|
||||
if (proxyState.connectionId != currentDesktopProxy?.connectionId) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
proxyState.optOutNotificationMethods.contains(
|
||||
FrameworkEventBridge.THREAD_FRAMEWORK_EVENT_METHOD,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
val threadId = currentThreadId ?: return
|
||||
val agentManager = context.getSystemService(AgentManager::class.java) ?: return
|
||||
val events = runCatching {
|
||||
agentManager.getSessionEvents(sessionId)
|
||||
}.getOrElse { err ->
|
||||
Log.w(TAG, "Failed to load framework events for planner $sessionId", err)
|
||||
return
|
||||
}
|
||||
if (lastReportedFrameworkEventCount > events.size) {
|
||||
lastReportedFrameworkEventCount = 0
|
||||
}
|
||||
for (index in lastReportedFrameworkEventCount until events.size) {
|
||||
val notification = FrameworkEventBridge.buildThreadFrameworkEventNotification(
|
||||
threadId = threadId,
|
||||
event = events[index],
|
||||
) ?: continue
|
||||
sendDesktopMessage(notification, proxyState.connectionId)
|
||||
}
|
||||
lastReportedFrameworkEventCount = events.size
|
||||
}
|
||||
|
||||
private fun buildRemoteAccountReadResult(): JSONObject {
|
||||
val authenticated = runtimeStatus?.authenticated == true
|
||||
val account = if (authenticated) {
|
||||
JSONObject().put("type", "apiKey")
|
||||
} else {
|
||||
JSONObject.NULL
|
||||
}
|
||||
return JSONObject()
|
||||
.put("account", account)
|
||||
.put("requiresOpenaiAuth", true)
|
||||
}
|
||||
|
||||
private fun extractTurnPrompt(params: JSONObject): String? {
|
||||
val input = params.optJSONArray("input") ?: return null
|
||||
val text = buildString {
|
||||
for (index in 0 until input.length()) {
|
||||
val item = input.optJSONObject(index) ?: continue
|
||||
if (item.optString("type") != "text") {
|
||||
continue
|
||||
}
|
||||
val value = item.optString("text").trim()
|
||||
if (value.isEmpty()) {
|
||||
continue
|
||||
}
|
||||
if (isNotEmpty()) {
|
||||
append('\n')
|
||||
}
|
||||
append(value)
|
||||
}
|
||||
}.trim()
|
||||
return text.ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun forwardRemoteNotification(
|
||||
rawMessage: String,
|
||||
message: JSONObject,
|
||||
) {
|
||||
val proxyState = remoteProxyState ?: return
|
||||
if (proxyState.connectionId != currentDesktopProxy?.connectionId) {
|
||||
return
|
||||
}
|
||||
val method = message.optString("method")
|
||||
if (proxyState.optOutNotificationMethods.contains(method)) {
|
||||
return
|
||||
}
|
||||
sendDesktopMessage(rawMessage, proxyState.connectionId)
|
||||
}
|
||||
|
||||
private fun sendDesktopMessage(
|
||||
message: String,
|
||||
connectionId: String? = currentDesktopProxy?.connectionId,
|
||||
) {
|
||||
val proxy = currentDesktopProxy
|
||||
if (proxy == null || connectionId == null || proxy.connectionId != connectionId) {
|
||||
return
|
||||
}
|
||||
pendingDesktopMessages.offer(
|
||||
PendingDesktopMessage(
|
||||
connectionId = connectionId,
|
||||
message = message,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun errorResponse(
|
||||
requestId: Any?,
|
||||
code: Int,
|
||||
message: String,
|
||||
): String {
|
||||
return JSONObject()
|
||||
.put("id", requestId)
|
||||
.put(
|
||||
"error",
|
||||
JSONObject()
|
||||
.put("code", code)
|
||||
.put("message", message),
|
||||
)
|
||||
.toString()
|
||||
}
|
||||
|
||||
private fun JSONArray.toStringSet(): Set<String> {
|
||||
return buildSet {
|
||||
for (index in 0 until length()) {
|
||||
optString(index).takeIf(String::isNotBlank)?.let(::add)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchDesktopMessages() {
|
||||
while (!closing.get()) {
|
||||
val pending = try {
|
||||
pendingDesktopMessages.take()
|
||||
} catch (_: InterruptedException) {
|
||||
return
|
||||
}
|
||||
val proxy = currentDesktopProxy
|
||||
if (proxy == null || proxy.connectionId != pending.connectionId) {
|
||||
continue
|
||||
}
|
||||
runCatching {
|
||||
proxy.onMessage(pending.message)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Failed to deliver planner desktop message for $sessionId", err)
|
||||
closeDesktopProxy(
|
||||
connectionId = pending.connectionId,
|
||||
reason = err.message ?: err::class.java.simpleName,
|
||||
detachPlanner = false,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,518 @@
|
||||
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>()
|
||||
private val desktopPlannerSessions = ConcurrentHashMap<String, AgentPlannerDesktopSessionHost>()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
fun ensureIdleDesktopSession(
|
||||
context: Context,
|
||||
sessionController: AgentSessionController,
|
||||
sessionId: String,
|
||||
) {
|
||||
check(!activePlannerSessions.containsKey(sessionId)) {
|
||||
"Planner runtime already active for parent session $sessionId"
|
||||
}
|
||||
desktopPlannerSessions.computeIfAbsent(sessionId) {
|
||||
AgentPlannerDesktopSessionHost(
|
||||
context = context.applicationContext,
|
||||
sessionController = sessionController,
|
||||
sessionId = sessionId,
|
||||
onClosed = {
|
||||
desktopPlannerSessions.remove(sessionId)
|
||||
},
|
||||
).also(AgentPlannerDesktopSessionHost::start)
|
||||
}
|
||||
}
|
||||
|
||||
fun activeThreadId(sessionId: String): String? = desktopPlannerSessions[sessionId]?.activeThreadId()
|
||||
|
||||
fun openDesktopProxy(
|
||||
sessionId: String,
|
||||
onMessage: (String) -> Unit,
|
||||
onClosed: (String?) -> Unit,
|
||||
): String? = desktopPlannerSessions[sessionId]?.openDesktopProxy(onMessage, onClosed)
|
||||
|
||||
fun sendDesktopProxyInput(
|
||||
sessionId: String,
|
||||
connectionId: String,
|
||||
message: String,
|
||||
): Boolean = desktopPlannerSessions[sessionId]?.sendDesktopProxyInput(connectionId, message) ?: false
|
||||
|
||||
fun closeDesktopProxy(
|
||||
sessionId: String,
|
||||
connectionId: String,
|
||||
reason: String? = null,
|
||||
detachPlanner: Boolean = false,
|
||||
) {
|
||||
desktopPlannerSessions[sessionId]?.closeDesktopProxy(connectionId, reason, detachPlanner)
|
||||
}
|
||||
|
||||
fun closeSession(sessionId: String) {
|
||||
desktopPlannerSessions.remove(sessionId)?.close()
|
||||
}
|
||||
|
||||
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("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,378 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.RemoteInput
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.drawable.Icon
|
||||
import android.os.Build
|
||||
|
||||
object AgentQuestionNotifier {
|
||||
const val ACTION_REPLY_FROM_NOTIFICATION =
|
||||
"com.openai.codex.agent.action.REPLY_FROM_NOTIFICATION"
|
||||
const val EXTRA_SESSION_ID = "sessionId"
|
||||
const val EXTRA_NOTIFICATION_TOKEN = "notificationToken"
|
||||
const val REMOTE_INPUT_KEY = "codexAgentNotificationReply"
|
||||
|
||||
private const val CHANNEL_ID = "codex_agent_questions"
|
||||
private const val CHANNEL_NAME = "Codex Agent Questions"
|
||||
private const val MAX_CONTENT_PREVIEW_CHARS = 400
|
||||
private val notificationStateLock = Any()
|
||||
private val activeNotificationTokens = mutableMapOf<String, String>()
|
||||
private val retiredNotificationTokens = mutableMapOf<String, MutableSet<String>>()
|
||||
private val suppressedNotificationTokens = mutableMapOf<String, MutableSet<String>>()
|
||||
|
||||
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 showOrUpdateDelegatedNotification(
|
||||
context: Context,
|
||||
session: AgentSessionInfo,
|
||||
notificationToken: String,
|
||||
notificationText: String,
|
||||
): Boolean {
|
||||
if (!activateNotificationToken(session.sessionId, notificationToken)) {
|
||||
return false
|
||||
}
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return false
|
||||
if (
|
||||
!shouldShowDelegatedNotification(session.state) ||
|
||||
isSuppressedNotificationToken(session.sessionId, notificationToken)
|
||||
) {
|
||||
manager.cancel(notificationId(session.sessionId))
|
||||
return true
|
||||
}
|
||||
if (notificationText.isBlank()) {
|
||||
return false
|
||||
}
|
||||
ensureChannel(manager)
|
||||
manager.notify(
|
||||
notificationId(session.sessionId),
|
||||
buildDelegatedNotification(
|
||||
context = context,
|
||||
session = session,
|
||||
notificationToken = notificationToken,
|
||||
notificationText = notificationText.trim(),
|
||||
),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
private fun shouldShowDelegatedNotification(state: Int): Boolean {
|
||||
return when (state) {
|
||||
AgentSessionInfo.STATE_WAITING_FOR_USER,
|
||||
AgentSessionInfo.STATE_COMPLETED,
|
||||
AgentSessionInfo.STATE_FAILED,
|
||||
AgentSessionInfo.STATE_CANCELLED,
|
||||
-> true
|
||||
AgentSessionInfo.STATE_CREATED,
|
||||
AgentSessionInfo.STATE_QUEUED,
|
||||
AgentSessionInfo.STATE_RUNNING,
|
||||
-> false
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
fun suppress(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
notificationToken: String,
|
||||
) {
|
||||
if (!suppressNotificationToken(sessionId, notificationToken)) {
|
||||
return
|
||||
}
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
manager.cancel(notificationId(sessionId))
|
||||
}
|
||||
|
||||
fun cancel(context: Context, sessionId: String) {
|
||||
retireActiveNotificationToken(sessionId)
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
manager.cancel(notificationId(sessionId))
|
||||
}
|
||||
|
||||
fun clearSessionState(sessionId: String) {
|
||||
clearNotificationToken(sessionId)
|
||||
}
|
||||
|
||||
fun cancel(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
notificationToken: String,
|
||||
) {
|
||||
if (!retireNotificationToken(sessionId, notificationToken)) {
|
||||
return
|
||||
}
|
||||
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),
|
||||
SessionPopupActivity.intent(context, sessionId).apply {
|
||||
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 buildDelegatedNotification(
|
||||
context: Context,
|
||||
session: AgentSessionInfo,
|
||||
notificationToken: String,
|
||||
notificationText: String,
|
||||
): Notification {
|
||||
val targetIdentity = resolveTargetIdentity(context, session.targetPackage)
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
context,
|
||||
notificationId(session.sessionId),
|
||||
SessionPopupActivity.intent(context, session.sessionId).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val contentText = notificationText.take(MAX_CONTENT_PREVIEW_CHARS)
|
||||
val builder = Notification.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(targetIdentity.icon)
|
||||
.setLargeIcon(targetIdentity.icon)
|
||||
.setContentTitle(buildNotificationTitle(session.state, targetIdentity.displayName))
|
||||
.setContentText(contentText)
|
||||
.setStyle(Notification.BigTextStyle().bigText(contentText))
|
||||
.setContentIntent(contentIntent)
|
||||
.setAutoCancel(false)
|
||||
.setOngoing(true)
|
||||
buildInlineReplyAction(
|
||||
context = context,
|
||||
session = session,
|
||||
notificationToken = notificationToken,
|
||||
)?.let { replyAction ->
|
||||
builder.addAction(replyAction)
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
private fun buildNotificationTitle(
|
||||
state: Int,
|
||||
targetDisplayName: String,
|
||||
): String {
|
||||
return when (state) {
|
||||
AgentSessionInfo.STATE_WAITING_FOR_USER ->
|
||||
"Codex needs input for $targetDisplayName"
|
||||
AgentSessionInfo.STATE_COMPLETED ->
|
||||
"Codex finished $targetDisplayName"
|
||||
AgentSessionInfo.STATE_FAILED ->
|
||||
"Codex hit an issue in $targetDisplayName"
|
||||
AgentSessionInfo.STATE_CANCELLED ->
|
||||
"Codex cancelled $targetDisplayName"
|
||||
AgentSessionInfo.STATE_CREATED,
|
||||
AgentSessionInfo.STATE_QUEUED,
|
||||
AgentSessionInfo.STATE_RUNNING,
|
||||
-> "Codex session for $targetDisplayName"
|
||||
else -> "Codex session for $targetDisplayName"
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildInlineReplyAction(
|
||||
context: Context,
|
||||
session: AgentSessionInfo,
|
||||
notificationToken: String,
|
||||
): Notification.Action? {
|
||||
if (session.state != AgentSessionInfo.STATE_WAITING_FOR_USER || notificationToken.isBlank()) {
|
||||
return null
|
||||
}
|
||||
val replyIntent = PendingIntent.getBroadcast(
|
||||
context,
|
||||
notificationId(session.sessionId),
|
||||
Intent(context, AgentNotificationReplyReceiver::class.java).apply {
|
||||
action = ACTION_REPLY_FROM_NOTIFICATION
|
||||
putExtra(EXTRA_SESSION_ID, session.sessionId)
|
||||
putExtra(EXTRA_NOTIFICATION_TOKEN, notificationToken)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
|
||||
)
|
||||
val remoteInput = RemoteInput.Builder(REMOTE_INPUT_KEY)
|
||||
.setLabel("Reply")
|
||||
.build()
|
||||
return Notification.Action.Builder(
|
||||
Icon.createWithResource(context, android.R.drawable.ic_menu_send),
|
||||
"Reply",
|
||||
replyIntent,
|
||||
)
|
||||
.addRemoteInput(remoteInput)
|
||||
.setAllowGeneratedReplies(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 activateNotificationToken(
|
||||
sessionId: String,
|
||||
notificationToken: String,
|
||||
): Boolean {
|
||||
if (notificationToken.isBlank()) {
|
||||
return false
|
||||
}
|
||||
synchronized(notificationStateLock) {
|
||||
if (retiredNotificationTokens[sessionId]?.contains(notificationToken) == true) {
|
||||
return false
|
||||
}
|
||||
activeNotificationTokens.put(sessionId, notificationToken)?.let { previousToken ->
|
||||
if (previousToken != notificationToken) {
|
||||
retiredNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
|
||||
.add(previousToken)
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun clearNotificationToken(sessionId: String) {
|
||||
synchronized(notificationStateLock) {
|
||||
activeNotificationTokens.remove(sessionId)
|
||||
retiredNotificationTokens.remove(sessionId)
|
||||
suppressedNotificationTokens.remove(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun retireNotificationToken(
|
||||
sessionId: String,
|
||||
notificationToken: String,
|
||||
): Boolean {
|
||||
if (notificationToken.isBlank()) {
|
||||
retireActiveNotificationToken(sessionId)
|
||||
return true
|
||||
}
|
||||
synchronized(notificationStateLock) {
|
||||
retiredNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
|
||||
.add(notificationToken)
|
||||
suppressedNotificationTokens[sessionId]?.remove(notificationToken)
|
||||
if (activeNotificationTokens[sessionId] != notificationToken) {
|
||||
return false
|
||||
}
|
||||
activeNotificationTokens.remove(sessionId)
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
private fun retireActiveNotificationToken(sessionId: String) {
|
||||
synchronized(notificationStateLock) {
|
||||
activeNotificationTokens.remove(sessionId)?.let { notificationToken ->
|
||||
retiredNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
|
||||
.add(notificationToken)
|
||||
suppressedNotificationTokens[sessionId]?.remove(notificationToken)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun suppressNotificationToken(
|
||||
sessionId: String,
|
||||
notificationToken: String,
|
||||
): Boolean {
|
||||
if (!activateNotificationToken(sessionId, notificationToken)) {
|
||||
return false
|
||||
}
|
||||
synchronized(notificationStateLock) {
|
||||
suppressedNotificationTokens.getOrPut(sessionId, ::mutableSetOf)
|
||||
.add(notificationToken)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private fun isSuppressedNotificationToken(
|
||||
sessionId: String,
|
||||
notificationToken: String,
|
||||
): Boolean {
|
||||
synchronized(notificationStateLock) {
|
||||
return suppressedNotificationTokens[sessionId]?.contains(notificationToken) == true
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolveTargetIdentity(
|
||||
context: Context,
|
||||
targetPackage: String?,
|
||||
): TargetIdentity {
|
||||
if (targetPackage.isNullOrBlank()) {
|
||||
return TargetIdentity(
|
||||
displayName = "Codex Agent",
|
||||
icon = Icon.createWithResource(context, android.R.drawable.ic_dialog_info),
|
||||
)
|
||||
}
|
||||
val packageManager = context.packageManager
|
||||
return runCatching {
|
||||
val appInfo = packageManager.getApplicationInfo(
|
||||
targetPackage,
|
||||
PackageManager.ApplicationInfoFlags.of(0),
|
||||
)
|
||||
val iconResId = appInfo.icon.takeIf { it != 0 }
|
||||
TargetIdentity(
|
||||
displayName = packageManager.getApplicationLabel(appInfo).toString()
|
||||
.ifBlank { targetPackage },
|
||||
icon = if (iconResId == null) {
|
||||
Icon.createWithResource(context, android.R.drawable.ic_dialog_info)
|
||||
} else {
|
||||
Icon.createWithResource(targetPackage, iconResId)
|
||||
},
|
||||
)
|
||||
}.getOrDefault(
|
||||
TargetIdentity(
|
||||
displayName = targetPackage,
|
||||
icon = Icon.createWithResource(context, android.R.drawable.ic_dialog_info),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun notificationId(sessionId: String): Int {
|
||||
return sessionId.hashCode()
|
||||
}
|
||||
|
||||
private data class TargetIdentity(
|
||||
val displayName: String,
|
||||
val icon: Icon,
|
||||
)
|
||||
}
|
||||
@@ -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,399 @@
|
||||
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.LinkedBlockingQueue
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import kotlin.concurrent.thread
|
||||
import org.json.JSONObject
|
||||
|
||||
object AgentSessionBridgeServer {
|
||||
private val runningBridges = ConcurrentHashMap<String, RunningBridge>()
|
||||
|
||||
private const val TAG = "AgentSessionBridge"
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fun activeThreadId(sessionId: String): String? = runningBridges[sessionId]?.activeThreadId()
|
||||
|
||||
fun openDesktopProxy(
|
||||
sessionId: String,
|
||||
onMessage: (String) -> Unit,
|
||||
onClosed: (String?) -> Unit,
|
||||
): String? = runningBridges[sessionId]?.openDesktopProxy(onMessage, onClosed)
|
||||
|
||||
fun sendDesktopProxyInput(
|
||||
sessionId: String,
|
||||
connectionId: String,
|
||||
message: String,
|
||||
): Boolean = runningBridges[sessionId]?.sendDesktopProxyInput(connectionId, message) ?: false
|
||||
|
||||
fun closeDesktopProxy(
|
||||
sessionId: String,
|
||||
connectionId: String,
|
||||
reason: String? = null,
|
||||
) {
|
||||
runningBridges[sessionId]?.closeDesktopProxy(connectionId, reason)
|
||||
}
|
||||
|
||||
private class RunningBridge(
|
||||
private val context: Context,
|
||||
private val agentManager: AgentManager,
|
||||
private val sessionId: String,
|
||||
) : Closeable {
|
||||
companion object {
|
||||
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 METHOD_READ_DESKTOP_INSPECTION_HOLD = "readDesktopInspectionHold"
|
||||
private const val METHOD_REGISTER_APP_SERVER_THREAD = "registerAppServerThread"
|
||||
private const val WRITE_CHUNK_BYTES = 4096
|
||||
private const val KIND_REQUEST = "request"
|
||||
private const val KIND_RESPONSE = "response"
|
||||
private const val KIND_REMOTE_CLIENT_MESSAGE = "remoteAppServerClientMessage"
|
||||
private const val KIND_REMOTE_SERVER_MESSAGE = "remoteAppServerServerMessage"
|
||||
private const val KIND_REMOTE_CLOSED = "remoteAppServerClosed"
|
||||
}
|
||||
|
||||
private data class DesktopProxy(
|
||||
val connectionId: String,
|
||||
val onMessage: (String) -> Unit,
|
||||
val onClosed: (String?) -> Unit,
|
||||
)
|
||||
|
||||
private data class PendingDesktopMessage(
|
||||
val connectionId: String,
|
||||
val message: String,
|
||||
)
|
||||
|
||||
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 writerLock = Any()
|
||||
private val proxyLock = Any()
|
||||
private val pendingDesktopMessages = LinkedBlockingQueue<PendingDesktopMessage>()
|
||||
@Volatile
|
||||
private var currentDesktopProxy: DesktopProxy? = null
|
||||
@Volatile
|
||||
private var currentThreadId: String? = null
|
||||
private val serveThread = thread(
|
||||
start = false,
|
||||
name = "AgentSessionBridge-$sessionId",
|
||||
) {
|
||||
serveLoop()
|
||||
}
|
||||
private val desktopDispatchThread = thread(
|
||||
start = false,
|
||||
name = "AgentSessionBridgeDesktop-$sessionId",
|
||||
) {
|
||||
dispatchDesktopMessages()
|
||||
}
|
||||
|
||||
fun start() {
|
||||
serveThread.start()
|
||||
desktopDispatchThread.start()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
val proxy = synchronized(proxyLock) {
|
||||
currentDesktopProxy.also {
|
||||
currentDesktopProxy = null
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
proxy?.onClosed("Agent session bridge closed")
|
||||
}
|
||||
runCatching { input?.close() }
|
||||
runCatching { output?.close() }
|
||||
runCatching { bridgeFd?.close() }
|
||||
serveThread.interrupt()
|
||||
desktopDispatchThread.interrupt()
|
||||
}
|
||||
|
||||
fun activeThreadId(): String? = currentThreadId
|
||||
|
||||
fun openDesktopProxy(
|
||||
onMessage: (String) -> Unit,
|
||||
onClosed: (String?) -> Unit,
|
||||
): String? {
|
||||
val threadId = currentThreadId ?: return null
|
||||
val connectionId = UUID.randomUUID().toString()
|
||||
val replacement = synchronized(proxyLock) {
|
||||
currentDesktopProxy.also {
|
||||
currentDesktopProxy = DesktopProxy(connectionId, onMessage, onClosed)
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
replacement?.onClosed("Replaced by a newer desktop attach")
|
||||
}
|
||||
return connectionId
|
||||
}
|
||||
|
||||
fun sendDesktopProxyInput(
|
||||
connectionId: String,
|
||||
message: String,
|
||||
): Boolean {
|
||||
val proxy = currentDesktopProxy
|
||||
if (proxy?.connectionId != connectionId) {
|
||||
return false
|
||||
}
|
||||
sendBridgeMessage(
|
||||
JSONObject()
|
||||
.put("kind", KIND_REMOTE_CLIENT_MESSAGE)
|
||||
.put("connectionId", connectionId)
|
||||
.put("message", message),
|
||||
)
|
||||
return true
|
||||
}
|
||||
|
||||
fun closeDesktopProxy(
|
||||
connectionId: String,
|
||||
reason: String? = null,
|
||||
) {
|
||||
val proxy = synchronized(proxyLock) {
|
||||
currentDesktopProxy?.takeIf { it.connectionId == connectionId }?.also {
|
||||
currentDesktopProxy = null
|
||||
}
|
||||
} ?: return
|
||||
sendBridgeMessage(
|
||||
JSONObject()
|
||||
.put("kind", KIND_REMOTE_CLOSED)
|
||||
.put("connectionId", connectionId)
|
||||
.put("reason", reason),
|
||||
)
|
||||
runCatching {
|
||||
proxy.onClosed(reason)
|
||||
}
|
||||
}
|
||||
|
||||
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 message = try {
|
||||
readMessage(input ?: break)
|
||||
} catch (_: EOFException) {
|
||||
return
|
||||
}
|
||||
when (message.optString("kind", KIND_REQUEST)) {
|
||||
KIND_REQUEST -> {
|
||||
val response = handleRequest(message)
|
||||
sendBridgeMessage(response)
|
||||
}
|
||||
KIND_REMOTE_SERVER_MESSAGE -> {
|
||||
handleRemoteServerMessage(message)
|
||||
}
|
||||
KIND_REMOTE_CLOSED -> {
|
||||
handleRemoteClosed(message)
|
||||
}
|
||||
else -> {
|
||||
Log.w(TAG, "Ignoring unsupported Agent bridge message for $sessionId: $message")
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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("kind", KIND_RESPONSE)
|
||||
.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("kind", KIND_RESPONSE)
|
||||
.put("requestId", requestId)
|
||||
.put("ok", true)
|
||||
.put("agentsMarkdown", HostedCodexConfig.readInstalledAgentsMarkdown(codexHome))
|
||||
}
|
||||
METHOD_READ_SESSION_EXECUTION_SETTINGS -> {
|
||||
JSONObject()
|
||||
.put("kind", KIND_RESPONSE)
|
||||
.put("requestId", requestId)
|
||||
.put("ok", true)
|
||||
.put("executionSettings", executionSettingsStore.toJson(sessionId))
|
||||
}
|
||||
METHOD_READ_DESKTOP_INSPECTION_HOLD -> {
|
||||
JSONObject()
|
||||
.put("kind", KIND_RESPONSE)
|
||||
.put("requestId", requestId)
|
||||
.put("ok", true)
|
||||
.put("inspectionHold", DesktopInspectionRegistry.isSessionHeldForInspection(sessionId))
|
||||
}
|
||||
METHOD_REGISTER_APP_SERVER_THREAD -> {
|
||||
currentThreadId = request.optString("threadId").trim().ifEmpty { null }
|
||||
JSONObject()
|
||||
.put("kind", KIND_RESPONSE)
|
||||
.put("requestId", requestId)
|
||||
.put("ok", true)
|
||||
}
|
||||
else -> {
|
||||
JSONObject()
|
||||
.put("kind", KIND_RESPONSE)
|
||||
.put("requestId", requestId)
|
||||
.put("ok", false)
|
||||
.put("error", "Unsupported bridge method: ${request.optString("method")}")
|
||||
}
|
||||
}
|
||||
}.getOrElse { err ->
|
||||
JSONObject()
|
||||
.put("kind", KIND_RESPONSE)
|
||||
.put("requestId", requestId)
|
||||
.put("ok", false)
|
||||
.put("error", err.message ?: err::class.java.simpleName)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleRemoteServerMessage(message: JSONObject) {
|
||||
val connectionId = message.optString("connectionId")
|
||||
val proxy = currentDesktopProxy
|
||||
if (proxy == null || proxy.connectionId != connectionId) {
|
||||
return
|
||||
}
|
||||
pendingDesktopMessages.offer(
|
||||
PendingDesktopMessage(
|
||||
connectionId = connectionId,
|
||||
message = message.optString("message"),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun handleRemoteClosed(message: JSONObject) {
|
||||
val connectionId = message.optString("connectionId")
|
||||
val reason = message.optString("reason").ifBlank { null }
|
||||
val proxy = synchronized(proxyLock) {
|
||||
currentDesktopProxy?.takeIf { it.connectionId == connectionId }?.also {
|
||||
currentDesktopProxy = null
|
||||
}
|
||||
} ?: return
|
||||
runCatching {
|
||||
proxy.onClosed(reason)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendBridgeMessage(message: JSONObject) {
|
||||
synchronized(writerLock) {
|
||||
writeMessage(output ?: throw IOException("Session bridge output unavailable"), message)
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchDesktopMessages() {
|
||||
while (!closed.get()) {
|
||||
val pending = try {
|
||||
pendingDesktopMessages.take()
|
||||
} catch (_: InterruptedException) {
|
||||
return
|
||||
}
|
||||
val proxy = currentDesktopProxy
|
||||
if (proxy == null || proxy.connectionId != pending.connectionId) {
|
||||
continue
|
||||
}
|
||||
runCatching {
|
||||
proxy.onMessage(pending.message)
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Desktop proxy delivery failed for $sessionId", err)
|
||||
closeDesktopProxy(pending.connectionId, err.message ?: err::class.java.simpleName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
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 CreateSessionRequest(
|
||||
val targetPackage: String?,
|
||||
val model: String?,
|
||||
val reasoningEffort: String?,
|
||||
)
|
||||
|
||||
data class LaunchSessionRequest(
|
||||
val prompt: String,
|
||||
val targetPackage: String?,
|
||||
val model: String?,
|
||||
val reasoningEffort: String?,
|
||||
val existingSessionId: String? = null,
|
||||
)
|
||||
|
||||
data class StartSessionRequest(
|
||||
val sessionId: String,
|
||||
val prompt: String,
|
||||
)
|
||||
|
||||
data class SessionDraftResult(
|
||||
val sessionId: String,
|
||||
val anchor: Int,
|
||||
)
|
||||
|
||||
object AgentSessionLauncher {
|
||||
fun createSessionDraft(
|
||||
request: CreateSessionRequest,
|
||||
sessionController: AgentSessionController,
|
||||
): SessionDraftResult {
|
||||
val executionSettings = SessionExecutionSettings(
|
||||
model = request.model?.trim()?.ifEmpty { null },
|
||||
reasoningEffort = request.reasoningEffort?.trim()?.ifEmpty { null },
|
||||
)
|
||||
val targetPackage = request.targetPackage?.trim()?.ifEmpty { null }
|
||||
return if (targetPackage == null) {
|
||||
SessionDraftResult(
|
||||
sessionId = sessionController.createDirectSessionDraft(executionSettings),
|
||||
anchor = AgentSessionInfo.ANCHOR_AGENT,
|
||||
)
|
||||
} else {
|
||||
SessionDraftResult(
|
||||
sessionId = sessionController.createHomeSessionDraft(
|
||||
targetPackage = targetPackage,
|
||||
finalPresentationPolicy = SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
executionSettings = executionSettings,
|
||||
),
|
||||
anchor = AgentSessionInfo.ANCHOR_HOME,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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 draftSession = createSessionDraft(
|
||||
request = CreateSessionRequest(
|
||||
targetPackage = null,
|
||||
model = executionSettings.model,
|
||||
reasoningEffort = executionSettings.reasoningEffort,
|
||||
),
|
||||
sessionController = sessionController,
|
||||
)
|
||||
return startSessionDraftAsync(
|
||||
context = context,
|
||||
request = StartSessionRequest(
|
||||
sessionId = draftSession.sessionId,
|
||||
prompt = request.prompt,
|
||||
),
|
||||
sessionController = sessionController,
|
||||
requestUserInputHandler = requestUserInputHandler,
|
||||
)
|
||||
}
|
||||
|
||||
fun startSessionDraftAsync(
|
||||
context: Context,
|
||||
request: StartSessionRequest,
|
||||
sessionController: AgentSessionController,
|
||||
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
|
||||
): SessionStartResult {
|
||||
val sessionId = request.sessionId.trim()
|
||||
require(sessionId.isNotEmpty()) { "Missing session id" }
|
||||
val prompt = request.prompt.trim()
|
||||
require(prompt.isNotEmpty()) { "Missing prompt" }
|
||||
val snapshot = sessionController.loadSnapshot(sessionId)
|
||||
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId }
|
||||
?: throw IllegalArgumentException("Unknown session: $sessionId")
|
||||
if (
|
||||
session.anchor == AgentSessionInfo.ANCHOR_HOME &&
|
||||
session.parentSessionId == null &&
|
||||
!session.targetPackage.isNullOrBlank()
|
||||
) {
|
||||
return sessionController.startExistingHomeSession(
|
||||
sessionId = sessionId,
|
||||
targetPackage = checkNotNull(session.targetPackage),
|
||||
prompt = prompt,
|
||||
allowDetachedMode = true,
|
||||
finalPresentationPolicy = session.requiredFinalPresentationPolicy
|
||||
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
executionSettings = sessionController.executionSettingsForSession(sessionId),
|
||||
)
|
||||
}
|
||||
check(
|
||||
session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
|
||||
session.parentSessionId == null &&
|
||||
session.targetPackage == null,
|
||||
) {
|
||||
"Session $sessionId is not a startable draft"
|
||||
}
|
||||
check(AgentPlannerRuntimeManager.activeThreadId(sessionId) == null) {
|
||||
"Session $sessionId is already attached to an idle planner runtime; send the first prompt through the attached client"
|
||||
}
|
||||
val executionSettings = sessionController.executionSettingsForSession(sessionId)
|
||||
val pendingSession = sessionController.prepareDirectSessionDraftForStart(
|
||||
sessionId = sessionId,
|
||||
objective = prompt,
|
||||
executionSettings = executionSettings,
|
||||
)
|
||||
val applicationContext = context.applicationContext
|
||||
thread(name = "CodexAgentPlanner-${pendingSession.parentSessionId}") {
|
||||
runCatching {
|
||||
AgentTaskPlanner.planSession(
|
||||
context = applicationContext,
|
||||
userObjective = 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,300 @@
|
||||
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`.
|
||||
- Prefer `DETACHED_HIDDEN` by default so the target app stays in the background while the Agent reports the outcome.
|
||||
- Use `ATTACHED` only when the user explicitly asks to leave the app open, bring it to the front, show them the resulting screen, or the request clearly implies that the final UI must be visible.
|
||||
- Use `DETACHED_SHOWN` only when the user asks for the app to remain visibly shown in detached mode rather than attached.
|
||||
- Use `AGENT_CHOICE` only when there is a strong reason to let the Genie choose the final presentation dynamically; otherwise pick `DETACHED_HIDDEN`.
|
||||
- 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)
|
||||
|
||||
internal fun plannerInstructions(): String = PLANNER_INSTRUCTIONS
|
||||
|
||||
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,420 @@
|
||||
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 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 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 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 (isTerminalSessionState(session.state) && !DesktopInspectionRegistry.isPlannerAttached(session.sessionId)) {
|
||||
AgentPlannerRuntimeManager.closeSession(session.sessionId)
|
||||
}
|
||||
if (session.state != AgentSessionInfo.STATE_WAITING_FOR_USER) {
|
||||
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)
|
||||
AgentPlannerRuntimeManager.closeSession(sessionId)
|
||||
DesktopInspectionRegistry.removeSession(sessionId)
|
||||
AgentQuestionNotifier.cancel(this, sessionId)
|
||||
AgentQuestionNotifier.clearSessionState(sessionId)
|
||||
presentationPolicyStore.removePolicy(sessionId)
|
||||
handledGenieQuestions.removeIf { it.startsWith("$sessionId:") }
|
||||
handledBridgeRequests.removeIf { it.startsWith("$sessionId:") }
|
||||
pendingGenieQuestions.removeIf { it.startsWith("$sessionId:") }
|
||||
}
|
||||
|
||||
override fun onShowOrUpdateSessionNotification(
|
||||
session: AgentSessionInfo,
|
||||
notificationToken: String,
|
||||
notificationText: String,
|
||||
) {
|
||||
showOrUpdateSessionNotification(
|
||||
session = session,
|
||||
notificationToken = notificationToken,
|
||||
notificationText = notificationText,
|
||||
)
|
||||
}
|
||||
|
||||
override fun onCancelSessionNotification(
|
||||
sessionId: String,
|
||||
notificationToken: String,
|
||||
reason: Int,
|
||||
) {
|
||||
cancelSessionNotification(
|
||||
sessionId = sessionId,
|
||||
notificationToken = notificationToken,
|
||||
reason = reason,
|
||||
)
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
},
|
||||
)
|
||||
val deferTerminalRollup =
|
||||
DesktopInspectionRegistry.isPlannerAttached(parentSessionId) &&
|
||||
isTerminalSessionState(rollup.state)
|
||||
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 (!deferTerminalRollup && 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 (!deferTerminalRollup && (rollup.resultMessage != null || rollup.errorMessage != null)) {
|
||||
manager.getSessionEvents(parentSessionId)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
if (
|
||||
!deferTerminalRollup &&
|
||||
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 (
|
||||
!deferTerminalRollup &&
|
||||
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
|
||||
if (!isBridgeQuestion(question)) {
|
||||
return
|
||||
}
|
||||
maybeAutoAnswerGenieQuestion(session, question)
|
||||
}
|
||||
|
||||
private fun maybeAutoAnswerGenieQuestion(
|
||||
session: AgentSessionInfo,
|
||||
question: String,
|
||||
) {
|
||||
if (!isBridgeQuestion(question)) {
|
||||
return
|
||||
}
|
||||
val questionKey = genieQuestionKey(session.sessionId, question)
|
||||
if (handledGenieQuestions.contains(questionKey) || !pendingGenieQuestions.add(questionKey)) {
|
||||
return
|
||||
}
|
||||
thread(name = "CodexAgentAutoAnswer-${session.sessionId}") {
|
||||
Log.i(TAG, "Attempting Agent bridge-answer for ${session.sessionId}")
|
||||
runCatching {
|
||||
answerBridgeQuestion(session, question)
|
||||
handledGenieQuestions.add(questionKey)
|
||||
AgentQuestionNotifier.cancel(this, session.sessionId)
|
||||
Log.i(TAG, "Answered bridge question for ${session.sessionId}")
|
||||
}.onFailure { err ->
|
||||
Log.i(TAG, "Agent bridge-answer unavailable for ${session.sessionId}: ${err.message}")
|
||||
}
|
||||
pendingGenieQuestions.remove(questionKey)
|
||||
}
|
||||
}
|
||||
|
||||
private fun showOrUpdateSessionNotification(
|
||||
session: AgentSessionInfo,
|
||||
notificationToken: String,
|
||||
notificationText: String,
|
||||
) {
|
||||
thread(name = "CodexAgentNotificationShow-${session.sessionId}") {
|
||||
val posted = runCatching {
|
||||
AgentQuestionNotifier.showOrUpdateDelegatedNotification(
|
||||
context = this,
|
||||
session = session,
|
||||
notificationToken = notificationToken,
|
||||
notificationText = notificationText,
|
||||
)
|
||||
}.onFailure { err ->
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to post delegated notification sessionId=${session.sessionId} token=$notificationToken",
|
||||
err,
|
||||
)
|
||||
}.getOrDefault(false)
|
||||
if (!posted) {
|
||||
return@thread
|
||||
}
|
||||
runCatching {
|
||||
sessionController.ackSessionNotification(session.sessionId, notificationToken)
|
||||
}.onFailure { err ->
|
||||
Log.w(
|
||||
TAG,
|
||||
"Failed to ack delegated notification sessionId=${session.sessionId} token=$notificationToken",
|
||||
err,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelSessionNotification(
|
||||
sessionId: String,
|
||||
notificationToken: String,
|
||||
reason: Int,
|
||||
) {
|
||||
thread(name = "CodexAgentNotificationCancel-$sessionId") {
|
||||
Log.i(
|
||||
TAG,
|
||||
"Cancelling delegated notification sessionId=$sessionId token=$notificationToken reason=${notificationCancelReasonToString(reason)}",
|
||||
)
|
||||
AgentQuestionNotifier.cancel(
|
||||
context = this,
|
||||
sessionId = sessionId,
|
||||
notificationToken = notificationToken,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun notificationCancelReasonToString(reason: Int): String {
|
||||
return when (reason) {
|
||||
NOTIFICATION_CANCEL_REASON_SUPPRESSED -> "SUPPRESSED"
|
||||
NOTIFICATION_CANCEL_REASON_REMOVED -> "REMOVED"
|
||||
else -> reason.toString()
|
||||
}
|
||||
}
|
||||
|
||||
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 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,600 @@
|
||||
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(
|
||||
SessionPopupActivity.intent(this, incomingSessionId)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP),
|
||||
)
|
||||
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,36 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.util.Log
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
|
||||
object DesktopAttachKeepAliveManager {
|
||||
private const val TAG = "DesktopAttachKeepAlive"
|
||||
private val activeConnections = ConcurrentHashMap.newKeySet<String>()
|
||||
|
||||
fun acquire(
|
||||
connectionId: String,
|
||||
) {
|
||||
if (!activeConnections.add(connectionId)) {
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Acquired desktop attach keepalive id=$connectionId count=${activeConnections.size}")
|
||||
}
|
||||
|
||||
fun release(
|
||||
context: Context,
|
||||
connectionId: String,
|
||||
) {
|
||||
if (!activeConnections.remove(connectionId)) {
|
||||
return
|
||||
}
|
||||
Log.i(TAG, "Released desktop attach keepalive id=$connectionId count=${activeConnections.size}")
|
||||
if (activeConnections.isEmpty()) {
|
||||
context.startService(
|
||||
Intent(context, DesktopAttachKeepAliveService::class.java)
|
||||
.setAction(DesktopAttachKeepAliveService.ACTION_STOP),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
|
||||
class DesktopAttachKeepAliveService : Service() {
|
||||
companion object {
|
||||
private const val TAG = "DesktopAttachKeepAlive"
|
||||
const val ACTION_START = "com.openai.codex.agent.action.START_DESKTOP_ATTACH_KEEPALIVE"
|
||||
const val ACTION_STOP = "com.openai.codex.agent.action.STOP_DESKTOP_ATTACH_KEEPALIVE"
|
||||
|
||||
private const val CHANNEL_ID = "codex_desktop_attach"
|
||||
private const val CHANNEL_NAME = "Codex Desktop Attach"
|
||||
private const val NOTIFICATION_ID = 0x43445841
|
||||
}
|
||||
|
||||
override fun onStartCommand(
|
||||
intent: Intent?,
|
||||
flags: Int,
|
||||
startId: Int,
|
||||
): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
Log.i(TAG, "Stopping desktop attach keepalive service")
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
val manager = getSystemService(NotificationManager::class.java)
|
||||
if (manager != null) {
|
||||
ensureChannel(manager)
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
Log.i(TAG, "Started desktop attach keepalive service")
|
||||
}
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val contentIntent = PendingIntent.getActivity(
|
||||
this,
|
||||
0,
|
||||
Intent(this, MainActivity::class.java).apply {
|
||||
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
},
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
return Notification.Builder(this, CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||
.setContentTitle("Codex desktop attach active")
|
||||
.setContentText("Keeping the Agent bridge alive for attached desktop sessions.")
|
||||
.setContentIntent(contentIntent)
|
||||
.setOngoing(true)
|
||||
.setForegroundServiceBehavior(Notification.FOREGROUND_SERVICE_IMMEDIATE)
|
||||
.build()
|
||||
}
|
||||
|
||||
private fun ensureChannel(manager: NotificationManager) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
|
||||
return
|
||||
}
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) {
|
||||
return
|
||||
}
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
CHANNEL_NAME,
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply {
|
||||
description = "Keeps the Codex Agent desktop bridge alive while a desktop session is attached."
|
||||
setShowBadge(false)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
class DesktopBridgeBootstrapReceiver : BroadcastReceiver() {
|
||||
companion object {
|
||||
const val ACTION_BOOTSTRAP_DESKTOP_BRIDGE =
|
||||
"com.openai.codex.agent.action.BOOTSTRAP_DESKTOP_BRIDGE"
|
||||
const val EXTRA_AUTH_TOKEN = "com.openai.codex.agent.extra.DESKTOP_BRIDGE_AUTH_TOKEN"
|
||||
}
|
||||
|
||||
override fun onReceive(
|
||||
context: Context,
|
||||
intent: Intent,
|
||||
) {
|
||||
if (intent.action != ACTION_BOOTSTRAP_DESKTOP_BRIDGE) {
|
||||
return
|
||||
}
|
||||
intent.getStringExtra(EXTRA_AUTH_TOKEN)
|
||||
?.trim()
|
||||
?.takeIf(String::isNotEmpty)
|
||||
?.let { token ->
|
||||
DesktopBridgeServer.ensureStarted(context.applicationContext, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,707 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.app.agent.AgentSessionInfo
|
||||
import android.content.Context
|
||||
import android.os.Binder
|
||||
import android.os.SystemClock
|
||||
import android.util.Log
|
||||
import java.net.InetAddress
|
||||
import java.net.InetSocketAddress
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.concurrent.thread
|
||||
import org.java_websocket.WebSocket
|
||||
import org.java_websocket.handshake.ClientHandshake
|
||||
import org.java_websocket.server.WebSocketServer
|
||||
import org.json.JSONArray
|
||||
import org.json.JSONObject
|
||||
|
||||
object DesktopBridgeServer {
|
||||
private const val TAG = "DesktopBridgeServer"
|
||||
private const val LISTEN_PORT = 48765
|
||||
private const val CONTROL_PATH = "/control"
|
||||
private const val SESSION_PATH_PREFIX = "/session/"
|
||||
private const val DEFAULT_MODEL = "gpt-5.3-codex-spark"
|
||||
private const val DEFAULT_REASONING_EFFORT = "low"
|
||||
private const val ATTACH_TOKEN_TTL_MS = 60_000L
|
||||
private const val ATTACH_THREAD_WAIT_MS = 5_000L
|
||||
private const val ATTACH_THREAD_POLL_MS = 100L
|
||||
private const val BRIDGE_STARTUP_WAIT_MS = 5_000L
|
||||
private const val BRIDGE_STARTUP_RETRY_DELAY_MS = 100L
|
||||
|
||||
private val authorizedTokens = ConcurrentHashMap.newKeySet<String>()
|
||||
private val attachTokens = ConcurrentHashMap<String, AttachedSessionTarget>()
|
||||
private val createdHomeSessionUiLeases = ConcurrentHashMap<String, Binder>()
|
||||
@Volatile
|
||||
private var server: AgentDesktopBridgeSocketServer? = null
|
||||
|
||||
private data class AttachedSessionTarget(
|
||||
val sessionId: String,
|
||||
val expiresAtElapsedRealtimeMs: Long,
|
||||
val keepAliveId: String,
|
||||
)
|
||||
|
||||
fun ensureStarted(
|
||||
context: Context,
|
||||
authToken: String,
|
||||
) {
|
||||
authorizedTokens += authToken
|
||||
val existing = synchronized(this) { server }
|
||||
if (existing != null && existing.isStarted()) {
|
||||
return
|
||||
}
|
||||
synchronized(this) {
|
||||
val running = server
|
||||
if (running != null && running.isStarted()) {
|
||||
return
|
||||
}
|
||||
if (running != null) {
|
||||
Log.w(TAG, "Desktop bridge reference exists but is not ready; restarting")
|
||||
runCatching { running.stop(100) }
|
||||
server = null
|
||||
}
|
||||
val startupDeadline = SystemClock.elapsedRealtime() + BRIDGE_STARTUP_WAIT_MS
|
||||
while (SystemClock.elapsedRealtime() < startupDeadline) {
|
||||
val candidate = AgentDesktopBridgeSocketServer(context.applicationContext)
|
||||
candidate.setReuseAddr(true)
|
||||
server = candidate
|
||||
candidate.start()
|
||||
if (candidate.awaitStartup(BRIDGE_STARTUP_WAIT_MS)) {
|
||||
Log.i(TAG, "Desktop bridge listening on ws://127.0.0.1:$LISTEN_PORT$CONTROL_PATH")
|
||||
return
|
||||
}
|
||||
val startupFailure = candidate.startupFailureMessage()
|
||||
runCatching { candidate.stop(100) }
|
||||
if (server === candidate) {
|
||||
server = null
|
||||
}
|
||||
if (
|
||||
startupFailure?.contains("Address already in use", ignoreCase = true) == true &&
|
||||
SystemClock.elapsedRealtime() + BRIDGE_STARTUP_RETRY_DELAY_MS < startupDeadline
|
||||
) {
|
||||
SystemClock.sleep(BRIDGE_STARTUP_RETRY_DELAY_MS)
|
||||
continue
|
||||
}
|
||||
if (startupFailure != null) {
|
||||
Log.w(TAG, "Desktop bridge failed to start after bootstrap: $startupFailure")
|
||||
} else {
|
||||
Log.w(TAG, "Desktop bridge failed to start within ${BRIDGE_STARTUP_WAIT_MS}ms; clearing state")
|
||||
}
|
||||
return
|
||||
}
|
||||
Log.w(TAG, "Desktop bridge startup retries exhausted within ${BRIDGE_STARTUP_WAIT_MS}ms")
|
||||
}
|
||||
}
|
||||
|
||||
private class AgentDesktopBridgeSocketServer(
|
||||
private val context: Context,
|
||||
) : WebSocketServer(InetSocketAddress(InetAddress.getByName("127.0.0.1"), LISTEN_PORT)) {
|
||||
private val sessionController = AgentSessionController(context)
|
||||
private val startupLatch = CountDownLatch(1)
|
||||
@Volatile
|
||||
private var started = false
|
||||
@Volatile
|
||||
private var startupFailure: Exception? = null
|
||||
|
||||
fun awaitStartup(timeoutMs: Long): Boolean {
|
||||
startupLatch.await(timeoutMs, TimeUnit.MILLISECONDS)
|
||||
return started
|
||||
}
|
||||
|
||||
fun isStarted(): Boolean = started
|
||||
|
||||
fun startupFailureMessage(): String? = startupFailure?.message
|
||||
|
||||
override fun onOpen(
|
||||
conn: WebSocket,
|
||||
handshake: ClientHandshake,
|
||||
) {
|
||||
val authHeader = handshake.getFieldValue("Authorization")
|
||||
val bearerToken = parseBearerToken(authHeader)
|
||||
if (bearerToken == null || !authorizedTokens.contains(bearerToken)) {
|
||||
conn.close(1008, "Unauthorized")
|
||||
return
|
||||
}
|
||||
val path = handshake.resourceDescriptor ?: CONTROL_PATH
|
||||
if (path == CONTROL_PATH) {
|
||||
return
|
||||
}
|
||||
if (path.startsWith(SESSION_PATH_PREFIX)) {
|
||||
val attachToken = path.removePrefix(SESSION_PATH_PREFIX)
|
||||
val target = attachTokens[attachToken]
|
||||
if (target == null) {
|
||||
conn.close(1008, "Unknown attach token")
|
||||
return
|
||||
}
|
||||
if (target.expiresAtElapsedRealtimeMs <= SystemClock.elapsedRealtime()) {
|
||||
attachTokens.remove(attachToken, target)
|
||||
DesktopAttachKeepAliveManager.release(context, target.keepAliveId)
|
||||
conn.close(1008, "Expired attach token")
|
||||
return
|
||||
}
|
||||
val connectionId = openSessionProxy(
|
||||
sessionId = target.sessionId,
|
||||
onMessage = { message ->
|
||||
runCatching { conn.send(message) }
|
||||
.onFailure { conn.close(1011, it.message ?: "Desktop send failed") }
|
||||
},
|
||||
onClosed = { reason ->
|
||||
conn.close(1000, reason ?: "Session proxy closed")
|
||||
},
|
||||
)
|
||||
if (connectionId == null) {
|
||||
conn.close(1011, "Session is not attachable")
|
||||
return
|
||||
}
|
||||
DesktopAttachKeepAliveManager.acquire(connectionId)
|
||||
conn.setAttachment(
|
||||
SessionProxyConnection(
|
||||
sessionId = target.sessionId,
|
||||
connectionId = connectionId,
|
||||
keepAliveId = connectionId,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
conn.close(1008, "Unsupported path")
|
||||
}
|
||||
|
||||
override fun onClose(
|
||||
conn: WebSocket,
|
||||
code: Int,
|
||||
reason: String,
|
||||
remote: Boolean,
|
||||
) {
|
||||
val attachment = conn.getAttachment<SessionProxyConnection>()
|
||||
if (attachment != null) {
|
||||
DesktopAttachKeepAliveManager.release(context, attachment.keepAliveId)
|
||||
closeSessionProxy(
|
||||
sessionId = attachment.sessionId,
|
||||
connectionId = attachment.connectionId,
|
||||
reason = reason.ifBlank { null },
|
||||
detachPlanner = shouldDetachPlannerOnWebSocketClose(code, remote),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onMessage(
|
||||
conn: WebSocket,
|
||||
message: String,
|
||||
) {
|
||||
val attachment = conn.getAttachment<SessionProxyConnection>()
|
||||
if (attachment != null) {
|
||||
if (!sendSessionProxyInput(
|
||||
sessionId = attachment.sessionId,
|
||||
connectionId = attachment.connectionId,
|
||||
message = message,
|
||||
)
|
||||
) {
|
||||
conn.close(1008, "Session proxy is no longer active")
|
||||
}
|
||||
return
|
||||
}
|
||||
thread(name = "DesktopBridgeControl") {
|
||||
handleControlMessage(conn, message)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onError(
|
||||
conn: WebSocket?,
|
||||
ex: Exception,
|
||||
) {
|
||||
Log.w(TAG, "Desktop bridge websocket failed", ex)
|
||||
if (conn == null && !started) {
|
||||
startupFailure = ex
|
||||
startupLatch.countDown()
|
||||
synchronized(this@DesktopBridgeServer) {
|
||||
if (server === this) {
|
||||
server = null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onStart() {
|
||||
started = true
|
||||
connectionLostTimeout = 30
|
||||
startupLatch.countDown()
|
||||
}
|
||||
|
||||
private fun handleControlMessage(
|
||||
conn: WebSocket,
|
||||
message: String,
|
||||
) {
|
||||
val request = runCatching { JSONObject(message) }
|
||||
.getOrElse { err ->
|
||||
sendError(conn, null, -32700, err.message ?: "Invalid JSON")
|
||||
return
|
||||
}
|
||||
val requestId = request.opt("id")
|
||||
val method = request.optString("method")
|
||||
val params = request.optJSONObject("params")
|
||||
pruneCreatedHomeSessionUiLeases()
|
||||
runCatching {
|
||||
when (method) {
|
||||
"androidSession/list" -> listSessions()
|
||||
"androidSession/read" -> readSession(params)
|
||||
"androidSession/create" -> createSession(params)
|
||||
"androidSession/start" -> startSession(params)
|
||||
"androidSession/answer" -> answerQuestion(params)
|
||||
"androidSession/cancel" -> cancelSession(params)
|
||||
"androidSession/clear" -> clearSessions(params)
|
||||
"androidSession/attachTarget" -> attachTarget(params)
|
||||
"androidSession/attach" -> attachSession(params)
|
||||
else -> {
|
||||
sendError(
|
||||
conn = conn,
|
||||
requestId = requestId,
|
||||
code = -32601,
|
||||
message = "Unsupported desktop bridge method: $method",
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
}.onSuccess { result ->
|
||||
sendResult(conn, requestId, result)
|
||||
}.onFailure { err ->
|
||||
val code = when (err) {
|
||||
is IllegalArgumentException -> -32602
|
||||
is IllegalStateException -> -32000
|
||||
else -> -32603
|
||||
}
|
||||
sendError(
|
||||
conn = conn,
|
||||
requestId = requestId,
|
||||
code = code,
|
||||
message = err.message ?: err::class.java.simpleName,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun listSessions(): JSONObject {
|
||||
val snapshot = sessionController.loadSnapshot(null)
|
||||
val data = JSONArray()
|
||||
snapshot.sessions.forEach { session ->
|
||||
data.put(sessionJson(session))
|
||||
}
|
||||
return JSONObject().put("data", data)
|
||||
}
|
||||
|
||||
private fun readSession(params: JSONObject?): JSONObject {
|
||||
val sessionId = params.requireString("sessionId")
|
||||
return sessionJson(requireSession(sessionId), includeTimeline = true)
|
||||
}
|
||||
|
||||
private fun createSession(params: JSONObject?): JSONObject {
|
||||
val targetPackage = params.optNullableString("targetPackage")
|
||||
val model = params.optNullableString("model") ?: DEFAULT_MODEL
|
||||
val reasoningEffort = params.optNullableString("reasoningEffort") ?: DEFAULT_REASONING_EFFORT
|
||||
val result = AgentSessionLauncher.createSessionDraft(
|
||||
request = CreateSessionRequest(
|
||||
targetPackage = targetPackage,
|
||||
model = model,
|
||||
reasoningEffort = reasoningEffort,
|
||||
),
|
||||
sessionController = sessionController,
|
||||
)
|
||||
if (result.anchor == AgentSessionInfo.ANCHOR_HOME) {
|
||||
registerCreatedHomeSessionUiLease(result.sessionId)
|
||||
}
|
||||
return sessionJson(requireSession(result.sessionId), includeTimeline = true)
|
||||
}
|
||||
|
||||
private fun startSession(params: JSONObject?): JSONObject {
|
||||
val sessionId = params.requireString("sessionId")
|
||||
val prompt = params.requireString("prompt")
|
||||
val result = AgentSessionLauncher.startSessionDraftAsync(
|
||||
context = context,
|
||||
request = StartSessionRequest(
|
||||
sessionId = sessionId,
|
||||
prompt = prompt,
|
||||
),
|
||||
sessionController = sessionController,
|
||||
requestUserInputHandler = null,
|
||||
)
|
||||
unregisterCreatedHomeSessionUiLease(sessionId)
|
||||
return sessionJson(requireSession(result.parentSessionId), includeTimeline = true)
|
||||
.put("geniePackage", result.geniePackage)
|
||||
.put("plannedTargets", JSONArray(result.plannedTargets))
|
||||
.put("childSessionIds", JSONArray(result.childSessionIds))
|
||||
}
|
||||
|
||||
private fun answerQuestion(params: JSONObject?): JSONObject {
|
||||
val sessionId = params.requireString("sessionId")
|
||||
val answer = params.requireString("answer")
|
||||
val snapshot = sessionController.loadSnapshot(sessionId)
|
||||
val session = snapshot.sessions.firstOrNull { it.sessionId == sessionId }
|
||||
?: throw IllegalArgumentException("Unknown session: $sessionId")
|
||||
sessionController.answerQuestion(sessionId, answer, session.parentSessionId)
|
||||
return JSONObject().put("ok", true)
|
||||
}
|
||||
|
||||
private fun cancelSession(params: JSONObject?): JSONObject {
|
||||
val sessionId = params.requireString("sessionId")
|
||||
sessionController.cancelSessionTree(sessionId)
|
||||
unregisterCreatedHomeSessionUiLease(sessionId)
|
||||
return JSONObject().put("ok", true)
|
||||
}
|
||||
|
||||
private fun clearSessions(params: JSONObject?): JSONObject {
|
||||
require(params?.optBoolean("all") == true) { "sessions clear requires --all" }
|
||||
|
||||
val clearedSessionIds = linkedSetOf<String>()
|
||||
val failedSessionIds = linkedMapOf<String, String>()
|
||||
repeat(32) {
|
||||
val sessions = sessionController.loadSnapshot(null).sessions
|
||||
if (sessions.isEmpty()) {
|
||||
return JSONObject()
|
||||
.put("ok", failedSessionIds.isEmpty())
|
||||
.put("clearedSessionIds", JSONArray(clearedSessionIds.toList()))
|
||||
.put("failedSessionIds", JSONObject(failedSessionIds))
|
||||
.put("remainingSessionIds", JSONArray())
|
||||
}
|
||||
|
||||
val sessionIdsBefore = sessions.map(AgentSessionDetails::sessionId).toSet()
|
||||
val sessionsById = sessions.associateBy(AgentSessionDetails::sessionId)
|
||||
val candidates = sessions.filter { session ->
|
||||
session.parentSessionId == null ||
|
||||
!sessionsById.containsKey(session.parentSessionId)
|
||||
}.ifEmpty { sessions }
|
||||
|
||||
candidates.forEach { session ->
|
||||
runCatching {
|
||||
sessionController.cancelSessionTree(session.sessionId)
|
||||
unregisterCreatedHomeSessionUiLease(session.sessionId)
|
||||
}.onFailure { err ->
|
||||
failedSessionIds[session.sessionId] = err.message ?: err::class.java.simpleName
|
||||
}
|
||||
}
|
||||
|
||||
val remainingSessions = sessionController.loadSnapshot(null).sessions
|
||||
val remainingSessionIds = remainingSessions.map(AgentSessionDetails::sessionId).toSet()
|
||||
clearedSessionIds += sessionIdsBefore - remainingSessionIds
|
||||
if (remainingSessionIds.size == sessionIdsBefore.size) {
|
||||
return JSONObject()
|
||||
.put("ok", false)
|
||||
.put("clearedSessionIds", JSONArray(clearedSessionIds.toList()))
|
||||
.put("failedSessionIds", JSONObject(failedSessionIds))
|
||||
.put("remainingSessionIds", JSONArray(remainingSessionIds.toList()))
|
||||
}
|
||||
}
|
||||
|
||||
val remainingSessionIds = sessionController.loadSnapshot(null).sessions
|
||||
.map(AgentSessionDetails::sessionId)
|
||||
return JSONObject()
|
||||
.put("ok", false)
|
||||
.put("clearedSessionIds", JSONArray(clearedSessionIds.toList()))
|
||||
.put("failedSessionIds", JSONObject(failedSessionIds))
|
||||
.put("remainingSessionIds", JSONArray(remainingSessionIds))
|
||||
}
|
||||
|
||||
private fun attachTarget(params: JSONObject?): JSONObject {
|
||||
val sessionId = params.requireString("sessionId")
|
||||
sessionController.attachTarget(sessionId)
|
||||
return JSONObject().put("ok", true)
|
||||
}
|
||||
|
||||
private fun attachSession(params: JSONObject?): JSONObject {
|
||||
val sessionId = params.requireString("sessionId")
|
||||
val session = requireSession(sessionId)
|
||||
ensureSessionAttachable(session)
|
||||
val threadId = activeThreadId(session)
|
||||
?: throw IllegalStateException("Session $sessionId is not attachable")
|
||||
pruneExpiredAttachTokens()
|
||||
val attachToken = UUID.randomUUID().toString().replace("-", "")
|
||||
val target = AttachedSessionTarget(
|
||||
sessionId = sessionId,
|
||||
expiresAtElapsedRealtimeMs = SystemClock.elapsedRealtime() + ATTACH_TOKEN_TTL_MS,
|
||||
keepAliveId = attachToken,
|
||||
)
|
||||
DesktopAttachKeepAliveManager.acquire(attachToken)
|
||||
attachTokens[attachToken] = target
|
||||
thread(name = "DesktopAttachTokenExpiry") {
|
||||
SystemClock.sleep(ATTACH_TOKEN_TTL_MS)
|
||||
if (attachTokens.remove(attachToken, target)) {
|
||||
DesktopAttachKeepAliveManager.release(context, target.keepAliveId)
|
||||
}
|
||||
}
|
||||
return JSONObject()
|
||||
.put("sessionId", sessionId)
|
||||
.put("threadId", threadId)
|
||||
.put("websocketPath", "$SESSION_PATH_PREFIX$attachToken")
|
||||
}
|
||||
|
||||
private fun pruneExpiredAttachTokens() {
|
||||
val now = SystemClock.elapsedRealtime()
|
||||
attachTokens.entries.removeIf { (_, target) ->
|
||||
if (target.expiresAtElapsedRealtimeMs > now) {
|
||||
return@removeIf false
|
||||
}
|
||||
DesktopAttachKeepAliveManager.release(context, target.keepAliveId)
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
private fun sessionJson(
|
||||
session: AgentSessionDetails,
|
||||
includeTimeline: Boolean = false,
|
||||
): JSONObject {
|
||||
val threadId = activeThreadId(session)
|
||||
val executionSettings = sessionController.executionSettingsForSession(session.sessionId)
|
||||
return JSONObject()
|
||||
.put("sessionId", session.sessionId)
|
||||
.put("parentSessionId", session.parentSessionId)
|
||||
.put("kind", sessionKind(session))
|
||||
.put("anchor", session.anchor)
|
||||
.put("state", session.state)
|
||||
.put("stateLabel", session.stateLabel)
|
||||
.put("targetPackage", session.targetPackage)
|
||||
.put("targetPresentation", session.targetPresentationLabel)
|
||||
.put("targetRuntime", session.targetRuntimeLabel)
|
||||
.put("latestQuestion", session.latestQuestion)
|
||||
.put("latestResult", session.latestResult)
|
||||
.put("latestError", session.latestError)
|
||||
.put("latestTrace", session.latestTrace)
|
||||
.put("model", executionSettings.model)
|
||||
.put("reasoningEffort", executionSettings.reasoningEffort)
|
||||
.put("threadId", threadId)
|
||||
.put("attachable", !threadId.isNullOrBlank())
|
||||
.apply {
|
||||
if (includeTimeline) {
|
||||
put("timeline", session.timeline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun activeThreadId(session: AgentSessionDetails): String? {
|
||||
return AgentSessionBridgeServer.activeThreadId(session.sessionId)
|
||||
?: AgentPlannerRuntimeManager.activeThreadId(session.sessionId)
|
||||
}
|
||||
|
||||
private fun requireSession(sessionId: String): AgentSessionDetails {
|
||||
val snapshot = sessionController.loadSnapshot(sessionId)
|
||||
return snapshot.sessions.firstOrNull { it.sessionId == sessionId }
|
||||
?: throw IllegalArgumentException("Unknown session: $sessionId")
|
||||
}
|
||||
|
||||
private fun ensureSessionAttachable(session: AgentSessionDetails) {
|
||||
if (!activeThreadId(session).isNullOrBlank()) {
|
||||
return
|
||||
}
|
||||
if (
|
||||
!session.targetPackage.isNullOrBlank() &&
|
||||
session.parentSessionId != null &&
|
||||
session.state != AgentSessionInfo.STATE_COMPLETED &&
|
||||
session.state != AgentSessionInfo.STATE_CANCELLED &&
|
||||
session.state != AgentSessionInfo.STATE_FAILED
|
||||
) {
|
||||
waitForAttachableThread(session)
|
||||
return
|
||||
}
|
||||
if (session.state != AgentSessionInfo.STATE_CREATED) {
|
||||
return
|
||||
}
|
||||
when {
|
||||
session.anchor == AgentSessionInfo.ANCHOR_HOME &&
|
||||
session.parentSessionId == null &&
|
||||
!session.targetPackage.isNullOrBlank() -> {
|
||||
sessionController.startExistingHomeSessionIdle(
|
||||
sessionId = session.sessionId,
|
||||
targetPackage = checkNotNull(session.targetPackage),
|
||||
allowDetachedMode = true,
|
||||
finalPresentationPolicy = session.requiredFinalPresentationPolicy
|
||||
?: SessionFinalPresentationPolicy.AGENT_CHOICE,
|
||||
executionSettings = sessionController.executionSettingsForSession(session.sessionId),
|
||||
)
|
||||
unregisterCreatedHomeSessionUiLease(session.sessionId)
|
||||
}
|
||||
session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
|
||||
session.parentSessionId == null &&
|
||||
session.targetPackage == null -> {
|
||||
AgentPlannerRuntimeManager.ensureIdleDesktopSession(
|
||||
context = context,
|
||||
sessionController = sessionController,
|
||||
sessionId = session.sessionId,
|
||||
)
|
||||
}
|
||||
else -> return
|
||||
}
|
||||
waitForAttachableThread(session)
|
||||
}
|
||||
|
||||
private fun waitForAttachableThread(session: AgentSessionDetails) {
|
||||
val deadline = SystemClock.elapsedRealtime() + ATTACH_THREAD_WAIT_MS
|
||||
while (SystemClock.elapsedRealtime() < deadline) {
|
||||
if (!activeThreadId(session).isNullOrBlank()) {
|
||||
return
|
||||
}
|
||||
Thread.sleep(ATTACH_THREAD_POLL_MS)
|
||||
}
|
||||
throw IllegalStateException("Session ${session.sessionId} did not expose an attachable thread in time")
|
||||
}
|
||||
|
||||
private fun registerCreatedHomeSessionUiLease(sessionId: String) {
|
||||
createdHomeSessionUiLeases.computeIfAbsent(sessionId) {
|
||||
Binder().also { token ->
|
||||
sessionController.registerSessionUiLease(sessionId, token)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun unregisterCreatedHomeSessionUiLease(sessionId: String) {
|
||||
val token = createdHomeSessionUiLeases.remove(sessionId) ?: return
|
||||
runCatching {
|
||||
sessionController.unregisterSessionUiLease(sessionId, token)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pruneCreatedHomeSessionUiLeases() {
|
||||
if (createdHomeSessionUiLeases.isEmpty()) {
|
||||
return
|
||||
}
|
||||
val sessionsById = sessionController.loadSnapshot(null).sessions.associateBy(AgentSessionDetails::sessionId)
|
||||
createdHomeSessionUiLeases.keys.forEach { sessionId ->
|
||||
val session = sessionsById[sessionId]
|
||||
if (
|
||||
session == null ||
|
||||
session.anchor != AgentSessionInfo.ANCHOR_HOME ||
|
||||
session.parentSessionId != null ||
|
||||
session.state != AgentSessionInfo.STATE_CREATED
|
||||
) {
|
||||
unregisterCreatedHomeSessionUiLease(sessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun openSessionProxy(
|
||||
sessionId: String,
|
||||
onMessage: (String) -> Unit,
|
||||
onClosed: (String?) -> Unit,
|
||||
): String? {
|
||||
return AgentSessionBridgeServer.openDesktopProxy(
|
||||
sessionId = sessionId,
|
||||
onMessage = onMessage,
|
||||
onClosed = onClosed,
|
||||
) ?: AgentPlannerRuntimeManager.openDesktopProxy(
|
||||
sessionId = sessionId,
|
||||
onMessage = onMessage,
|
||||
onClosed = onClosed,
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendSessionProxyInput(
|
||||
sessionId: String,
|
||||
connectionId: String,
|
||||
message: String,
|
||||
): Boolean {
|
||||
return AgentSessionBridgeServer.sendDesktopProxyInput(
|
||||
sessionId = sessionId,
|
||||
connectionId = connectionId,
|
||||
message = message,
|
||||
) || AgentPlannerRuntimeManager.sendDesktopProxyInput(
|
||||
sessionId = sessionId,
|
||||
connectionId = connectionId,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
private fun closeSessionProxy(
|
||||
sessionId: String,
|
||||
connectionId: String,
|
||||
reason: String? = null,
|
||||
detachPlanner: Boolean = false,
|
||||
) {
|
||||
AgentSessionBridgeServer.closeDesktopProxy(sessionId, connectionId, reason)
|
||||
AgentPlannerRuntimeManager.closeDesktopProxy(
|
||||
sessionId = sessionId,
|
||||
connectionId = connectionId,
|
||||
reason = reason,
|
||||
detachPlanner = detachPlanner,
|
||||
)
|
||||
}
|
||||
|
||||
private fun shouldDetachPlannerOnWebSocketClose(
|
||||
code: Int,
|
||||
remote: Boolean,
|
||||
): Boolean {
|
||||
return when (code) {
|
||||
1000, 1001 -> true
|
||||
else -> !remote && code == 1005
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendResult(
|
||||
conn: WebSocket,
|
||||
requestId: Any?,
|
||||
result: JSONObject,
|
||||
) {
|
||||
conn.send(
|
||||
JSONObject()
|
||||
.put("id", requestId)
|
||||
.put("result", result)
|
||||
.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sendError(
|
||||
conn: WebSocket,
|
||||
requestId: Any?,
|
||||
code: Int,
|
||||
message: String,
|
||||
) {
|
||||
conn.send(
|
||||
JSONObject()
|
||||
.put("id", requestId)
|
||||
.put(
|
||||
"error",
|
||||
JSONObject()
|
||||
.put("code", code)
|
||||
.put("message", message),
|
||||
)
|
||||
.toString(),
|
||||
)
|
||||
}
|
||||
|
||||
private fun sessionKind(session: AgentSessionDetails): String {
|
||||
return when {
|
||||
session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
|
||||
session.parentSessionId == null &&
|
||||
session.targetPackage == null -> "agent_parent"
|
||||
session.parentSessionId != null -> "genie_child"
|
||||
else -> "home_session"
|
||||
}
|
||||
}
|
||||
|
||||
private fun JSONObject?.requireString(key: String): String {
|
||||
val value = this?.optString(key)?.trim().orEmpty()
|
||||
require(value.isNotEmpty()) { "Missing required parameter: $key" }
|
||||
return value
|
||||
}
|
||||
|
||||
private fun JSONObject?.optNullableString(key: String): String? {
|
||||
if (this == null || !has(key) || isNull(key)) {
|
||||
return null
|
||||
}
|
||||
return optString(key).trim().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
|
||||
private data class SessionProxyConnection(
|
||||
val sessionId: String,
|
||||
val connectionId: String,
|
||||
val keepAliveId: String,
|
||||
)
|
||||
|
||||
private fun parseBearerToken(header: String?): String? {
|
||||
if (header.isNullOrBlank()) {
|
||||
return null
|
||||
}
|
||||
val prefix = "Bearer "
|
||||
if (!header.startsWith(prefix, ignoreCase = true)) {
|
||||
return null
|
||||
}
|
||||
return header.substring(prefix.length).trim().ifEmpty { null }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
object DesktopInspectionRegistry {
|
||||
private val lock = Any()
|
||||
private val attachedPlannerSessions = linkedSetOf<String>()
|
||||
private val heldChildrenByParent = mutableMapOf<String, MutableSet<String>>()
|
||||
private val parentByHeldChild = mutableMapOf<String, String>()
|
||||
|
||||
fun markPlannerAttached(parentSessionId: String) {
|
||||
synchronized(lock) {
|
||||
attachedPlannerSessions += parentSessionId
|
||||
}
|
||||
}
|
||||
|
||||
fun markPlannerDetached(parentSessionId: String): Set<String> {
|
||||
return synchronized(lock) {
|
||||
attachedPlannerSessions.remove(parentSessionId)
|
||||
val releasedChildren = heldChildrenByParent.remove(parentSessionId).orEmpty().toSet()
|
||||
releasedChildren.forEach(parentByHeldChild::remove)
|
||||
releasedChildren
|
||||
}
|
||||
}
|
||||
|
||||
fun holdChildrenForAttachedPlanner(
|
||||
parentSessionId: String,
|
||||
childSessionIds: Collection<String>,
|
||||
): Boolean {
|
||||
return synchronized(lock) {
|
||||
if (parentSessionId !in attachedPlannerSessions) {
|
||||
return false
|
||||
}
|
||||
val heldChildren = heldChildrenByParent.getOrPut(parentSessionId, ::linkedSetOf)
|
||||
childSessionIds.forEach { childSessionId ->
|
||||
if (childSessionId.isNotBlank()) {
|
||||
heldChildren += childSessionId
|
||||
parentByHeldChild[childSessionId] = parentSessionId
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
fun isPlannerAttached(parentSessionId: String): Boolean {
|
||||
return synchronized(lock) {
|
||||
parentSessionId in attachedPlannerSessions
|
||||
}
|
||||
}
|
||||
|
||||
fun isSessionHeldForInspection(sessionId: String): Boolean {
|
||||
return synchronized(lock) {
|
||||
sessionId in parentByHeldChild
|
||||
}
|
||||
}
|
||||
|
||||
fun removeSession(sessionId: String) {
|
||||
synchronized(lock) {
|
||||
if (attachedPlannerSessions.remove(sessionId)) {
|
||||
heldChildrenByParent.remove(sessionId).orEmpty().forEach(parentByHeldChild::remove)
|
||||
}
|
||||
parentByHeldChild.remove(sessionId)?.let { parentSessionId ->
|
||||
heldChildrenByParent[parentSessionId]?.remove(sessionId)
|
||||
if (heldChildrenByParent[parentSessionId].isNullOrEmpty()) {
|
||||
heldChildrenByParent.remove(parentSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,761 @@
|
||||
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 = "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 the parent and all 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 {
|
||||
sessionController.cancelSessionTree(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 ${topLevelSession.sessionId} and its 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,15 @@
|
||||
package com.openai.codex.agent
|
||||
|
||||
import android.content.Context
|
||||
|
||||
object SessionNotificationCoordinator {
|
||||
@Suppress("UNUSED_PARAMETER")
|
||||
fun acknowledgeSessionTree(
|
||||
context: Context,
|
||||
sessionController: AgentSessionController,
|
||||
topLevelSessionId: String,
|
||||
sessionIds: Collection<String>,
|
||||
) {
|
||||
sessionController.acknowledgeSessionUi(topLevelSessionId)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,636 @@
|
||||
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.view.WindowManager
|
||||
import android.widget.Button
|
||||
import android.widget.EditText
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
class SessionPopupActivity : Activity() {
|
||||
companion object {
|
||||
const val EXTRA_SESSION_ID = "sessionId"
|
||||
private const val HOME_FOLLOW_UP_SETTLE_TIMEOUT_MS = 2_000L
|
||||
private const val HOME_FOLLOW_UP_SETTLE_POLL_MS = 50L
|
||||
|
||||
fun intent(
|
||||
context: Context,
|
||||
sessionId: String,
|
||||
): Intent {
|
||||
return Intent(context, SessionPopupActivity::class.java)
|
||||
.putExtra(EXTRA_SESSION_ID, sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private val sessionController by lazy { AgentSessionController(this) }
|
||||
private var requestedSessionId: String? = null
|
||||
private var fallbackLaunched = false
|
||||
private var popupRendered = false
|
||||
private var refreshInFlight = false
|
||||
private var sessionListenerRegistered = false
|
||||
@Volatile
|
||||
private var answerSubmissionInFlight = false
|
||||
@Volatile
|
||||
private var followUpSubmissionInFlight = false
|
||||
|
||||
private val sessionListener = object : AgentManager.SessionListener {
|
||||
override fun onSessionChanged(session: AgentSessionInfo) {
|
||||
if (answerSubmissionInFlight && session.sessionId == requestedSessionId) {
|
||||
return
|
||||
}
|
||||
if (followUpSubmissionInFlight && session.sessionId == requestedSessionId) {
|
||||
return
|
||||
}
|
||||
if (session.sessionId == requestedSessionId || session.parentSessionId == requestedSessionId) {
|
||||
refreshPopup(force = true)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onSessionRemoved(sessionId: String, userId: Int) {
|
||||
if (answerSubmissionInFlight && sessionId == requestedSessionId) {
|
||||
return
|
||||
}
|
||||
if (followUpSubmissionInFlight && sessionId == requestedSessionId) {
|
||||
return
|
||||
}
|
||||
if (sessionId == requestedSessionId) {
|
||||
finish()
|
||||
} else {
|
||||
refreshPopup(force = true)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestedSessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.trim()?.ifEmpty { null }
|
||||
if (requestedSessionId == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
setFinishOnTouchOutside(false)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
registerSessionListenerIfNeeded()
|
||||
refreshPopup(force = true)
|
||||
}
|
||||
|
||||
override fun onPause() {
|
||||
unregisterSessionListenerIfNeeded()
|
||||
super.onPause()
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
requestedSessionId = intent.getStringExtra(EXTRA_SESSION_ID)?.trim()?.ifEmpty { null }
|
||||
fallbackLaunched = false
|
||||
popupRendered = false
|
||||
refreshPopup(force = true)
|
||||
}
|
||||
|
||||
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 refreshPopup(force: Boolean = false) {
|
||||
if (!force && refreshInFlight) {
|
||||
return
|
||||
}
|
||||
val sessionId = requestedSessionId ?: return
|
||||
refreshInFlight = true
|
||||
thread(name = "CodexSessionPopupLoad-$sessionId") {
|
||||
try {
|
||||
val session = runCatching {
|
||||
resolvePopupSession(sessionController.loadSnapshot(sessionId), sessionId)
|
||||
}.getOrNull()
|
||||
runOnUiThread {
|
||||
renderSession(session)
|
||||
}
|
||||
} finally {
|
||||
refreshInFlight = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resolvePopupSession(
|
||||
snapshot: AgentSnapshot,
|
||||
sessionId: String,
|
||||
): AgentSessionDetails? {
|
||||
return snapshot.sessions.firstOrNull { session -> session.sessionId == sessionId }
|
||||
?: snapshot.selectedSession?.takeIf { session -> session.sessionId == sessionId }
|
||||
?: snapshot.parentSession?.takeIf { session -> session.sessionId == sessionId }
|
||||
}
|
||||
|
||||
private fun renderSession(session: AgentSessionDetails?) {
|
||||
if (session == null) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
when {
|
||||
isQuestionSession(session) -> showQuestionPopup(session)
|
||||
isResultSession(session) -> showResultPopup(session)
|
||||
isRunningHomeSession(session) -> openRunningHomeTarget(session)
|
||||
popupRendered || fallbackLaunched -> finish()
|
||||
else -> launchFallbackDetail(session.sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun isQuestionSession(session: AgentSessionDetails): Boolean {
|
||||
return session.state == AgentSessionInfo.STATE_WAITING_FOR_USER &&
|
||||
!session.latestQuestion.isNullOrBlank()
|
||||
}
|
||||
|
||||
private fun isResultSession(session: AgentSessionDetails): Boolean {
|
||||
return when (session.state) {
|
||||
AgentSessionInfo.STATE_COMPLETED,
|
||||
AgentSessionInfo.STATE_CANCELLED,
|
||||
AgentSessionInfo.STATE_FAILED,
|
||||
-> true
|
||||
else -> false
|
||||
}
|
||||
}
|
||||
|
||||
private fun isRunningHomeSession(session: AgentSessionDetails): Boolean {
|
||||
return session.anchor == AgentSessionInfo.ANCHOR_HOME &&
|
||||
session.parentSessionId == null &&
|
||||
session.state == AgentSessionInfo.STATE_RUNNING
|
||||
}
|
||||
|
||||
private fun showQuestionPopup(session: AgentSessionDetails) {
|
||||
popupRendered = true
|
||||
setContentView(R.layout.activity_session_popup)
|
||||
bindPopupHeader(
|
||||
session = session,
|
||||
title = "Codex needs input for ${targetDisplayName(session)}",
|
||||
body = session.latestQuestion.orEmpty(),
|
||||
)
|
||||
val answerInput = findViewById<EditText>(R.id.session_popup_prompt_input)
|
||||
answerInput.hint = "Answer"
|
||||
val cancelButton = findViewById<Button>(R.id.session_popup_secondary_button)
|
||||
val answerButton = findViewById<Button>(R.id.session_popup_primary_button)
|
||||
cancelButton.text = "Cancel"
|
||||
answerButton.text = "Answer"
|
||||
cancelButton.setOnClickListener {
|
||||
finish()
|
||||
}
|
||||
answerButton.setOnClickListener {
|
||||
submitAnswer(
|
||||
session = session,
|
||||
answerInput = answerInput,
|
||||
submitButton = answerButton,
|
||||
cancelButton = cancelButton,
|
||||
)
|
||||
}
|
||||
answerInput.requestFocus()
|
||||
window?.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_VISIBLE)
|
||||
}
|
||||
|
||||
private fun showResultPopup(session: AgentSessionDetails) {
|
||||
popupRendered = true
|
||||
setContentView(R.layout.activity_session_popup)
|
||||
bindPopupHeader(
|
||||
session = session,
|
||||
title = resultTitle(session),
|
||||
body = resultBody(session),
|
||||
)
|
||||
val followUpInput = findViewById<EditText>(R.id.session_popup_prompt_input)
|
||||
followUpInput.hint = "Follow-up prompt"
|
||||
val okButton = findViewById<Button>(R.id.session_popup_secondary_button)
|
||||
val sendButton = findViewById<Button>(R.id.session_popup_primary_button)
|
||||
okButton.text = "OK"
|
||||
sendButton.text = "Send"
|
||||
okButton.setOnClickListener {
|
||||
dismissResultPopup(session, okButton, sendButton)
|
||||
}
|
||||
sendButton.setOnClickListener {
|
||||
submitFollowUpPrompt(
|
||||
session = session,
|
||||
promptInput = followUpInput,
|
||||
sendButton = sendButton,
|
||||
okButton = okButton,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resultTitle(session: AgentSessionDetails): String {
|
||||
val targetDisplayName = targetDisplayName(session)
|
||||
return when (session.state) {
|
||||
AgentSessionInfo.STATE_COMPLETED -> "Codex finished $targetDisplayName"
|
||||
AgentSessionInfo.STATE_CANCELLED -> "Codex cancelled $targetDisplayName"
|
||||
AgentSessionInfo.STATE_FAILED -> "Codex hit an issue in $targetDisplayName"
|
||||
else -> "Codex session for $targetDisplayName"
|
||||
}
|
||||
}
|
||||
|
||||
private fun resultBody(session: AgentSessionDetails): String {
|
||||
return when {
|
||||
!session.latestResult.isNullOrBlank() -> session.latestResult
|
||||
!session.latestError.isNullOrBlank() -> session.latestError
|
||||
session.state == AgentSessionInfo.STATE_CANCELLED -> "This session was cancelled."
|
||||
else -> "No final message was recorded for this session."
|
||||
}
|
||||
}
|
||||
|
||||
private fun submitAnswer(
|
||||
session: AgentSessionDetails,
|
||||
answerInput: EditText,
|
||||
submitButton: Button,
|
||||
cancelButton: Button,
|
||||
) {
|
||||
val answer = answerInput.text.toString().trim()
|
||||
if (answer.isEmpty()) {
|
||||
answerInput.error = "Enter an answer"
|
||||
return
|
||||
}
|
||||
answerSubmissionInFlight = true
|
||||
submitButton.isEnabled = false
|
||||
cancelButton.isEnabled = false
|
||||
thread(name = "CodexSessionPopupAnswer-${session.sessionId}") {
|
||||
runCatching {
|
||||
sessionController.answerQuestion(
|
||||
session.sessionId,
|
||||
answer,
|
||||
session.parentSessionId,
|
||||
)
|
||||
SessionNotificationCoordinator.acknowledgeSessionTree(
|
||||
context = this,
|
||||
sessionController = sessionController,
|
||||
topLevelSessionId = session.parentSessionId ?: session.sessionId,
|
||||
sessionIds = listOf(session.sessionId),
|
||||
)
|
||||
}.onFailure { err ->
|
||||
runOnUiThread {
|
||||
answerSubmissionInFlight = false
|
||||
submitButton.isEnabled = true
|
||||
cancelButton.isEnabled = true
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Failed to answer question: ${err.message}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}.onSuccess {
|
||||
runOnUiThread {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dismissResultPopup(
|
||||
session: AgentSessionDetails,
|
||||
okButton: Button,
|
||||
sendButton: Button,
|
||||
) {
|
||||
AgentQuestionNotifier.cancel(this, session.sessionId)
|
||||
if (isTopLevelHomeSession(session)) {
|
||||
consumeHomeResultPresentation(
|
||||
session = session,
|
||||
okButton = okButton,
|
||||
sendButton = sendButton,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (isTopLevelAgentSession(session)) {
|
||||
cancelAgentSessionTree(
|
||||
sessionId = session.sessionId,
|
||||
okButton = okButton,
|
||||
sendButton = sendButton,
|
||||
)
|
||||
return
|
||||
}
|
||||
if (session.parentSessionId != null) {
|
||||
cancelAgentSession(
|
||||
sessionId = session.sessionId,
|
||||
okButton = okButton,
|
||||
sendButton = sendButton,
|
||||
)
|
||||
return
|
||||
}
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun submitFollowUpPrompt(
|
||||
session: AgentSessionDetails,
|
||||
promptInput: EditText,
|
||||
sendButton: Button,
|
||||
okButton: Button,
|
||||
) {
|
||||
val prompt = promptInput.text.toString().trim()
|
||||
if (prompt.isEmpty()) {
|
||||
promptInput.error = "Enter a follow-up prompt"
|
||||
return
|
||||
}
|
||||
followUpSubmissionInFlight = true
|
||||
sendButton.isEnabled = false
|
||||
okButton.isEnabled = false
|
||||
thread(name = "CodexSessionPopupFollowUp-${session.sessionId}") {
|
||||
runCatching {
|
||||
startFollowUpPrompt(session, prompt)
|
||||
AgentQuestionNotifier.cancel(this, session.sessionId)
|
||||
}.onFailure { err ->
|
||||
runOnUiThread {
|
||||
followUpSubmissionInFlight = false
|
||||
sendButton.isEnabled = true
|
||||
okButton.isEnabled = true
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Failed to send follow-up: ${err.message}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}.onSuccess {
|
||||
runOnUiThread {
|
||||
followUpSubmissionInFlight = false
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun startFollowUpPrompt(
|
||||
session: AgentSessionDetails,
|
||||
prompt: String,
|
||||
) {
|
||||
val snapshot = sessionController.loadSnapshot(session.sessionId)
|
||||
val selectedSession = resolvePopupSession(snapshot, session.sessionId) ?: session
|
||||
val topLevelSession = selectedSession.parentSessionId
|
||||
?.let { parentSessionId ->
|
||||
snapshot.sessions.firstOrNull { candidate ->
|
||||
candidate.sessionId == parentSessionId
|
||||
}
|
||||
}
|
||||
?: selectedSession
|
||||
if (topLevelSession.anchor == AgentSessionInfo.ANCHOR_HOME) {
|
||||
startHomeFollowUp(
|
||||
topLevelSession = topLevelSession,
|
||||
prompt = SessionContinuationPromptBuilder.build(
|
||||
sourceTopLevelSession = topLevelSession,
|
||||
selectedSession = selectedSession,
|
||||
prompt = prompt,
|
||||
),
|
||||
)
|
||||
return
|
||||
}
|
||||
val childSession = if (selectedSession.parentSessionId == topLevelSession.sessionId) {
|
||||
selectedSession
|
||||
} else {
|
||||
snapshot.sessions.lastOrNull { candidate ->
|
||||
candidate.parentSessionId == topLevelSession.sessionId
|
||||
} ?: selectedSession
|
||||
}
|
||||
AgentSessionLauncher.continueSessionInPlace(
|
||||
sourceTopLevelSession = topLevelSession,
|
||||
selectedSession = childSession,
|
||||
prompt = SessionContinuationPromptBuilder.build(
|
||||
sourceTopLevelSession = topLevelSession,
|
||||
selectedSession = childSession,
|
||||
prompt = prompt,
|
||||
),
|
||||
sessionController = sessionController,
|
||||
)
|
||||
}
|
||||
|
||||
private fun startHomeFollowUp(
|
||||
topLevelSession: AgentSessionDetails,
|
||||
prompt: String,
|
||||
) {
|
||||
val targetPackage = checkNotNull(topLevelSession.targetPackage) {
|
||||
"No target package available for follow-up"
|
||||
}
|
||||
val executionSettings = sessionController.executionSettingsForSession(topLevelSession.sessionId)
|
||||
consumePreviousHomeSessionPresentation(topLevelSession)
|
||||
val newSessionId = AgentSessionLauncher.startSession(
|
||||
context = this,
|
||||
request = LaunchSessionRequest(
|
||||
prompt = prompt,
|
||||
targetPackage = targetPackage,
|
||||
model = executionSettings.model,
|
||||
reasoningEffort = executionSettings.reasoningEffort,
|
||||
),
|
||||
sessionController = sessionController,
|
||||
).parentSessionId
|
||||
val deadline = System.currentTimeMillis() + HOME_FOLLOW_UP_SETTLE_TIMEOUT_MS
|
||||
while (System.currentTimeMillis() < deadline) {
|
||||
val followUpSession = runCatching {
|
||||
resolvePopupSession(sessionController.loadSnapshot(newSessionId), newSessionId)
|
||||
}.getOrNull()
|
||||
if (followUpSession != null) {
|
||||
if (
|
||||
followUpSession.targetDetached ||
|
||||
followUpSession.targetPresentation != AgentSessionInfo.TARGET_PRESENTATION_ATTACHED
|
||||
) {
|
||||
return
|
||||
}
|
||||
}
|
||||
Thread.sleep(HOME_FOLLOW_UP_SETTLE_POLL_MS)
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumePreviousHomeSessionPresentation(
|
||||
topLevelSession: AgentSessionDetails,
|
||||
) {
|
||||
runCatching {
|
||||
sessionController.consumeHomeSessionPresentation(topLevelSession.sessionId)
|
||||
}.onFailure { err ->
|
||||
if (!isUnknownSessionError(err)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
if (!topLevelSession.targetDetached) {
|
||||
return
|
||||
}
|
||||
runCatching {
|
||||
sessionController.closeDetachedTarget(topLevelSession.sessionId)
|
||||
}.onFailure { err ->
|
||||
if (!isUnknownSessionError(err)) {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumeHomeResultPresentation(
|
||||
session: AgentSessionDetails,
|
||||
okButton: Button,
|
||||
sendButton: Button,
|
||||
) {
|
||||
okButton.isEnabled = false
|
||||
sendButton.isEnabled = false
|
||||
thread(name = "CodexSessionPopupConsume-${session.sessionId}") {
|
||||
runCatching {
|
||||
sessionController.consumeHomeSessionPresentation(session.sessionId)
|
||||
if (session.targetDetached) {
|
||||
sessionController.closeDetachedTarget(session.sessionId)
|
||||
}
|
||||
}.onFailure { err ->
|
||||
runOnUiThread {
|
||||
okButton.isEnabled = true
|
||||
sendButton.isEnabled = true
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Failed to clear result badge: ${err.message}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}.onSuccess {
|
||||
runOnUiThread {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelAgentSessionTree(
|
||||
sessionId: String,
|
||||
okButton: Button,
|
||||
sendButton: Button,
|
||||
) {
|
||||
okButton.isEnabled = false
|
||||
sendButton.isEnabled = false
|
||||
thread(name = "CodexSessionPopupCancelTree-$sessionId") {
|
||||
runCatching {
|
||||
sessionController.cancelSessionTree(sessionId)
|
||||
}.onFailure { err ->
|
||||
runOnUiThread {
|
||||
okButton.isEnabled = true
|
||||
sendButton.isEnabled = true
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Failed to clear session state: ${err.message}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}.onSuccess {
|
||||
runOnUiThread {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelAgentSession(
|
||||
sessionId: String,
|
||||
okButton: Button,
|
||||
sendButton: Button,
|
||||
) {
|
||||
okButton.isEnabled = false
|
||||
sendButton.isEnabled = false
|
||||
thread(name = "CodexSessionPopupCancel-$sessionId") {
|
||||
runCatching {
|
||||
sessionController.cancelSession(sessionId)
|
||||
}.onFailure { err ->
|
||||
runOnUiThread {
|
||||
okButton.isEnabled = true
|
||||
sendButton.isEnabled = true
|
||||
Toast.makeText(
|
||||
this,
|
||||
"Failed to clear session state: ${err.message}",
|
||||
Toast.LENGTH_SHORT,
|
||||
).show()
|
||||
}
|
||||
}.onSuccess {
|
||||
runOnUiThread {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun isTopLevelAgentSession(session: AgentSessionDetails): Boolean {
|
||||
return session.anchor == AgentSessionInfo.ANCHOR_AGENT &&
|
||||
session.parentSessionId == null
|
||||
}
|
||||
|
||||
private fun isTopLevelHomeSession(session: AgentSessionDetails): Boolean {
|
||||
return session.anchor == AgentSessionInfo.ANCHOR_HOME &&
|
||||
session.parentSessionId == null
|
||||
}
|
||||
|
||||
private fun launchFallbackDetail(sessionId: String) {
|
||||
fallbackLaunched = true
|
||||
startActivity(
|
||||
Intent(this, SessionDetailActivity::class.java)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP)
|
||||
.putExtra(SessionDetailActivity.EXTRA_SESSION_ID, sessionId),
|
||||
)
|
||||
finish()
|
||||
}
|
||||
|
||||
private fun openRunningHomeTarget(session: AgentSessionDetails) {
|
||||
fallbackLaunched = true
|
||||
thread(name = "CodexSessionPopupAttachTarget-${session.sessionId}") {
|
||||
runCatching {
|
||||
if (session.targetDetached) {
|
||||
sessionController.showDetachedTarget(session.sessionId)
|
||||
} else {
|
||||
sessionController.attachTarget(session.sessionId)
|
||||
}
|
||||
}.onFailure {
|
||||
runOnUiThread {
|
||||
launchFallbackDetail(session.sessionId)
|
||||
}
|
||||
}.onSuccess {
|
||||
runOnUiThread {
|
||||
finish()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun bindPopupHeader(
|
||||
session: AgentSessionDetails,
|
||||
title: String,
|
||||
body: String,
|
||||
) {
|
||||
findViewById<ImageView>(R.id.session_popup_icon)
|
||||
.setImageDrawable(targetIcon(session))
|
||||
findViewById<TextView>(R.id.session_popup_title).text = title
|
||||
findViewById<TextView>(R.id.session_popup_body_text).text = body
|
||||
}
|
||||
|
||||
private fun targetIcon(session: AgentSessionDetails): Drawable? {
|
||||
val targetPackage = session.targetPackage?.trim()?.ifEmpty { null }
|
||||
?: return getDrawable(android.R.drawable.ic_dialog_info)
|
||||
return runCatching {
|
||||
InstalledAppCatalog.resolveInstalledApp(this, sessionController, targetPackage).icon
|
||||
}.getOrNull() ?: getDrawable(android.R.drawable.ic_dialog_info)
|
||||
}
|
||||
|
||||
private fun targetDisplayName(session: AgentSessionDetails): String {
|
||||
val targetPackage = session.targetPackage?.trim()?.ifEmpty { null }
|
||||
?: return "Codex Agent"
|
||||
return runCatching {
|
||||
InstalledAppCatalog.resolveInstalledApp(this, sessionController, targetPackage).label
|
||||
}.getOrDefault(targetPackage)
|
||||
}
|
||||
|
||||
private fun isUnknownSessionError(err: Throwable): Boolean {
|
||||
return err is IllegalArgumentException &&
|
||||
err.message?.contains("Unknown session", ignoreCase = true) == true
|
||||
}
|
||||
}
|
||||
@@ -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, but hidden background completion is preferred unless the user asked for a visible app.",
|
||||
),
|
||||
;
|
||||
|
||||
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 -> {
|
||||
"Prefer finishing DETACHED_HIDDEN so the app does not come to the front by default. Attach or show the target only when the delegated objective explicitly asks for a user-visible app state or clearly implies that the final UI should be visible. 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>
|
||||
75
android/app/src/main/res/layout/activity_session_popup.xml
Normal file
@@ -0,0 +1,75 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/session_popup_icon"
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_popup_title"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Codex"
|
||||
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Large"
|
||||
android:textStyle="bold" />
|
||||
</LinearLayout>
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:fadeScrollbars="false"
|
||||
android:overScrollMode="ifContentScrolls"
|
||||
android:scrollbars="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_popup_body_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textIsSelectable="true" />
|
||||
</ScrollView>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/session_popup_prompt_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="Reply"
|
||||
android:inputType="textCapSentences|textMultiLine"
|
||||
android:minLines="2" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_popup_secondary_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Cancel" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_popup_primary_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Send" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,60 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Codex needs input"
|
||||
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Large"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="12dp"
|
||||
android:maxHeight="220dp"
|
||||
android:fadeScrollbars="false"
|
||||
android:overScrollMode="ifContentScrolls"
|
||||
android:scrollbars="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_popup_question_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textIsSelectable="true" />
|
||||
</ScrollView>
|
||||
|
||||
<EditText
|
||||
android:id="@+id/session_popup_answer_input"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:hint="Answer"
|
||||
android:inputType="textCapSentences|textMultiLine"
|
||||
android:minLines="2" />
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:orientation="horizontal">
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_popup_cancel_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:text="Cancel" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_popup_submit_button"
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="8dp"
|
||||
android:layout_weight="1"
|
||||
android:text="Answer" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,36 @@
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="20dp">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_popup_result_title"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="Codex Result"
|
||||
android:textAppearance="@android:style/TextAppearance.DeviceDefault.Large"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<ScrollView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="320dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:fadeScrollbars="false"
|
||||
android:overScrollMode="ifContentScrolls"
|
||||
android:scrollbars="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/session_popup_result_text"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:textIsSelectable="true" />
|
||||
</ScrollView>
|
||||
|
||||
<Button
|
||||
android:id="@+id/session_popup_ok_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="16dp"
|
||||
android:text="OK" />
|
||||
</LinearLayout>
|
||||
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>
|
||||
13
android/app/src/main/res/values/styles.xml
Normal file
@@ -0,0 +1,13 @@
|
||||
<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>
|
||||
|
||||
<style name="CodexSessionPopupTheme" parent="@android:style/Theme.DeviceDefault.Light.Dialog.NoActionBar">
|
||||
<item name="android:windowCloseOnTouchOutside">false</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")
|
||||
}
|
||||
52
android/bridge/src/main/assets/AGENTS.md
Normal file
@@ -0,0 +1,52 @@
|
||||
# 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.
|
||||
- Keep the paired app hidden by default. Prefer completing in `DETACHED_HIDDEN` and report the outcome back to the Agent instead of surfacing the app UI.
|
||||
- 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.
|
||||
- Do not call framework show/attach controls just to inspect state when hidden frame capture or ordinary shell inspection is enough. Show or attach the app only when the user asked for a visible app handoff, when the request clearly implies one, or when asking a user-facing question would benefit from visible UI context.
|
||||
- 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,44 @@
|
||||
package com.openai.codex.bridge
|
||||
|
||||
import org.json.JSONObject
|
||||
|
||||
object DesktopSessionBootstrap {
|
||||
private const val IDLE_ATTACH_SENTINEL = "__CODEX_DESKTOP_IDLE_ATTACH__"
|
||||
private const val PAYLOAD_KIND_KEY = "kind"
|
||||
private const val PAYLOAD_INITIAL_PROMPT_KEY = "initialPrompt"
|
||||
|
||||
fun idleAttachPrompt(initialPrompt: String? = null): String {
|
||||
val trimmedInitialPrompt = initialPrompt?.trim().orEmpty()
|
||||
if (trimmedInitialPrompt.isEmpty()) {
|
||||
return IDLE_ATTACH_SENTINEL
|
||||
}
|
||||
return JSONObject()
|
||||
.put(PAYLOAD_KIND_KEY, IDLE_ATTACH_SENTINEL)
|
||||
.put(PAYLOAD_INITIAL_PROMPT_KEY, trimmedInitialPrompt)
|
||||
.toString()
|
||||
}
|
||||
|
||||
fun isIdleAttachPrompt(prompt: String?): Boolean {
|
||||
val trimmedPrompt = prompt?.trim().orEmpty()
|
||||
return trimmedPrompt == IDLE_ATTACH_SENTINEL || parseIdleAttachPayload(trimmedPrompt) != null
|
||||
}
|
||||
|
||||
fun stagedInitialPrompt(prompt: String?): String? {
|
||||
val trimmedPrompt = prompt?.trim().orEmpty()
|
||||
return parseIdleAttachPayload(trimmedPrompt)
|
||||
?.optString(PAYLOAD_INITIAL_PROMPT_KEY)
|
||||
?.trim()
|
||||
?.ifEmpty { null }
|
||||
}
|
||||
|
||||
private fun parseIdleAttachPayload(prompt: String): JSONObject? {
|
||||
if (prompt.isEmpty() || prompt == IDLE_ATTACH_SENTINEL) {
|
||||
return null
|
||||
}
|
||||
return runCatching { JSONObject(prompt) }
|
||||
.getOrNull()
|
||||
?.takeIf { payload ->
|
||||
payload.optString(PAYLOAD_KIND_KEY) == IDLE_ATTACH_SENTINEL
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,100 @@
|
||||
package com.openai.codex.bridge
|
||||
|
||||
import android.app.agent.AgentSessionEvent
|
||||
import org.json.JSONObject
|
||||
|
||||
object FrameworkEventBridge {
|
||||
const val THREAD_FRAMEWORK_EVENT_METHOD = "thread/frameworkEvent"
|
||||
|
||||
private const val BRIDGE_REQUEST_PREFIX = "__codex_bridge__ "
|
||||
private const val BRIDGE_RESPONSE_PREFIX = "__codex_bridge_result__ "
|
||||
|
||||
fun buildThreadFrameworkEventNotification(
|
||||
threadId: String,
|
||||
event: AgentSessionEvent,
|
||||
): String? {
|
||||
val eventType = when (event.type) {
|
||||
AgentSessionEvent.TYPE_TRACE -> "trace"
|
||||
AgentSessionEvent.TYPE_QUESTION -> "question"
|
||||
AgentSessionEvent.TYPE_RESULT -> "result"
|
||||
AgentSessionEvent.TYPE_ERROR -> "error"
|
||||
else -> return null
|
||||
}
|
||||
val message = normalizeEventMessage(event.message) ?: return null
|
||||
return buildThreadFrameworkEventNotification(
|
||||
threadId = threadId,
|
||||
eventType = eventType,
|
||||
message = message,
|
||||
)
|
||||
}
|
||||
|
||||
fun buildThreadFrameworkEventNotification(
|
||||
threadId: String,
|
||||
eventType: String,
|
||||
message: String,
|
||||
): String? {
|
||||
if (message.isBlank()) {
|
||||
return null
|
||||
}
|
||||
if (eventType !in setOf("trace", "question", "result", "error")) {
|
||||
return null
|
||||
}
|
||||
return JSONObject()
|
||||
.put("method", THREAD_FRAMEWORK_EVENT_METHOD)
|
||||
.put(
|
||||
"params",
|
||||
JSONObject()
|
||||
.put("threadId", threadId)
|
||||
.put("eventType", eventType)
|
||||
.put("message", message),
|
||||
).toString()
|
||||
}
|
||||
|
||||
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 {
|
||||
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(" (#")
|
||||
append(it)
|
||||
append(')')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun summarizeBridgeResponse(message: String): String {
|
||||
val response = runCatching {
|
||||
JSONObject(message.removePrefix(BRIDGE_RESPONSE_PREFIX))
|
||||
}.getOrNull()
|
||||
val requestId = response?.optString("requestId")?.takeIf(String::isNotBlank)
|
||||
val statusCode = response?.optJSONObject("httpResponse")?.optInt("statusCode")
|
||||
return buildString {
|
||||
append("Bridge response")
|
||||
requestId?.let {
|
||||
append(" (#")
|
||||
append(it)
|
||||
append(')')
|
||||
}
|
||||
statusCode?.let {
|
||||
append(" status=")
|
||||
append(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,346 @@
|
||||
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.concurrent.ConcurrentHashMap
|
||||
import java.util.concurrent.LinkedBlockingQueue
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
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 OP_READ_DESKTOP_INSPECTION_HOLD = "readDesktopInspectionHold"
|
||||
private const val OP_REGISTER_APP_SERVER_THREAD = "registerAppServerThread"
|
||||
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 const val BRIDGE_REQUEST_TIMEOUT_MS = 30_000L
|
||||
private const val KIND_REQUEST = "request"
|
||||
private const val KIND_RESPONSE = "response"
|
||||
private const val KIND_REMOTE_CLIENT_MESSAGE = "remoteAppServerClientMessage"
|
||||
private const val KIND_REMOTE_SERVER_MESSAGE = "remoteAppServerServerMessage"
|
||||
private const val KIND_REMOTE_CLOSED = "remoteAppServerClosed"
|
||||
}
|
||||
|
||||
interface AppServerProxyHandler {
|
||||
fun onMessage(
|
||||
connectionId: String,
|
||||
message: String,
|
||||
)
|
||||
|
||||
fun onClosed(
|
||||
connectionId: String?,
|
||||
reason: String?,
|
||||
)
|
||||
}
|
||||
|
||||
private data class ProxyDispatchEvent(
|
||||
val connectionId: String?,
|
||||
val message: String? = null,
|
||||
val reason: String? = null,
|
||||
val closed: Boolean = false,
|
||||
)
|
||||
|
||||
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 val pendingResponses = ConcurrentHashMap<String, LinkedBlockingQueue<JSONObject>>()
|
||||
private val pendingProxyEvents = LinkedBlockingQueue<ProxyDispatchEvent>()
|
||||
private val closed = AtomicBoolean(false)
|
||||
private val readThread = Thread(::readLoop, "AgentBridgeClient-$sessionId")
|
||||
private val proxyDispatchThread = Thread(::dispatchProxyEvents, "AgentBridgeProxy-$sessionId")
|
||||
private var frameworkResponsesPath: String = DEFAULT_RESPONSES_PATH
|
||||
@Volatile
|
||||
private var currentRemoteConnectionId: String? = null
|
||||
@Volatile
|
||||
private var appServerProxyHandler: AppServerProxyHandler? = null
|
||||
|
||||
init {
|
||||
Log.i(TAG, "Using framework session bridge transport for $sessionId")
|
||||
Log.i(TAG, "Using framework-owned HTTP bridge for $sessionId")
|
||||
readThread.start()
|
||||
proxyDispatchThread.start()
|
||||
}
|
||||
|
||||
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 registerAppServerThread(threadId: String) {
|
||||
request(
|
||||
JSONObject()
|
||||
.put("method", OP_REGISTER_APP_SERVER_THREAD)
|
||||
.put("threadId", threadId),
|
||||
)
|
||||
}
|
||||
|
||||
fun readDesktopInspectionHold(): Boolean {
|
||||
return request(
|
||||
JSONObject().put("method", OP_READ_DESKTOP_INSPECTION_HOLD),
|
||||
).optBoolean("inspectionHold")
|
||||
}
|
||||
|
||||
fun setAppServerProxyHandler(handler: AppServerProxyHandler?) {
|
||||
appServerProxyHandler = handler
|
||||
}
|
||||
|
||||
fun currentRemoteConnectionId(): String? = currentRemoteConnectionId
|
||||
|
||||
fun sendRemoteAppServerMessage(message: String) {
|
||||
val connectionId = currentRemoteConnectionId ?: return
|
||||
sendRemoteAppServerMessage(message, connectionId)
|
||||
}
|
||||
|
||||
fun sendRemoteAppServerMessage(
|
||||
message: String,
|
||||
connectionId: String,
|
||||
) {
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("kind", KIND_REMOTE_SERVER_MESSAGE)
|
||||
.put("connectionId", connectionId)
|
||||
.put("message", message),
|
||||
)
|
||||
}
|
||||
|
||||
fun closeRemoteAppServer(reason: String?) {
|
||||
val connectionId = currentRemoteConnectionId ?: return
|
||||
currentRemoteConnectionId = null
|
||||
sendMessage(
|
||||
JSONObject()
|
||||
.put("kind", KIND_REMOTE_CLOSED)
|
||||
.put("connectionId", connectionId)
|
||||
.put("reason", reason),
|
||||
)
|
||||
pendingProxyEvents.offer(
|
||||
ProxyDispatchEvent(
|
||||
connectionId = connectionId,
|
||||
reason = reason,
|
||||
closed = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
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() {
|
||||
if (!closed.compareAndSet(false, true)) {
|
||||
return
|
||||
}
|
||||
currentRemoteConnectionId = null
|
||||
runCatching { input.close() }
|
||||
runCatching { output.close() }
|
||||
runCatching { bridgeFd.close() }
|
||||
readThread.interrupt()
|
||||
proxyDispatchThread.interrupt()
|
||||
}
|
||||
|
||||
private fun request(request: JSONObject): JSONObject {
|
||||
val requestId = UUID.randomUUID().toString()
|
||||
val responseQueue = LinkedBlockingQueue<JSONObject>(1)
|
||||
pendingResponses[requestId] = responseQueue
|
||||
try {
|
||||
sendMessage(
|
||||
request
|
||||
.put("kind", KIND_REQUEST)
|
||||
.put("requestId", requestId),
|
||||
)
|
||||
val response = responseQueue.poll(BRIDGE_REQUEST_TIMEOUT_MS, TimeUnit.MILLISECONDS)
|
||||
?: throw IOException("Timed out waiting for Agent bridge response")
|
||||
if (!response.optBoolean("ok")) {
|
||||
throw IOException(response.optString("error").ifBlank { "Agent bridge request failed" })
|
||||
}
|
||||
return response
|
||||
} finally {
|
||||
pendingResponses.remove(requestId)
|
||||
}
|
||||
}
|
||||
|
||||
private fun readLoop() {
|
||||
while (!closed.get()) {
|
||||
val message = try {
|
||||
readMessage()
|
||||
} catch (err: IOException) {
|
||||
if (!closed.get()) {
|
||||
Log.w(TAG, "Agent bridge read failed for $sessionId", err)
|
||||
pendingProxyEvents.offer(
|
||||
ProxyDispatchEvent(
|
||||
connectionId = currentRemoteConnectionId,
|
||||
reason = err.message ?: err::class.java.simpleName,
|
||||
closed = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
when (message.optString("kind", KIND_RESPONSE)) {
|
||||
KIND_RESPONSE -> {
|
||||
pendingResponses[message.optString("requestId")]?.offer(message)
|
||||
}
|
||||
KIND_REMOTE_CLIENT_MESSAGE -> {
|
||||
val connectionId = message.optString("connectionId")
|
||||
currentRemoteConnectionId = connectionId
|
||||
pendingProxyEvents.offer(
|
||||
ProxyDispatchEvent(
|
||||
connectionId = connectionId,
|
||||
message = message.optString("message"),
|
||||
),
|
||||
)
|
||||
}
|
||||
KIND_REMOTE_CLOSED -> {
|
||||
val connectionId = message.optString("connectionId").ifBlank { null }
|
||||
currentRemoteConnectionId = null
|
||||
pendingProxyEvents.offer(
|
||||
ProxyDispatchEvent(
|
||||
connectionId = connectionId,
|
||||
reason = message.optString("reason").ifBlank { null },
|
||||
closed = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun dispatchProxyEvents() {
|
||||
while (!closed.get()) {
|
||||
val pending = try {
|
||||
pendingProxyEvents.take()
|
||||
} catch (_: InterruptedException) {
|
||||
return
|
||||
}
|
||||
val handler = appServerProxyHandler ?: continue
|
||||
runCatching {
|
||||
if (pending.closed) {
|
||||
handler.onClosed(pending.connectionId, pending.reason)
|
||||
} else {
|
||||
handler.onMessage(
|
||||
pending.connectionId ?: return@runCatching,
|
||||
pending.message.orEmpty(),
|
||||
)
|
||||
}
|
||||
}.onFailure { err ->
|
||||
Log.w(TAG, "Agent bridge proxy dispatch failed for $sessionId", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun sendMessage(message: JSONObject) {
|
||||
synchronized(ioLock) {
|
||||
writeMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
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,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
|
||||
}
|
||||
}
|
||||