mirror of
https://github.com/openai/codex.git
synced 2026-04-27 09:51:03 +03:00
Let Agent ask the user during planning
Co-authored-by: Codex <noreply@openai.com>
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -188,6 +188,9 @@ class MainActivity : Activity() {
|
||||
targetPackageOverride = targetPackageOverride.ifBlank { null },
|
||||
allowDetachedMode = true,
|
||||
sessionController = agentSessionController,
|
||||
requestUserInputHandler = { questions ->
|
||||
AgentUserInputPrompter.promptForAnswers(this, questions)
|
||||
},
|
||||
)
|
||||
}
|
||||
result.onFailure { err ->
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
}
|
||||
@@ -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`
|
||||
|
||||
Reference in New Issue
Block a user