Android Agent Harness
Align with bootstrap: Result / AppError at boundaries, concrete classes,
no interface theater.
Use this skill only if the prompt needs an LLM/tool loop. Otherwise stay on ../android-interview-bootstrap/SKILL.md.
Two topologies — pick first
| Topology | When | What the client does |
|---|---|---|
| A. Client-side loop | No backend allowed; tools act on the device | Runs the tool loop itself against OpenAI (bulk of this skill) |
| B. Harness-server client | Agent operates on a codebase / machine elsewhere; server runs the agent (e.g. Cursor/Codex SDK) | Sends turns, consumes a typed event stream, renders/speaks it — see Consuming a harness server |
In topology B the server owns the loop; do not duplicate tool-call parsing on
the client. The client's contract is the event protocol, not the LLM wire
format. Keep the protocol small, turn-scoped, with exactly one terminal event
per turn (completed / failed / cancelled).
For topology A: the model proposes tools; the Android app executes them and returns results.
Placement
| Scope | Where |
|---|---|
| One chat feature | Keep harness in :feature:<chat> (agent/ package) |
| Reused by 2+ features | Promote to :core:agent |
Depends on :core:network (OkHttp client) + :core:model (messages / errors).
Don’t put streaming inside Retrofit unless you already know that path cold —
OkHttp + SSE event source / ResponseBody.byteStream() is the interview-safe default.
Loop
messages (source of truth)
→ POST /v1/chat/completions (messages + tools, stream optional)
→ stream delta.content → UI; accumulate delta.tool_calls by index
→ finish_reason == tool_calls?
yes → permission → execute tools → append assistant + tool msgs → loop
no → append assistant text → stop; wait for user
Rules:
- Send the same tools list on every call while tools are allowed
- Cap iterations (e.g. 5) — prevent infinite tool loops
- Cancel via
Job/CoroutineScopecancellation; don’t append half-baked assistant messages on cancel (or mark cancelled explicitly) tool_choice: "auto"normally;"none"to force a final text answer
Roles
| role | writer | purpose |
|---|---|---|
| system | app | instructions / product personality |
| user | human | input |
| assistant | model | text and/or tool_calls |
| tool | app | result; must set tool_call_id |
Kotlin models (keep small)
data class ChatMessage(
val role: Role, // System, User, Assistant, Tool
val content: String? = null,
val toolCalls: List<ToolCall>? = null,
val toolCallId: String? = null, // for Role.Tool
)
data class ToolCall(
val id: String,
val name: String,
val argumentsJson: String, // JSON string
)
data class ToolDefinition(
val name: String,
val description: String,
val parametersJson: String, // JSON Schema; map to provider DTO at API edge
)
sealed interface AgentEvent {
data class Token(val text: String) : AgentEvent
data class ToolRequests(val calls: List<ToolCall>) : AgentEvent
data class Finished(val reason: String) : AgentEvent // stop | tool_calls | length | cancel
data class Error(val error: AppError) : AgentEvent
}
interface Tool {
val definition: ToolDefinition
suspend fun execute(argumentsJson: String): String
}
Concrete Tool classes + a Map<String, Tool> registry is enough — no
ToolFactoryProvider stacks.
@Module
@InstallIn(SingletonComponent::class)
object AgentModule {
@Provides
fun tools(
weather: WeatherTool,
readFile: ReadFileTool,
): Map<String, Tool> =
listOf(weather, readFile).associateBy { it.definition.name }
}
This explicit provider is faster than custom Hilt map keys for a small tool set.
Choose the wire protocol first
The harness loop is provider-independent; DTOs and SSE event names are not. Confirm the interview's endpoint before coding.
| Contract | Use |
|---|---|
OpenAI Responses (/v1/responses) |
Default for a new OpenAI project; current recommended API |
Chat Completions (/v1/chat/completions) |
OpenAI-compatible providers or when the prompt specifies it |
Responses uses typed output items/events (response.output_text.delta,
function_call, function_call_output) rather than
choices[].delta.tool_calls. Keep provider DTOs behind
ChatCompletionsClient/AgentClient so the harness consumes only
StreamOutcome. Consider store: false when prompts should not be retained.
The examples below use Chat Completions because its raw tool loop is common across OpenAI-compatible providers.
Chat Completions wire format
Request essentials
{
"model": "MODEL",
"stream": true,
"messages": [/* full history */],
"tools": [{
"type": "function",
"function": {
"name": "get_weather",
"description": "...",
"parameters": {
"type": "object",
"properties": { "city": { "type": "string" } },
"required": ["city"]
}
}
}],
"tool_choice": "auto"
}
Non-stream tool call
finish_reason:"tool_calls"message.tool_calls[].id,.function.name,.function.argumentsargumentsis a JSON string —Moshi/JSONObjectparse after assemble- Append the assistant message (with
tool_calls) to history before tool results
Tool result message
{
"role": "tool",
"tool_call_id": "call_abc123",
"content": "{\"temp\":72}"
}
content is a string (often stringified JSON).
Streaming (SSE)
- Lines:
data: {...}thendata: [DONE] - Concatenate
choices[0].delta.contentinto the in-progress assistant bubble - Merge
delta.tool_callsbyindex(id / name / arguments arrive fragmented) finish_reason: tool_calls→ run tools;stop→ done
// Pseudocode accumulator
val args = mutableMapOf<Int, StringBuilder>()
val ids = mutableMapOf<Int, String>()
val names = mutableMapOf<Int, String>()
// on each delta.tool_calls entry:
// ids[index] = id ?: ids[index]
// names[index] = name ?: names[index]
// args.getOrPut(index) { StringBuilder() }.append(arguments.orEmpty())
Wire with shared OkHttp client from :core:network. Parse with Moshi adapters
for chunk types — keep DTOs next to the API, map to ChatMessage / AgentEvent
in the harness.
OkHttp SSE reader (the part you don’t want to derive live)
suspend fun streamCompletion(
request: Request, // POST with JSON body, Accept: text/event-stream
onToken: (String) -> Unit,
): StreamOutcome = withContext(Dispatchers.IO) {
val call = client.newCall(request)
val cancelHandle = currentCoroutineContext().job.invokeOnCompletion { cause ->
if (cause is CancellationException) call.cancel()
}
try {
call.execute().use { response ->
if (!response.isSuccessful) throw AppError.Http(response.code)
val source = response.body.source()
val text = StringBuilder()
val toolAccumulator = ToolCallAccumulator()
var finishReason: String? = null
while (!source.exhausted()) {
currentCoroutineContext().ensureActive()
val line = source.readUtf8Line() ?: break
if (!line.startsWith("data: ")) continue
val payload = line.removePrefix("data: ")
if (payload == "[DONE]") break
val chunk = chunkAdapter.fromJson(payload) ?: continue
val choice = chunk.choices.firstOrNull() ?: continue
choice.delta?.content?.let { text.append(it); onToken(it) }
choice.delta?.toolCalls?.forEach(toolAccumulator::accept)
choice.finishReason?.let { finishReason = it }
}
if (finishReason == "tool_calls") {
StreamOutcome.ToolCalls(
text.toString().ifEmpty { null },
toolAccumulator.build(),
)
} else {
StreamOutcome.Text(text.toString())
}
}
} finally {
cancelHandle.dispose()
}
}
sealed interface StreamOutcome {
data class Text(val text: String) : StreamOutcome
data class ToolCalls(val assistantText: String?, val calls: List<ToolCall>) : StreamOutcome
}
Notes:
- Set
readTimeoutgenerously or to zero for the streaming client (client.newBuilder().readTimeout(0, MILLISECONDS)) - Chunks can arrive with empty
choices— skip, don’t crash - Tie coroutine cancellation to
Call.cancel();withContext(IO)alone does not cancel a blocking OkHttp read response.use { }guarantees the connection closes on completion/error- OkHttp’s
okhttp-sseartifact (EventSources) is a fine alternative to hand-parsing lines; callbacks instead of a loop
Harness sketch
class AgentHarness @Inject constructor(
private val api: ChatCompletionsClient, // OkHttp streaming wrapper
private val tools: Map<String, Tool>,
) {
private val toolDefs = tools.values.map { it.definition }
suspend fun run(
history: MutableList<ChatMessage>,
userText: String,
onEvent: (AgentEvent) -> Unit,
): Result<Unit> = try {
history += ChatMessage(Role.User, content = userText)
var steps = 0
while (steps++ < MAX_STEPS) {
currentCoroutineContext().ensureActive()
val outcome = api.stream(history, tools = toolDefs) { token ->
onEvent(AgentEvent.Token(token))
}
when (outcome) {
is StreamOutcome.Text -> {
history += ChatMessage(Role.Assistant, content = outcome.text)
onEvent(AgentEvent.Finished("stop"))
return Result.success(Unit)
}
is StreamOutcome.ToolCalls -> {
history += ChatMessage(
Role.Assistant,
content = outcome.assistantText,
toolCalls = outcome.calls,
)
onEvent(AgentEvent.ToolRequests(outcome.calls))
for (call in outcome.calls) {
val tool = tools[call.name]
val result = try {
tool?.execute(call.argumentsJson)
?: """{"error":"unknown tool"}"""
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) {
// Log t; keep tool output valid JSON and non-sensitive.
"""{"error":"tool execution failed"}"""
}
history += ChatMessage(
Role.Tool,
content = result,
toolCallId = call.id,
)
}
// loop for model follow-up
}
}
}
Result.failure(AppError.Message("Tool loop limit reached"))
} catch (t: CancellationException) {
throw t
} catch (t: Throwable) {
val err = t.toAppError()
onEvent(AgentEvent.Error(err))
Result.failure(err)
}
}
Permissions go inside the tool execute path or a wrapper before execute.
Permissions
For read_file / device / network side effects:
- Model returns
tool_call - Allowlist and/or user confirmation dialog
- Deny → tool
content="permission denied"(model can recover) or abort - Allow → execute → string result
Never give the model blind access to the filesystem or tokens.
Compose chat UiState
data class ChatUiState(
val messages: List<MessageUi> = emptyList(),
val input: String = "",
val isStreaming: Boolean = false,
val error: String? = null,
)
data class MessageUi(
val id: String,
val role: Role,
val text: String,
val isPending: Boolean = false, // streaming assistant bubble
)
- On
Token: append/update the pending assistant message - On
Finished: clear pending /isStreaming - On
Error:error = appError.toUserMessage(); keep prior messages - Cancel button → cancel the harness
Job - Allow one active run per conversation (disable Send or guard with a
Mutex); concurrent runs corrupt message ordering - Collect in UI with
collectAsStateWithLifecycle()
Errors
Same policy as bootstrap:
- Transport/parse failures →
toAppError()→Result.failure/AgentEvent.Error - Tool failures → usually string error payload back to the model (continues loop) unless the failure is fatal to the app
- User-facing strings via
toUserMessage(); log the throwable
Config / security
- API key from
BuildConfig/local.properties— never hardcode - Prefer a backend proxy in real products; for interview, header
Authorization: Bearer …on OkHttp interceptor is fine - Don’t log full prompts if they may contain secrets
Consuming a harness server (WebSocket)
Topology B: server runs the agent; client is a protocol consumer. Define the wire events as a sealed type and never let raw server/SDK shapes past the client's network edge.
sealed interface AgentEvent {
val turnId: String
data class TurnStarted(override val turnId: String) : AgentEvent
data class AssistantText(override val turnId: String, val text: String) : AgentEvent
data class ToolAction(override val turnId: String, val label: String) : AgentEvent
data class TurnCompleted(
override val turnId: String,
val summary: String,
val speak: String? = null, // spoken content is server-chosen, distinct from display text
) : AgentEvent
data class TurnFailed(override val turnId: String, val message: String) : AgentEvent
data class TurnCancelled(override val turnId: String) : AgentEvent
}
Moshi: PolymorphicJsonAdapterFactory.of(AgentEventDto::class.java, "type")
with one DTO per event type; map DTO → sealed event at the client edge.
OkHttp WebSocket → Flow
class AgentClient @Inject constructor(private val okHttp: OkHttpClient) {
private var webSocket: WebSocket? = null
fun connect(url: String): Flow<AgentEvent> = callbackFlow {
val request = Request.Builder().url(url).build()
val ws = okHttp.newWebSocket(request, object : WebSocketListener() {
override fun onMessage(webSocket: WebSocket, text: String) {
parseEvent(text)?.let { trySend(it) } // skip unknown types, don't crash
}
override fun onFailure(ws: WebSocket, t: Throwable, r: Response?) {
close(t)
}
override fun onClosed(ws: WebSocket, code: Int, reason: String) {
close()
}
})
webSocket = ws
awaitClose { ws.close(1000, null) }
}
fun sendTurn(text: String) { webSocket?.send(turnJson(text)) }
fun cancel() { webSocket?.send("""{"type":"cancel"}""") }
}
Notes:
WebSocketListenerdelivers complete text frames — no SSE line parsing- Callbacks arrive on OkHttp threads;
callbackFlow+collectinviewModelScopegets you back to a coroutine context safely - Reconnect: retry
connectwith backoff in the VM/repo layer; interview scope = show the seam, not a full reconnect state machine - Unknown event
type→ log and skip (forward compatibility) - Emulator → laptop server:
adb reverse tcp:PORT tcp:PORT, then usews://localhost:PORT(or10.0.2.2without adb reverse)
Turn state machine in the VM
enum class TurnPhase { Idle, WaitingOnAgent, Speaking }
// events fold into UiState:
// TurnStarted → WaitingOnAgent; clear transcript for turn
// AssistantText → append/replace streaming transcript
// ToolAction → update "working on…" status line
// Terminal → Idle (or Speaking if speak != null); show summary/error
Exactly one terminal event per turn keeps this fold total — no "is it done?"
ambiguity. Guard with one-active-turn (disable send while WaitingOnAgent).
Interview vertical slice
Topology A (client-side loop):
- Non-stream fake JSON → parse assistant text into list
- Real non-stream completion
- Streaming tokens into one bubble
- One tool end-to-end (fake
get_weatheris enough) - Cancel + error + iteration cap
Stop adding tools/UI chrome until 1–4 work.
Topology B (harness-server client):
- Server first, alone — drive it with
wscatuntil events look right - Client connect + render
AssistantTextinto one bubble (typed input) - Full event fold: status line, terminal events, error states
- Cancel round-trip
- Only then: voice or other input modalities on top
Testing (no slop)
- Unit-test argument accumulation / message append ordering with fake chunks
- Fake
ChatCompletionsClientreturning scriptedStreamOutcomes — no MockK theater - Optional: one test that unknown tool → error JSON tool message
Pitfalls
- Forgetting assistant
tool_callsmessage beforerole: toolresults - Mismatched
tool_call_id - Treating
argumentsas a JSON object in the wire model (it’s a string) - Unbounded tool loops
- Updating UI off the main thread
- Coding against Chat Completions event shapes when the supplied contract is Responses (or vice versa)
- Parallel
tool_calls: execute all, append all tool msgs, then one follow-up completion (order preserved)
Related
- ../android-interview-bootstrap/SKILL.md
- ../android-compose/SKILL.md
- Voice input/output on top of an agent client: ../android-voice/SKILL.md