Let Agent ask the user during planning

Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
Iliyan Malchev
2026-03-19 14:39:32 -07:00
parent 7b8096e6ef
commit ac85481fee
6 changed files with 234 additions and 26 deletions

View File

@@ -44,6 +44,7 @@ object AgentCodexAppServerClient {
prompt: String,
dynamicTools: JSONArray? = null,
toolCallHandler: ((String, JSONObject) -> JSONObject)? = null,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): String = synchronized(lifecycleLock) {
ensureStarted(context.applicationContext)
activeRequests.incrementAndGet()
@@ -55,7 +56,7 @@ object AgentCodexAppServerClient {
dynamicTools = dynamicTools,
)
startTurn(threadId, prompt)
waitForTurnCompletion(toolCallHandler)
waitForTurnCompletion(toolCallHandler, requestUserInputHandler)
} finally {
activeRequests.decrementAndGet()
}
@@ -180,6 +181,7 @@ object AgentCodexAppServerClient {
private fun waitForTurnCompletion(
toolCallHandler: ((String, JSONObject) -> JSONObject)?,
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
): String {
val streamedAgentMessages = mutableMapOf<String, StringBuilder>()
var finalAgentMessage: String? = null
@@ -195,7 +197,7 @@ object AgentCodexAppServerClient {
continue
}
if (notification.has("id") && notification.has("method")) {
handleServerRequest(notification, toolCallHandler)
handleServerRequest(notification, toolCallHandler, requestUserInputHandler)
continue
}
val params = notification.optJSONObject("params") ?: JSONObject()
@@ -238,38 +240,64 @@ object AgentCodexAppServerClient {
private fun handleServerRequest(
message: JSONObject,
toolCallHandler: ((String, JSONObject) -> JSONObject)?,
requestUserInputHandler: ((JSONArray) -> JSONObject)?,
) {
val requestId = message.opt("id") ?: return
val method = message.optString("method", "unknown")
if (method != "item/tool/call") {
sendError(
requestId = requestId,
code = -32601,
message = "Unsupported Agent app-server request: $method",
)
return
}
if (toolCallHandler == null) {
sendError(
requestId = requestId,
code = -32601,
message = "No Agent tool handler registered for $method",
)
return
}
val params = message.optJSONObject("params") ?: JSONObject()
val toolName = params.optString("tool").trim()
val arguments = params.optJSONObject("arguments") ?: JSONObject()
val result = runCatching { toolCallHandler(toolName, arguments) }
.getOrElse { err ->
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()
val result = runCatching { toolCallHandler(toolName, arguments) }
.getOrElse { err ->
sendError(
requestId = requestId,
code = -32000,
message = err.message ?: "Agent tool call failed",
)
return
}
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()
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 = -32000,
message = err.message ?: "Agent tool call failed",
code = -32601,
message = "Unsupported Agent app-server request: $method",
)
return
}
sendResult(requestId, result)
}
}
private fun sendResult(

View File

@@ -2,6 +2,8 @@ package com.openai.codexd
import android.content.Context
import java.io.IOException
import org.json.JSONArray
import org.json.JSONObject
data class AgentDelegationTarget(
val packageName: String,
@@ -38,6 +40,7 @@ object AgentTaskPlanner {
targetPackageOverride: String?,
allowDetachedMode: Boolean,
sessionController: AgentSessionController,
requestUserInputHandler: ((JSONArray) -> JSONObject)? = null,
): SessionStartResult {
if (!targetPackageOverride.isNullOrBlank()) {
return sessionController.startDirectSession(
@@ -75,6 +78,7 @@ object AgentTaskPlanner {
},
)
},
requestUserInputHandler = requestUserInputHandler,
)
return sessionStartResult
?: throw IOException("Agent runtime did not launch any Genie sessions")

View File

@@ -0,0 +1,116 @@
package com.openai.codexd
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
}
}

View File

@@ -188,6 +188,9 @@ class MainActivity : Activity() {
targetPackageOverride = targetPackageOverride.ifBlank { null },
allowDetachedMode = true,
sessionController = agentSessionController,
requestUserInputHandler = { questions ->
AgentUserInputPrompter.promptForAnswers(this, questions)
},
)
}
result.onFailure { err ->

View File

@@ -0,0 +1,53 @@
package com.openai.codexd
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"))
}
}

View File

@@ -160,6 +160,8 @@ foreground-service auth/status surface while this refactor proceeds.
- Agent-owned question notifications for Genie questions that need user input
- Agent-mediated free-form answers for Genie questions, using the hosted Agent
Codex runtime as the temporary answer engine
- Agent planning can now use `request_user_input` to ask the user clarifying
questions before launching child Genie sessions
- Abstract-unix-socket support in the legacy Rust bridge via `@name` or
`abstract:name`, so the compatibility transport can move off app-private
filesystem sockets when Agent<->Genie traffic is introduced
@@ -187,7 +189,9 @@ foreground-service auth/status surface while this refactor proceeds.
- `android/app/src/main/java/com/openai/codexd/AgentFrameworkToolBridge.kt`
- hosted Agent bridge for framework session APIs
- `android/app/src/main/java/com/openai/codexd/MainActivity.kt`
- Agent session UI plus existing `codexd` bridge controls
- Agent session UI, Agent clarification dialogs, and existing `codexd` bridge controls
- `android/app/src/main/java/com/openai/codexd/AgentUserInputPrompter.kt`
- Android dialog bridge for hosted Agent `request_user_input` calls
- `android/genie/src/main/java/com/openai/codex/genie/CodexGenieService.kt`
- Genie lifecycle host for the embedded `codex app-server`
- `android/genie/src/main/java/com/openai/codex/genie/CodexAppServerHost.kt`