Framework-agnostic interface spec for swapping gemini-cli between internal execution loop, ADK, OpenRouter, or any future agent framework.
83 KiB
Phase 2 Interface Layer: Comprehensive Outline
Purpose
This document enumerates every interface required to make gemini-cli framework-agnostic -- capable of running agents via its own execution loop, ADK-TS, OpenRouter, or any future agent framework, selected by configuration.
Each interface is grouped by priority, annotated with rationale, mapped to existing systems, and tagged with open questions. The audience is the engineering team implementing Phase 2.
Table of Contents
- Architecture Overview
- P0 Interfaces (Must Define First)
- P1 Interfaces (Define After P0)
- P2 Interfaces (Can Follow)
- Dependency Graph
- Abhi's Questions: Traceability Matrix
- Questions the Team Should Be Asking
- Migration Path
1. Architecture Overview
The interface layer sits between gemini-cli's host systems (UI, hooks, policy, confirmation) and the agent execution backends:
gemini-cli Host
┌──────────────────────────────────────────┐
│ UI │ Hooks │ Policy │ Confirmation Bus │
└──────┬───────┬────────┬──────────────────┘
│ │ │
┌──────▼───────▼────────▼──────────────────┐
│ Interface Layer (this doc) │
│ AgentDescriptor │ AgentSession │ Events │
│ ToolContract │ SessionContract │
└──────┬───────────┬───────────────┬────────┘
│ │ │
┌────────▼──┐ ┌────▼────┐ ┌───────▼───────┐
│ Local │ │ ADK │ │ OpenRouter/ │
│ Executor │ │ Runner │ │ External │
└───────────┘ └─────────┘ └───────────────┘
Core Principle: Descriptor / Executor Separation
An agent has two facets:
- AgentDescriptor -- what the agent IS. Identity, schemas, capabilities, composability metadata. Consumed by SubagentTool, AgentRegistry, PolicyEngine, and UI without running the agent.
- AgentSession -- how the agent RUNS. The stateful runtime handle:
send()for between-turn data,update()for mid-stream changes,abort()for cancellation. Created by a factory keyed onexecutor: string.
This separation is the foundational insight. Every interface below supports it.
Design Philosophy: Define Shape, Not Vocabulary
Every interface follows these rules:
-
Known values + open string. Discriminator fields use the TypeScript pattern
'known1' | 'known2' | (string & {})— you get IDE autocomplete and compile-time checking for well-known values, while still accepting any string at runtime for extensibility. New values can be added by any implementation without changing the interface. We start with enums covering gemini-cli, ADK, Claude SDK, and OpenAI, then extend as needed. -
Record<string, unknown>for extensibility. Where implementations need host/framework-specific data (MCP server names, approval modes, tool annotations), they go in opencontext,_meta, orannotationsfields. The interface doesn't know or care about the contents. -
JsonSchemafor self-description. Elicitation requests describe their expected response shape. Tool descriptors describe their parameters. Agent descriptors describe their input/output. The schema IS the documentation — UIs can render dynamically. -
Conventions documented, not enforced. Common patterns (capability names like
'elicitation', annotation keys likereadOnly, elicitation kinds like'tool_confirmation') are documented as conventions in comments, but the interface compiles and works without them. Implementations define their own vocabulary. -
No upstream dependencies. No
@google/genaitypes, no ADK types, no gemini-cli internal types in the interface signatures. Everything is provider-agnostic. Adapters translate at the boundary. -
Superset, not lowest-common-denominator. Interfaces are designed from the superset of gemini-cli, ADK-TS, Claude Agent SDK, and OpenAI Codex/Agents SDK. If a concept appears in 2+ systems (cost tracking, session forking, refusal signals, structured termination), it belongs in the interface. If it's unique to one system (OpenAI's 53 granular streaming events), it's an implementation detail handled by adapters. See
NOTES-cross-sdk-comparison.mdfor the full analysis.
2. P0 Interfaces
These must be defined first. Everything else builds on them.
2.1 AgentEvent -- The Event Stream Contract
What it is: The discriminated union of all events that can flow between an agent executor and the host. This is the universal wire format.
Why we need it: Every other component consumes or produces these events. The UI renders them. Hooks intercept them. Adapters translate to/from them. Without a stable event contract, nothing else can be built.
Abhi's questions answered: #5 (what events for UX), partially #1 (events as the communication channel)
Existing systems touched:
CoreEventEmitter(maps AgentEvents to UI events)SubagentActivityEvent/SubagentProgress(replaced by standard events)StreamEventTypein GeminiChat (content_delta, thought, etc.)- Hook input/output types (hooks observe/modify events)
Proposed shape:
interface AgentEventBase {
/** Unique event ID. */
id: string;
/** ISO 8601 timestamp. */
timestamp: string;
/** The concrete type of the event. */
type: AgentEventType;
/** Which agent emitted this event. Distinct from threadId (thread identity)
* and source (attribution like 'user' or 'host'). */
agentId?: string;
/** Identifies the subagent thread. Omitted for root agent. */
threadId?: string;
/** Source attribution: 'agent', 'user', 'host', 'ext:name/hooks/hookName'. */
source?: string;
/** Extensibility escape hatch. */
_meta?: Record<string, unknown>;
}
/** Known event types + open extension.
* `(string & {})` preserves IDE autocomplete for known values
* while accepting any string at runtime. */
type AgentEventType =
| 'initialize' | 'session_update' | 'message'
| 'tool_request' | 'tool_update' | 'tool_response'
| 'elicitation_request' | 'elicitation_response'
| 'usage' | 'error' | 'stream_end' | 'custom'
| (string & {});
/**
* Event map — uses Michael's interface + mapped type pattern.
* Interface (not union) enables declaration merging: any module can
* add new event types without modifying this file.
*/
interface AgentEvents {
/** MUST be the first event emitted in a session. */
initialize: Initialize;
/** Updates configuration about the current session/agent. */
session_update: SessionUpdate;
/** Message content provided by user, agent, or developer. */
message: Message;
/** Tool request issued by the agent. */
tool_request: ToolRequest;
/** Intermediate progress on long-running tools (ephemeral, not sent to model). */
tool_update: ToolUpdate;
/** Tool response supplied by the host. */
tool_response: ToolResponse;
/** Elicitation request to be displayed to the user. */
elicitation_request: ElicitationRequest;
/** User's response to an elicitation. */
elicitation_response: ElicitationResponse;
/** Reports token usage and cost information. */
usage: Usage;
/** Report errors. */
error: ErrorData;
/** Signals why the stream ended. Last event in a stream. */
stream_end: StreamEnd;
/** Custom events for things not otherwise covered above. */
custom: CustomEvent;
}
/** Mapped type: discriminated union derived from AgentEvents interface. */
type AgentEvent<
EventType extends keyof AgentEvents = keyof AgentEvents,
> = AgentEventBase & AgentEvents[EventType] & { type: EventType };
/** Reports token usage and cost information. */
interface Usage {
model?: string;
inputTokens?: number;
outputTokens?: number;
cachedTokens?: number;
/** Cost in USD for this usage increment. From Claude SDK's total_cost_usd. */
cost?: { usd?: number };
}
/** Why the stream ended. Open extension via `(string & {})`. */
type StreamEndReason =
| 'completed' // Agent finished successfully
| 'failed' // Agent encountered an error
| 'aborted' // Agent was cancelled by user/host
| 'max_turns' // Hit turn limit
| 'max_budget' // Hit budget limit
| 'max_time' // Hit time limit
| 'refusal' // Model refused the request
| (string & {});
/** Last event in a stream. Tells the host why the stream ended
* and carries summary data (cost, usage, turn count, etc.). */
interface StreamEnd {
/** Why this stream ended. */
reason: StreamEndReason;
/** End-of-stream data (open shape).
* For completed: { result?, cost?, usage?, numTurns? }
* For failed: { error?, message? } */
data?: Record<string, unknown>;
}
Key design decisions (see NOTES-cross-sdk-comparison.md for full rationale):
-
type: AgentEventTypewith known values + open extension — Uses the'known' | (string & {})pattern throughout. We start with 12 well-known event types derived from the superset (gemini-cli, ADK, Claude SDK, OpenAI). New event types can be added by any implementation without changing the interface. IDE autocomplete works for known values. Aligns with Michael's opentype: stringwhile adding type safety. -
interface AgentEvents+ mapped type (from Michael) — Uses an interface (not a union type) so that any module can add new event types via declaration merging without modifying this file. The mapped typeAgentEvent<EventType>derives the discriminated union automatically. This is strictly better than an explicit union for extensibility. -
agentIdon the event base — Every event carries which agent emitted it. Distinct fromthreadId(which identifies the execution thread) andsource(which is attribution like 'user' or 'host'). Essential for multi-agent scenarios where events from different agents flow through the same stream. -
12 event types is the right abstraction level — Cross-SDK analysis confirms every category is needed.
stream_endreplaces a more generallifecycleevent — its only job is to signal why the stream ended, which is the one thing not covered by other events. OpenAI's 53 events are too granular (implementation details of the Responses API). Our ToolUpdate correctly abstracts over OpenAI's 15+ per-tool progress events. Claude SDK's 6 message types are too coarse (no tool progress, no elicitation). -
Streaming via the AsyncGenerator itself — Both Claude SDK (StreamEvent opt-in) and OpenAI (delta/done pairs) use streaming, but their patterns are provider-specific. The AsyncGenerator is the streaming mechanism — each Message event yields as it arrives. No
partialflag needed; the stream being open signals more content is coming. Adapters translate delta/done pairs into individual yielded events. -
stream_endfor termination signaling — The only signal not covered by other events is "why did the stream end?"stream_endcarries areason(completed, failed, max_turns, etc.) + opendatabag for cost/usage/result. Start is covered byinitialize. Pausing is implicit (elicitation/tool request events). Both Claude SDK (ResultMessage subtypes) and OpenAI (completed/incomplete/failed) validate the need for structured end-of-stream events. -
Handoff is a tool call, not a stream_end reason — OpenAI Agents SDK has explicit
handoff_requested/handoff_occurredevents. ADK hastransfer_to_agenttool +eventActions.transferToAgent. We treat handoff as a tool call (transfer_to_agent) that the host intercepts. This avoids duplicating routing logic between end-of-stream events and tool calls. -
Pausing is implicit — No explicit pause/resume events. When an agent emits an
elicitation_requestortool_requestevent, the stream ends. The host callssession.stream()with the response data to get the next stream. This matches ADK (callrunAsync()again) and Michael (callsend()again). -
Cost tracking on Usage — Claude SDK tracks
total_cost_usd. As agents become production infrastructure, cost visibility is essential. Addedcost: { usd }to Usage. -
Refusal as ContentPart, not event — OpenAI has
response.refusal.delta/doneevents; Claude hasstop_reason: "refusal". Rather than adding a 13th event type, we add{ type: 'refusal'; text: string }as a ContentPart. This keeps the event taxonomy stable. -
Event ordering: Strictly ordered within a thread. Across threads (parallel sub-agents), the host merges streams by timestamp.
-
Backpressure: AsyncGenerator naturally provides backpressure via
yield. Validated by all SDKs — Claude, Codex, and ADK all use async generators/iterators.
Relationship to prior work (see NOTES-cross-sdk-comparison.md for full analysis):
| Concept | Dewitt | Michael | Claude SDK | OpenAI/Codex | This proposal |
|---|---|---|---|---|---|
| Event base | AgentEvent (11) |
AgentEvent (11) |
SDKMessage (6) |
53 streaming events | AgentEvent (12) via interface + mapped type |
| Agent ID | — | — | — | — | agentId on event base |
| Streaming | content event |
message event |
StreamEvent (opt-in) |
output_text.delta/done |
AsyncGenerator yields Message events |
| Tool progress | activity |
tool_update |
— | per-tool events (15+) | ToolUpdate (ephemeral) |
| HITL | tool_confirmation |
elicitation_* |
canUseTool callback |
mcp_approval_* |
ElicitationRequest/Response |
| Stream end | finished |
initialize |
ResultMessage |
response.created/completed/failed |
stream_end with reason + open data |
| Termination | — | — | typed subtypes (5) | completed/incomplete/failed |
StreamEnd.reason (completed/failed/max_turns/etc.) |
| Handoff | — | — | — | handoff_* events |
Tool call (transfer_to_agent) |
| Metadata | none | _meta everywhere |
— | — | _meta on base + ContentPart |
| Sub-agents | Agent<T> |
threadId |
AgentDefinition |
handoff_* events |
threadId + agentId |
| Token tracking | none | usage |
ResultMessage.usage |
response.completed |
Usage |
| Cost tracking | — | — | total_cost_usd |
— | Usage.cost |
| Refusal | — | — | stop_reason: "refusal" |
refusal.delta/done |
ContentPart.refusal + StreamEnd.reason: 'refusal' |
| Session fork | — | — | forkSession |
— | ExecutionRequest.forkSession |
2.2 AgentDescriptor -- What an Agent IS
What it is: A static, serializable declaration of an agent's identity, capabilities, input/output schemas, tool declarations, and composability metadata. This is what everything reads WITHOUT running the agent.
Why we need it: Today, AgentDefinition (with LocalAgentDefinition and RemoteAgentDefinition) serves this role but is tightly coupled to the local execution model. We need a framework-agnostic version. SubagentTool, AgentRegistry, PolicyEngine, and the UI all consume descriptors.
Abhi's questions answered: #4 (composable agent requirements), #6 (tool isolation declaration)
Existing systems touched:
AgentDefinition/LocalAgentDefinition/RemoteAgentDefinition(replaced/extended)AgentRegistry(discovers and stores descriptors)SubagentTool(wraps descriptor as a tool)SubagentToolWrapper.createInvocation()(routes bykind-- becomesexecutor)- ADK
BaseAgentConfig(name, description, subAgents)
Proposed shape:
type ExecutorType =
| 'local' // gemini-cli's built-in executor (LocalAgentSession)
| 'adk' // ADK-TS Runner
| 'remote' // A2A protocol / remote agent
| 'openrouter' // OpenRouter model-as-agent
| 'browser' // Browser automation agent
| (string & {}); // extensible
interface AgentDescriptor {
/** Unique identifier. Must be a valid identifier (no spaces, special chars).
* (ADK: BaseAgent.name — validated as valid JS identifier) */
name: string;
/** Human-friendly display name */
displayName?: string;
/** One-line description for model routing and tool descriptions */
description: string;
/** Executor type — factory key for creating the AgentSession.
* (See NOTES-adk-alignment.md §1 — ADK agents always use 'adk') */
executor: ExecutorType;
/** Input schema (JSON Schema). What the agent accepts. */
inputSchema: JsonSchema;
/** Output schema. What the agent produces. */
outputSchema?: JsonSchema;
/** Declared capabilities */
capabilities?: AgentCapabilities;
/** Tools the agent brings with it (not inherited from parent) */
ownTools?: ToolDescriptor[];
/** Tools the agent requires from the host */
requiredTools?: string[];
/** Sub-agent descriptors (for composite agents) */
subAgents?: AgentDescriptor[];
/** Execution constraints */
constraints?: AgentConstraints;
/** Metadata for discovery, trust, versioning */
metadata?: AgentMetadata;
}
/**
* Open capability declaration. Not a fixed set of booleans.
* Agents declare what they support; the host reads and adapts.
* New capabilities can be added without changing the interface.
*
* Common capabilities (conventions, not enforced):
* 'elicitation' — can handle user questions mid-stream
* 'pause_resume' — can be paused and resumed
* 'cancellation' — can be cancelled mid-execution
* 'multi_turn' — can receive messages while running
* 'host_tool_execution' — accepts tool results from the host
* 'stream_end_events' — emits stream_end events (completed/failed/etc.)
* 'streaming' — can stream partial content
* 'mid_stream_update' — supports session.update() for mid-stream config/content
*
* Using a Set<string> instead of boolean fields means:
* - New capabilities don't require interface changes
* - Agents from different frameworks can declare framework-specific caps
* - The host can check: descriptor.capabilities?.has('elicitation')
*/
type AgentCapabilities = string[];
// Used as: descriptor.capabilities?.includes('elicitation')
// Array instead of Set because descriptors must be JSON-serializable.
interface AgentConstraints {
maxTurns?: number;
maxTimeMinutes?: number;
maxLlmCalls?: number;
/** Maximum spend in USD. From Claude SDK's maxBudgetUsd — increasingly
* standard for production deployments.
* (See NOTES-cross-sdk-comparison.md §4.2) */
maxBudgetUsd?: number;
}
interface AgentMetadata {
/** File path where agent was discovered */
filePath?: string;
/** Content hash for trust verification */
hash?: string;
/** Whether agent is experimental */
experimental?: boolean;
/** Semver version of the agent */
version?: string;
/** Interface version this agent targets */
interfaceVersion?: string;
}
Key design questions:
- How does
executor: stringreplacekind: 'local' | 'remote'? TodaySubagentToolWrapper.createInvocation()has a hard-codedif/elsechain. The factory pattern usesexecutoras a registry key. Registering a new executor type requires no code changes to the wrapper. - ownTools vs requiredTools:
ownToolsare tools the agent brings (e.g., an ADK agent with custom functions).requiredToolsare tools the agent expects the host to provide (e.g., "I need file_read"). The host decides which to grant. This answers Abhi's question #6 about tool isolation. - How does this relate to ADK's
BaseAgentConfig? An adapter mapsBaseAgentConfig+LlmAgentConfigtoAgentDescriptor. The ADK agent'stools,subAgents,name,descriptionall have direct mappings. - Serialization: Descriptors must be JSON-serializable for agent cards, registry storage, and A2A protocol. No functions or class instances.
2.3 AgentSession -- The Runtime Handle
What it is: The stateful runtime handle for an agent execution. Created by a factory, it provides four methods: stream() to start/continue execution, update() for mid-stream config changes, steer() for mid-stream interventions, and abort() for cancellation.
Why we need it: This is the primary abstraction that all agent backends implement. The host interacts with LocalAgent, ADK Runner, OpenRouter, and any future backend through this single interface. Without it, switching backends requires code changes throughout the host.
Abhi's questions answered: #1 (send messages to running agent), #2 (steering with function response vs user input), #3 (persist/restart sessions)
Existing systems touched:
LocalAgentSession(becomes the 'local' session implementation)- ADK
Runner.runAsync()(wrapped by 'adk' session) SubagentToolWrapper.createInvocation()(factory creates sessions byexecutorkey)GeminiChat(used internally by local session)- Dewitt's
Agent<TInput, TOutput>withrunAsync()/runEphemeral()(#22097)
Proposed shape:
/**
* The runtime handle for an agent execution.
*
* Four methods:
* stream() — start or continue: returns event stream + controller
* update() — mid-stream: change session config while running
* steer() — mid-stream: intervene in the running generation
* abort() — cancel: stop the current turn or entire execution
*
* Created by SessionFactory.create(). Aligns with Michael's AgentSession
* and subsumes Dewitt's Agent.runAsync() / runEphemeral() (#22097).
*
* Key pattern: stream() is used for BOTH starting AND continuing.
* The stream ends when the agent needs input (tool result, elicitation).
* Call stream() again with that input to get the next stream.
* This matches ADK (call runAsync() again) and Michael (call send() again).
*/
interface AgentSession {
/** Start or continue execution. Returns event stream + controller.
* First call: pass initial input and options to start.
* Subsequent calls: pass tool results, elicitation responses, or
* follow-up messages to continue after the previous stream ended.
*
* ADK: runner.runAsync() — call again with new content to continue.
* Michael: agent.send(session, data) → AgentResponse.
* Dewitt: agent.runAsync(input) (start only).
* Codex: thread.runStreamed(prompt). */
stream(data: SessionStreamData, options?: ExecutionOptions): AgentResponse;
/** Change session config mid-stream. Fire-and-forget — does NOT affect
* the event stream directly. Takes effect immediately.
* (Aligns with Michael's AgentSend.update / AgentSession.update()) */
update(config: SessionUpdateData): void;
/** Intervene in the running generation. Fire-and-forget.
* Can inject content (redirect the model) or trigger actions
* (move to background, etc.). Broader than just content injection.
* (Extends Michael's AgentTurnController.steer()) */
steer(data: SteerData): void;
/** Cancel the current turn or entire execution.
* (Aligns with Michael's AgentSession.abort()) */
abort(reason?: string): void;
}
/**
* Returned by stream(). Separates the event stream (read channel)
* from per-turn control (write channel).
* (Aligns with Michael's AgentResponse = { stream, controller })
*/
interface AgentResponse {
/** The event stream. Yields events until the agent needs input
* (tool result, elicitation) or execution completes. */
stream: AsyncGenerator<AgentEvent>;
/** Per-turn control. */
controller: TurnController;
}
/** Per-turn control channel. */
interface TurnController {
/** Abort the current turn (model stops generating), but the session
* stays alive for the next stream() call. Distinct from session.abort()
* which terminates the entire execution. */
abort: () => void;
}
/** What can be passed to stream() to start or continue execution.
* Same data for starting (message) and continuing (tool result, elicitation). */
type SessionStreamData =
| { kind: 'message'; content: string | ContentPart[] }
| { kind: 'tool_result'; requestId: string; result: ToolResultData }
| { kind: 'elicitation_response'; requestId: string; response: ElicitationResponseData };
/** Mid-stream session config changes. Fire-and-forget via update(). */
interface SessionUpdateData {
/** Change permission mode mid-session. */
permissionMode?: PermissionMode;
/** Switch model mid-session (fallback, user override). */
model?: string;
/** Enable/disable tools mid-session (MCP connect/disconnect). */
tools?: ToolDescriptor[];
/** Human-friendly session title. */
title?: string;
/** Catch-all for anything else. */
config?: Record<string, unknown>;
}
/** Mid-stream intervention. Fire-and-forget via steer().
* Can inject content OR trigger actions — not just messages.
* Examples: redirect model, move to background (Ctrl+B),
* pause/resume tool execution. */
interface SteerData {
/** Content to inject into the running generation. */
message?: string | ContentPart[];
/** Action to trigger mid-stream. */
action?: SteerAction;
/** Action-specific data. */
data?: Record<string, unknown>;
}
type SteerAction =
| 'background' // Move execution to background (Ctrl+B)
| (string & {}); // extensible
interface ExecutionOptions {
/** Tool descriptors available to this execution. */
tools?: ToolDescriptor[];
/** Model override. */
model?: string;
/** Whether host wants to execute tools (agent pauses on tool calls).
* (ADK: RunConfig.pauseOnToolCalls) */
hostToolExecution?: boolean;
/** Streaming mode. */
streaming?: boolean;
/** Permission mode for this execution.
* Both gemini-cli (default/autoEdit/yolo/plan) and Claude SDK
* (default/acceptEdits/plan/dontAsk/bypassPermissions) have this concept. */
permissionMode?: PermissionMode;
/** Session to resume (opaque ID or full snapshot). */
sessionRef?: string | SessionSnapshot;
/** Branch from sessionRef instead of continuing in-place.
* From Claude SDK's forkSession — enables "what if" exploration. */
forkSession?: boolean;
/** Run without persisting session state.
* (Dewitt's runEphemeral — handled as option, not separate method) */
ephemeral?: boolean;
/** Telemetry context for distributed tracing. */
traceContext?: TraceContext;
/** Abort signal for cancellation propagation. */
signal?: AbortSignal;
}
type PermissionMode =
| 'default' // Normal — ask user for non-read-only tools
| 'auto_edit' // Auto-approve file edits (gemini-cli: autoEdit)
| 'autonomous' // Auto-approve all (gemini-cli: yolo, Claude: bypassPermissions)
| 'plan' // Read-only, no mutations (both gemini-cli and Claude)
| (string & {});
type ExecutionState =
| { status: 'running'; turnCount: number }
| { status: 'idle' } // Between turns — waiting for stream()
| { status: 'completed'; reason: StreamEndReason; data?: Record<string, unknown> }
| { status: 'error'; error: ErrorData }
| { status: 'aborted'; reason: string };
Example usage:
const session = factory.create(descriptor, context);
// Turn 1 — start with message
let { stream, controller } = session.stream(
{ kind: 'message', content: 'Fix the login bug' },
{ permissionMode: 'default', hostToolExecution: true }
);
for await (const event of stream) {
if (event.type === 'tool_request') {
// Host runs policy, executes tool...
const result = await executeTool(event);
// Stream ended — continue with tool result (Turn 2)
({ stream, controller } = session.stream(
{ kind: 'tool_result', requestId: event.requestId, result }
));
// Stream resumes in the same for-await loop
}
if (event.type === 'elicitation_request') {
const response = await showConfirmationUI(event);
({ stream, controller } = session.stream(
{ kind: 'elicitation_response', requestId: event.requestId, response }
));
}
// Meanwhile, mid-stream:
// session.update({ permissionMode: 'autonomous' }); // switch mode
// session.steer({ action: 'background' }); // Ctrl+B
// session.steer({ message: 'focus on tests' }); // redirect
// controller.abort(); // cancel this turn
}
// Loop ends when stream_end event is received
Key design decisions:
-
Four-method design —
stream(),update(),steer(),abort(): Each method has distinct semantics:stream()= start or continue execution, returns new event streamupdate()= change session config mid-stream (fire-and-forget)steer()= intervene in running generation (fire-and-forget)abort()= cancel
update()andsteer()are both mid-stream and fire-and-forget, but they affect different things:update()changes what the session is configured to do (model, permissions, tools),steer()changes what the agent is currently doing (inject content, trigger actions like background). -
stream()for both start and continue (no separaterun()orsend()): When the stream ends because the agent needs input (tool result, elicitation response), you callstream()again with that input to get the next stream. This matches:- ADK: call
runAsync()again with new content - Michael: call
send()again, get newAgentResponse - Codex: call
runStreamed()again
The
SessionStreamDataunion handles the "function response vs new user input" distinction (Abhi's #2):kind: 'tool_result'vskind: 'message'. - ADK: call
-
steer()is broader than content injection:steer()can inject content (message) OR trigger actions (action). Ctrl+B to move to background issteer({ action: 'background' }). Redirecting the model issteer({ message: "do X instead" }). TheSteerActiontype is open via(string & {})for future actions. -
Turn abort vs session abort:
controller.abort()aborts the current turn (model stops generating, session stays alive for the nextstream()).session.abort()terminates the entire execution. Michael's design has the same split. -
runEphemeralas option, not method: Dewitt'sAgenthas two methods:runAsync(stateful) andrunEphemeral(stateless). We handle this viaoptions.ephemeral: boolean. -
ExecutionOptionsonly on firststream()call: Options liketools,model,hostToolExecution,sessionRefare creation-time concerns. They're passed on the firststream()call. Subsequent calls only passSessionStreamData. Mid-session changes go throughupdate().
Relationship to prior work:
| Dewitt (#22097) | Michael (latest) | ADK-TS | Claude SDK | Codex SDK | This proposal | |
|---|---|---|---|---|---|---|
| Create | AgentFactory |
agent.loadSession() |
new Runner() |
N/A | codex.startThread() |
factory.create(descriptor) |
| Start | runAsync(input) |
session.send({message}) |
runner.runAsync() |
query({prompt}) |
thread.run() |
session.stream({message}, options) |
| Continue | N/A | session.send({elicitations}) |
runner.runAsync() again |
query({resume}) |
same thread | session.stream({tool_result}) |
| Mid-stream config | N/A | session.update(config) |
N/A | N/A | N/A | session.update({permissionMode}) |
| Mid-stream steer | N/A | controller.steer(msg) |
N/A | N/A | N/A | session.steer({message}) |
| Mid-stream action | N/A | N/A | N/A | N/A | N/A | session.steer({action: 'background'}) |
| Turn abort | N/A | controller.abort() |
N/A | N/A | N/A | controller.abort() |
| Session abort | AbortSignal | session.abort() |
N/A | N/A | N/A | session.abort() |
| Fork | N/A | N/A | N/A | forkSession: true |
N/A | options.forkSession |
| Ephemeral | runEphemeral() |
N/A | N/A | N/A | N/A | options.ephemeral |
2.4 SessionFactory -- Pluggable Agent Creation
What it is: A registry that maps executor: string types to factory functions that create AgentSession instances. Aligns with Michael's Agent.loadSession() as the creation entry point and Dewitt's AgentFactory pattern (#22097).
Why we need it: Today, SubagentToolWrapper.createInvocation() has hard-coded routing:
// CURRENT (subagent-tool-wrapper.ts lines 74-103)
if (definition.kind === 'remote') {
return new RemoteAgentInvocation(...);
}
if (definition.name === BROWSER_AGENT_NAME) {
return new BrowserAgentInvocation(...);
}
return new LocalSubagentInvocation(...);
This must become a pluggable factory keyed on descriptor.executor.
Abhi's questions answered: #4 (composable agent with minimal adjustment)
Existing systems touched:
SubagentToolWrapper.createInvocation()(replaced by factory lookup)AgentRegistry(provides descriptors to factory)- Dewitt's
LocalAgentSession.create()/AgentFactory(#22097)
Proposed shape:
interface SessionFactory {
/** Register a factory for an executor type. */
register(executorType: string, creator: SessionCreator): void;
/** Create a session for a descriptor. The returned session is NOT yet
* running — call session.stream() to start execution. */
create(descriptor: AgentDescriptor, context: HostContext): AgentSession;
/** Check if an executor type is registered. */
has(executorType: string): boolean;
}
type SessionCreator = (descriptor: AgentDescriptor, context: HostContext) => AgentSession;
interface HostContext {
/** Policy engine for tool call authorization */
policyEngine: PolicyEvaluator;
/** Hook system for lifecycle interception */
hookSystem: LifecycleInterceptor;
/** Elicitation channel for user interactions (tool confirmation, auth, etc.) */
elicitation: ElicitationChannel;
/** Tool registry for host-provided tools */
toolRegistry: ToolProvider;
/** Model configuration service */
modelConfig: ModelConfigProvider;
/** Session service for persistence */
sessionService: SessionProvider;
/** Abort signal from parent */
signal?: AbortSignal;
/** Telemetry context */
traceContext?: TraceContext;
}
Key design questions:
- Where does HostContext come from? The host (gemini-cli) assembles it from its services. Each executor implementation picks what it needs. ADK executor uses modelConfig, local executor uses everything.
- Lifecycle: Does the factory create one executor per invocation, or one per agent that handles multiple invocations? Per invocation is simpler and matches the current
createInvocation()pattern. - Registration timing: Executors are registered at startup. ADK executor registered when
@google/adkis available. OpenRouter executor registered when configured. Default local executor always registered.
2.5 ToolContract -- The Tool Execution Boundary
What it is: The interface between the host's tool system and the agent executor. Defines how tools are described, how calls are authorized, and how results flow back.
Why we need it: Tools are the primary action mechanism. The host owns policy enforcement, confirmation, and hooks. The agent only declares intent to call a tool. This boundary must be clean regardless of which executor is running.
Abhi's questions answered: #6 (tool isolation for subagents)
Existing systems touched:
DeclarativeTool/BaseDeclarativeTool(host-side tool implementation)ToolRegistry(host-side tool discovery and provisioning)PolicyEngine.evaluate()(authorization at the boundary)CoreToolScheduler(validation, approval, scheduling, execution)ToolCallRequestInfo/ToolCallResponseInfo(scheduler types)ToolResult(llmContent + displayContent + error)- ADK
BaseTool/FunctionTool(agent-side tools)
Proposed shape:
/** What a tool looks like to the agent (declaration only, no execution) */
interface ToolDescriptor {
name: string;
displayName?: string;
description: string;
/** JSON Schema for parameters */
parametersSchema: JsonSchema;
/** Hints for policy and UI */
annotations?: ToolAnnotations;
}
interface ToolAnnotations {
/** Open key-value hints for policy and UI.
* Not an enum — hosts and tools define their own vocabulary.
*
* Common conventions (not enforced by interface):
* readOnly: boolean — tool only reads, never mutates
* destructive: boolean — tool may cause irreversible changes
* longRunning: boolean — tool may take significant time
* idempotent: boolean — safe to retry
* category: string — tool category for grouping
*
* Hosts interpret these for policy and UI. Unrecognized
* annotations are preserved and passed through. */
[key: string]: unknown;
}
/** Tool call request from agent to host */
interface ToolCallRequest {
/** Correlation ID for matching request to response */
requestId: string;
/** Tool name */
name: string;
/** Tool arguments */
args: Record<string, unknown>;
}
/** Tool call result from host to agent */
interface ToolResultData {
/** Content for the model (what goes into conversation history) */
llmContent: ContentPart[];
/** Content for the user (what gets displayed) */
displayContent?: ContentPart[];
/** Whether the tool encountered an error */
isError?: boolean;
/** Structured error details */
errorType?: string;
/** Tail calls: additional tool calls triggered by this result */
tailCalls?: ToolCallRequest[];
}
/** Host-side tool provider interface */
interface ToolProvider {
/** Get all available tool descriptors for an agent */
getToolsForAgent(agentName: string): ToolDescriptor[];
/** Execute a tool call (after policy check) */
execute(request: ToolCallRequest, context: ToolExecutionContext): Promise<ToolResultData>;
/** Check if a tool call is allowed (policy + confirmation) */
authorize(request: ToolCallRequest): Promise<ToolAuthorizationResult>;
}
/** Reuses PolicyDecision — tool authorization IS policy evaluation */
type ToolAuthorizationResult = PolicyDecision;
Key design questions:
- Agent-owned tools: When a sub-agent brings its own tools (
descriptor.ownTools), who executes them? Option A: Agent executes internally (bypasses policy). Option B: Agent declares them, host wraps them with policy. Recommendation: Option B for safety. The host createsDeclarativeToolwrappers around agent-declared tools, applying policy. - Tool namespacing: Agent-owned tools should be namespaced:
agentName.toolName. This prevents conflicts and enables per-agent policy rules. Mirrors MCP'smcp_server_toolpattern. - Tail calls: gemini-cli's
ToolResult.tailCalltriggers additional tool calls from a tool result. Does this concept cross the interface boundary? Yes -- the host may need to execute tail calls before sending the result to the agent. - Long-running tools: How does
ToolUpdateEvent(progress) relate to theToolProvider? The host emitsToolUpdateEventwhileexecute()is in progress. This could be via a callback or by havingexecute()return anAsyncGenerator<ToolUpdateEvent, ToolResultData>.
3. P1 Interfaces
Define after P0 is stable. These depend on the event contract and executor interface.
3.1 PolicyEvaluator -- Host-Enforced Authorization
What it is: The interface through which any executor can query the host's policy engine. The agent has NO say in policy -- it is entirely host-enforced.
Why we need it: Every tool call must be checked. External agents (ADK, OpenRouter) must not bypass policy. The current PolicyEngine is an implementation detail; we need an interface that executors call.
Abhi's questions answered: #6 (tool isolation is a policy concern)
Existing systems touched:
PolicyEngine(TOML-based, 5-tier priority system)CoreToolScheduler(calls policy before execution)- ADK
SecurityPlugin/BasePolicyEngine(simpler: DENY/CONFIRM/ALLOW) - Approval modes (default, autoEdit, yolo, plan)
Proposed shape:
interface PolicyEvaluator {
/** Evaluate an action against policy. Generic — not just for tools.
* Could evaluate tool calls, agent invocations, file access, etc. */
evaluate(request: PolicyRequest): Promise<PolicyDecision>;
/** Get actions statically denied (for pre-filtering without args). */
getExcluded(scope?: string): string[];
}
interface PolicyRequest {
/** What is being evaluated. Open string, not an enum.
* Examples: tool name, agent name, resource path */
action: string;
/** Arguments/context for the action (for conditional policies) */
args?: Record<string, unknown>;
/** Who is requesting. Could be agent name, user ID, etc. */
principal?: string;
/** Nesting path for hierarchical policy inheritance */
principalPath?: string[];
/** Additional context for policy evaluation.
* Hosts put implementation-specific context here
* (MCP server name, tool annotations, approval mode, etc.) */
context?: Record<string, unknown>;
}
/** Known policy outcomes + open extension.
* Maps to ADK SecurityPlugin's PolicyOutcome (ALLOW/DENY/CONFIRM)
* and gemini-cli's TOML policy decisions.
* (See NOTES-adk-alignment.md §6) */
type PolicyOutcome =
| 'allow' // ADK: PolicyOutcome.ALLOW, gemini-cli: allow
| 'deny' // ADK: PolicyOutcome.DENY, gemini-cli: deny
| 'ask_user' // ADK: PolicyOutcome.CONFIRM → requestConfirmation(), gemini-cli: ask_user
| (string & {});
type PolicyDecision =
| { outcome: 'allow'; message?: string; _meta?: Record<string, unknown> }
| { outcome: 'deny'; reason: string; _meta?: Record<string, unknown> }
| { outcome: 'ask_user'; hint?: string; _meta?: Record<string, unknown> }
| { outcome: PolicyOutcome; _meta?: Record<string, unknown>; [key: string]: unknown };
Why generic: The interface uses action (open string) instead of toolName, and context (open record) instead of specific fields like mcpServer or approvalMode. This means:
- gemini-cli's TOML policy engine implements this with its 5-tier system, putting
mcpServerandapprovalModeincontext - ADK's SecurityPlugin implements this with its simpler DENY/CONFIRM/ALLOW
- Future systems can add their own context without changing the interface
Key design questions:
- Policy for opaque agents: If an ADK agent calls a tool internally (with
pauseOnToolCalls: false), policy is bypassed. The host should mandatehostToolExecution: truefor safety-critical environments. Trust boundary is at the host. - Agent-level policy: Policy can evaluate agents themselves (action = agent name), not just tools. The existing gemini-cli pattern (agents are tools for policy purposes) works.
- Policy inheritance for sub-agents: A sub-agent inherits the parent's policy context. Additional restrictions can be layered. The
principalPathfield enables depth-aware rules. - Extensibility: New policy dimensions (rate limiting, cost budgets, data classification) are just new fields in
context— no interface changes needed.
3.2 LifecycleInterceptor -- Lifecycle Interception
What it is: The interface through which executors notify the host about lifecycle events, enabling hooks to fire regardless of which backend runs.
Why we need it: gemini-cli's 11 hook types must work regardless of backend. When ADK controls the model, hooks still work because AdkGeminiModel wraps GeminiChat. But for fully opaque agents, hooks can only fire if the executor delegates lifecycle events back to the host.
Abhi's questions answered: #5 (events for UX -- hooks are a key UX mechanism)
Existing systems touched:
HookSystem(coordinator, registry, runner, aggregator)- All 11 hook types
AdkGeminiModel(dewitt's adapter that preserves hook injection points)
Proposed shape:
/**
* Generic lifecycle interception. NOT a fixed set of named hooks.
* The host defines what hook points exist; executors fire the ones
* they support. New hook points can be added without changing the interface.
*/
/** Known hook points from gemini-cli (11) + ADK plugins (12).
* (See NOTES-adk-alignment.md §5 for complete mapping) */
type HookPoint =
// ADK plugin callbacks
| 'before_agent' | 'after_agent'
| 'before_model' | 'after_model'
| 'before_tool' | 'after_tool'
| 'on_event' | 'on_user_message'
| 'before_run' | 'after_run'
| 'on_model_error' | 'on_tool_error'
// gemini-cli hooks (additional)
| 'before_tool_selection' | 'notification'
| 'session_start' | 'session_end' | 'pre_compress'
| (string & {});
interface LifecycleInterceptor {
/**
* Fire a lifecycle hook by name.
* @param hookPoint - Open string: 'before_model', 'after_tool', 'session_start', etc.
* @param payload - Hook-specific data (open shape)
* @returns Hook result: proceed, block, or modify
*/
fire(hookPoint: HookPoint, payload: Record<string, unknown>): Promise<HookResult>;
/**
* Check which hook points the host supports.
* Executors can query this to know what to fire.
*/
supportedHookPoints(): HookPoint[];
}
type HookResult = {
/** Should execution continue? */
action: 'proceed' | 'block';
/** Why blocked (if action is 'block') */
reason?: string;
/** Modifications to apply (hook-point-specific, open shape).
* For 'before_model': might contain modified request.
* For 'before_tool': might contain modified args.
* For 'after_tool': might contain additional context.
* The executor interprets this per hook point. */
modifications?: Record<string, unknown>;
/** Message for the user (not sent to the model) */
systemMessage?: string;
/** Extensibility */
_meta?: Record<string, unknown>;
};
Why generic: Instead of separate typed methods (beforeModel, afterTool, etc.), we use a single fire() with an open hookPoint string. This means:
- gemini-cli's 11 hook types are just 11 different
hookPointvalues - ADK's plugin callbacks (14 of them) can map to hook points
- New hook points can be added by either side without interface changes
- Executors declare which hook points they fire; the host declares which it listens to
Key design questions:
- Opaque agent hooks: When an OpenRouter agent runs its own model,
before_model/after_modelhooks CANNOT fire. This is a known limitation. Executors only fire what they support. The host can querysupportedHookPoints()and warn users about gaps. - Hook result semantics: The meaning of
modificationsdepends on thehookPoint. Forbefore_modelit might contain a modified request or injected response. Forafter_toolit might contain additional context. This is intentionally open — implementations define the vocabulary. - Type safety: We sacrifice some static type safety for extensibility. Implementations can layer typed wrappers on top of the generic interface for their own hook points. The interface itself stays open.
- Ordering: Hook execution order is a host concern. The interface doesn't specify ordering — the host's
LifecycleInterceptorimplementation handles that internally.
3.3 Elicitation -- Generic User Interaction Protocol
What it is: A single, generic interface for ALL cases where an agent needs input from the user. This covers tool confirmations, model fallback prompts, clarification questions, auth requests, and any future user interaction we haven't imagined yet.
Design philosophy: Elicitation is NOT specific to tool confirmation. It's a general suspension-and-resume mechanism. The coworker is designing the event-level representation; this section defines the host-side channel through which elicitations are mediated.
Why we need it: Today gemini-cli has multiple parallel mechanisms for user interaction: MessageBus confirmations, consent requests via CoreEventEmitter, model fallback dialogs, and ask_user tool calls. These should all flow through one generic protocol. ADK has requestedToolConfirmations and requestCredential. AG-UI uses "frontend tools." All of these are specific incarnations of the same pattern: agent suspends, asks user something, user responds, agent resumes.
Abhi's questions answered: #1 (send messages to running agent -- elicitations are the primary mechanism), #5 (events for UX)
Existing systems touched:
MessageBus(pub/sub for TOOL_CONFIRMATION_REQUEST/RESPONSE)ToolCallConfirmationDetails(6 confirmation types — these become elicitationkindvalues)ToolConfirmationOutcome(7 outcomes — these become response payload values)CoreEventEmitter.ConsentRequest(model fallback)- ADK
ToolConfirmation,requestCredential(auth elicitation) - AG-UI frontend tools pattern
Proposed shape:
/**
* Generic elicitation: agent asks user something, user responds.
* The `kind` and `requestedSchema` are open — not an enum.
* This makes elicitation extensible to any future interaction type
* without changing the interface.
*/
/** Known elicitation kinds + open extension.
* ADK: tool_confirmation (requestedToolConfirmations), auth_required (requestedAuthConfigs)
* gemini-cli: tool_confirmation (6 ConfirmationTypes), model_fallback (ConsentRequest)
* Claude SDK: tool_confirmation (canUseTool callback)
* OpenAI: tool_confirmation (mcp_approval_requested) */
type ElicitationKind =
| 'tool_confirmation' // Tool needs user approval before executing
| 'model_fallback' // Model unavailable, user picks alternative
| 'auth_required' // Tool/agent needs credentials
| 'clarification' // Agent needs more info from user
| 'user_approval' // Generic approval (non-tool)
| (string & {});
type ElicitationDisplay =
| 'inline' // Show in the message stream (e.g., inline confirmation)
| 'modal' // Show as a standalone dialog box
| (string & {});
interface ElicitationRequest {
/** Unique ID for correlating request to response */
requestId: string;
/** What kind of elicitation this is.
* Known kinds cover gemini-cli confirmations, ADK ToolConfirmation
* and auth requests, Claude SDK canUseTool, and OpenAI mcp_approval.
* (See NOTES-adk-alignment.md §2 for ADK mapping) */
kind: ElicitationKind;
/** How to display the elicitation.
* (From Michael's ElicitationRequest.display) */
display?: ElicitationDisplay;
/** Human-readable message to display */
message: string;
/** Optional title for the elicitation UI */
title?: string;
/** JSON Schema describing what the response should look like.
* This makes the elicitation self-describing — the UI can
* dynamically render the right input controls. */
requestedSchema?: JsonSchema;
/** Structured context for the UI to render (open shape).
* For tool confirmations: tool name, args, diff preview.
* For model fallback: available models, error reason.
* For auth: provider, scopes, redirect URL.
* The host implementation interprets this per `kind`. */
context?: Record<string, unknown>;
/** Extensibility */
_meta?: Record<string, unknown>;
}
type ElicitationAction =
| 'accept' // User approved (ADK: confirmed=true, Claude: behavior='allow')
| 'decline' // User rejected (ADK: confirmed=false, Claude: behavior='deny')
| 'cancel' // User dismissed without choosing
| (string & {});
interface ElicitationResponse {
/** Correlates to the request */
requestId: string;
/** User's response action.
* ADK: confirmed=true → 'accept', confirmed=false → 'decline'
* Claude SDK: behavior='allow' → 'accept', behavior='deny' → 'decline' */
action: ElicitationAction;
/** The user's response data, validated against requestedSchema.
* For tool confirmation: might contain modified args.
* For model fallback: might contain selected model.
* For auth: might contain credentials/tokens.
* Shape is defined by requestedSchema. */
content?: Record<string, unknown>;
/** Extensibility */
_meta?: Record<string, unknown>;
}
Why this is better than separate Confirmation + Elicitation interfaces:
- Tool confirmation IS an elicitation (kind: 'tool_confirmation')
- Model fallback IS an elicitation (kind: 'model_fallback')
- Auth request IS an elicitation (kind: 'auth_required')
- Ctrl+B interrupt IS an elicitation (kind: 'interrupt')
- Any future user interaction is just a new
kind— no interface changes needed requestedSchemamakes each kind self-describing — the UI can render dynamically
How gemini-cli's current 7 confirmation outcomes map:
They become response content values, not a fixed enum. For example:
// kind: 'tool_confirmation'
// requestedSchema describes: { outcome: string, modifiedArgs?: ... }
// Response content: { outcome: 'proceed_always', scope: 'server' }
The host implementation interprets content.outcome per its own vocabulary. The interface doesn't need to know about "proceed_always_save" vs "proceed_always_server."
Key design questions:
- Blocking vs event-based: The executor pauses its generator, emits an
ElicitationRequestevent via the event stream, and the host callssend(executionId, { kind: 'elicitation_response', ... })when the user answers. The executor resumes. This means elicitation is just an event + send pattern, not a separate channel. - Multiplexing: Multiple elicitations can be outstanding (parallel tool calls each needing confirmation).
requestIdcorrelates them. - Timeout: If the user never responds, the executor can treat it as
action: 'cancel'after a configurable timeout. The_metafield can carry timeout hints. - Progressive disclosure: Can the host ask follow-up questions? Yes — multiple elicitation rounds are just multiple request/response cycles. The
kinddistinguishes them.
3.4 ModelInterface -- Provider-Agnostic LLM Access
What it is: The interface for making LLM calls, abstracted away from @google/genai types.
Why we need it: Dewitt's Model interface uses @google/genai types directly (PartListUnion, GenerateContentResponse). For OpenRouter or other providers, we need provider-agnostic types.
Abhi's questions answered: Indirectly #2 (model steering depends on model interface)
Existing systems touched:
GeminiChat(current model interaction)AdkGeminiModel(dewitt's adapter)ModelConfigService(hierarchical aliases)ModelRouterService(strategy chain)ModelAvailabilityService(health states)- ADK
BaseLlm/Gemini(abstract model class)
Proposed shape:
interface ModelInterface {
/** Generate content (non-streaming) */
generate(request: ModelRequest): Promise<ModelResponse>;
/** Generate content (streaming) */
generateStream(request: ModelRequest): AsyncGenerator<ModelResponse>;
}
interface ModelRequest {
/** System instruction */
systemInstruction?: ContentPart[];
/** Conversation history */
messages: ModelMessage[];
/** Available tools */
tools?: ToolDescriptor[];
/** Tool calling mode */
toolMode?: 'auto' | 'any' | 'none';
/** Generation config */
config?: ModelGenerationConfig;
}
interface ModelMessage {
role: 'user' | 'model' | 'system';
content: ContentPart[];
}
interface ModelResponse {
content: ContentPart[];
toolCalls?: ToolCallRequest[];
/** Token usage for this response */
usage?: { inputTokens?: number; outputTokens?: number; cachedTokens?: number };
/** Raw provider-specific response (escape hatch) */
_raw?: unknown;
}
interface ModelGenerationConfig {
temperature?: number;
topP?: number;
topK?: number;
maxOutputTokens?: number;
stopSequences?: string[];
thinkingConfig?: { thinkingBudget?: number; includeThoughts?: boolean };
}
Key design questions:
- Who owns the Model interface? The executor, NOT the host. The host provides model configuration (which model, generation params), but the executor creates the actual model connection. For the local executor, this is GeminiChat. For ADK, this is BaseLlm. For OpenRouter, it's an HTTP client.
- Can the host inject a model? Yes, via the HostContext. The ADK executor uses the host-provided model (AdkGeminiModel). The local executor uses GeminiChat. OpenRouter uses its own model.
- Provider-agnostic types: We define
ContentPart,ModelMessage,ModelResponsewithout@google/genaitypes. Adapters translate at the boundary. This is a breaking change from dewitt's interface. - Model routing: Is routing part of the model interface? No. Model routing (which model to use) is a host concern. The executor receives a resolved model name/endpoint.
4. P2 Interfaces
Important but can follow P0/P1. These extend the core interfaces for specific capabilities.
4.1 SessionContract -- Persistence and Resumption
What it is: How agent sessions are persisted, loaded, and resumed across process boundaries.
Why we need it: Abhi's question #3 asks about persisting/restarting agent sessions. Currently, gemini-cli uses ChatRecordingService (JSON files) and ADK uses BaseSessionService (in-memory or DB). We need a unified contract.
Abhi's questions answered: #3 (persist/restart sessions)
Existing systems touched:
ChatRecordingService(gemini-cli session persistence)ChatCompressionService(history summarization)- ADK
Session/BaseSessionService/InMemorySessionService/DatabaseSessionService - Coworker's
Trajectoryconcept
Proposed shape:
interface SessionProvider {
/** Create a new session */
create(agentName: string, metadata?: Record<string, unknown>): Promise<SessionHandle>;
/** Load an existing session */
load(sessionId: string): Promise<SessionHandle | undefined>;
/** List sessions for an agent */
list(agentName?: string): Promise<SessionSummary[]>;
/** Delete a session */
delete(sessionId: string): Promise<void>;
}
interface SessionHandle {
id: string;
agentName: string;
/** Replay events to reconstruct state (trajectory concept) */
events: AgentEvent[];
/** Key-value state */
state: Record<string, unknown>;
/** Append an event to the session */
append(event: AgentEvent): Promise<void>;
/** Get a snapshot for serialization */
snapshot(): SessionSnapshot;
/** Timestamp of last activity */
lastUpdateTime: number;
}
interface SessionSnapshot {
id: string;
agentName: string;
events: AgentEvent[];
state: Record<string, unknown>;
}
interface SessionSummary {
id: string;
agentName: string;
title?: string;
lastUpdateTime: number;
eventCount: number;
}
Key design questions:
- Trajectory vs session: The coworker's
Trajectoryis an append-only event log from which all state can be derived. This is powerful (event sourcing) but expensive for long sessions. Pragmatic approach: store events + materialized state, but support trajectory replay. - Cross-executor sessions: Can a session started with the local executor be resumed with ADK? In theory yes, if the event format is the same. In practice, executor-specific state may not transfer. The interface should support it; implementations may restrict it.
- Compression: When sessions grow too large, gemini-cli compresses history. This is executor-internal. The SessionHandle doesn't expose compression, but the executor may emit a
CustomEventnoting that compression occurred.
4.2 AgentComposition -- Multi-Agent Topologies
What it is: How agents compose into hierarchies, parallel groups, sequential chains, and loops.
Why we need it: ADK has LoopAgent, ParallelAgent, SequentialAgent. gemini-cli has SubagentTool (hierarchical only). We need a composition model that works across executor types.
Abhi's questions answered: #4 (composable agent as subagent)
Existing systems touched:
SubagentTool/SubagentToolWrapper(agent-as-tool pattern)- ADK
LoopAgent,ParallelAgent,SequentialAgent(composition primitives) - ADK agent transfer (
transfer_to_agenttool,eventActions.transferToAgent) - gemini-cli recursion prevention (agents can't call other agents)
Proposed shape:
type CompositionPattern =
| 'hierarchical' // Parent calls children as tools (ADK: any agent with subAgents)
| 'sequential' // Children run in order (ADK: SequentialAgent)
| 'parallel' // Children run concurrently (ADK: ParallelAgent, branch isolation)
| 'loop' // Children repeat until exit (ADK: LoopAgent, escalate to exit)
| 'transfer' // Peer-to-peer handoff (ADK: transfer_to_agent tool)
| (string & {});
/** How a descriptor declares its composition pattern */
interface CompositionConfig {
/** Composition pattern.
* Maps to ADK: hierarchical=subAgents, sequential=SequentialAgent,
* parallel=ParallelAgent, loop=LoopAgent, transfer=transfer_to_agent tool.
* (See NOTES-adk-alignment.md §9) */
pattern: CompositionPattern;
/** Sub-agents in this composition */
children: AgentDescriptor[];
/** For loops: max iterations */
maxIterations?: number;
/** For transfers: which agents can be transferred to */
transferTargets?: string[];
/** For hierarchical: recursion depth limit */
maxDepth?: number;
}
/** Events for composition lifecycle */
interface CompositionEvents {
/** A sub-agent started */
agent_started: { agentName: string; threadId: string; parentThreadId?: string };
/** A sub-agent completed */
agent_completed: { agentName: string; threadId: string; result: unknown };
/** Agent-to-agent transfer */
agent_transfer: { from: string; to: string; reason?: string };
/** Loop iteration */
loop_iteration: { iteration: number; maxIterations: number };
}
Key design questions:
- Transfers across executor types: Can an ADK agent transfer to a local agent? This requires the host to mediate. The host receives a
transferevent, looks up the target in the registry, creates a new executor via the factory, and starts it. - Parallel agent event merging: When parallel sub-agents produce events concurrently, how does the host merge them? By
threadId. Each parallel branch gets a unique threadId. The host can interleave events from different threads. - Recursion prevention: gemini-cli prevents agents from calling other agents (to avoid infinite recursion). With composition, we need a depth limit instead.
maxDepth: 3means an agent tree can be at most 3 levels deep. - Context isolation: Parallel agents in ADK have
branchisolation (don't see peer history). How does this work when agents are in different executors? Each executor gets its own session context. The host manages cross-branch visibility.
4.3 ContentPart -- Provider-Agnostic Content Model
What it is: The content model used throughout the interface layer, replacing @google/genai Part types.
Why we need it: Every interface above references ContentPart. It must be rich enough for text, thoughts, media, code, and references, but not tied to any provider.
Proposed shape:
/**
* Provider-agnostic content model.
* (See NOTES-cross-sdk-comparison.md §6 for superset analysis)
*
* `_meta` on every ContentPart (aligns with Michael's design) enables
* framework-specific metadata without changing the type union.
*
* `refusal` added based on both Claude SDK (stop_reason: "refusal")
* and OpenAI (response.refusal.delta/done) — model refusals are
* operationally important and both major providers surface them.
*/
/** Known content part types + open extension.
* Maps to ADK Part types (see NOTES-adk-alignment.md §8):
* text → TextPart, thought → TextPart{thought:true},
* media → InlineDataPart, function_call → FunctionCallPart, etc. */
type ContentPartType =
| 'text' | 'thought' | 'media' | 'reference' | 'refusal'
| 'code' | 'code_result'
| 'function_call' | 'function_response'
| (string & {});
type ContentPart = (
| { type: 'text'; text: string }
| { type: 'thought'; thought: string; thoughtSignature?: string }
| { type: 'media'; mimeType: string; data?: string; uri?: string }
| { type: 'reference'; text: string; uri?: string; mimeType?: string; data?: string }
| { type: 'refusal'; text: string }
| { type: 'code'; language: string; code: string }
| { type: 'code_result'; output: string; exitCode?: number }
| { type: 'function_call'; name: string; args: Record<string, unknown>; id: string }
| { type: 'function_response'; name: string; response: unknown; id: string }
| { type: ContentPartType; [key: string]: unknown } // catch-all for unknown types
) & {
/** Per-part metadata — aligns with Michael's ContentPart._meta design */
_meta?: Record<string, unknown>;
};
Key design questions:
- Should
function_call/function_responsebe ContentParts or separate event types? They appear in both places in different systems. In@google/genai, they're Parts. In our event model, they're separate events. Recommendation: keep them as separate events (ToolRequestEvent,ToolResponseEvent) but allowfunction_call/function_responseContentParts for serialization into model history. - Media handling: Large media (images, PDFs) shouldn't be embedded in events. The
urifield enables lazy loading. But how do URIs work across process boundaries? For local executors, file:// URIs. For remote, https:// URIs or data: URIs.
4.4 TelemetryBridge -- Observability Across Agent Boundaries
What it is: How telemetry (tracing, metrics, logging) propagates across agent boundaries and executor types.
Existing systems touched:
logAgentStart,logAgentFinish,logRecoveryAttempt(gemini-cli telemetry)- ADK OpenTelemetry integration (
@opentelemetry/apiin base_agent.ts) CoreEventEmitterevents (UserFeedback, ModelChanged, etc.)
Proposed shape:
interface TraceContext {
/** OpenTelemetry trace ID */
traceId: string;
/** Parent span ID */
parentSpanId?: string;
/** Baggage items */
baggage?: Record<string, string>;
}
interface TelemetryBridge {
/** Start a span for an agent execution */
startAgentSpan(agentName: string, context?: TraceContext): TelemetrySpan;
/** Record a tool call within a span */
recordToolCall(span: TelemetrySpan, toolName: string, args: Record<string, unknown>): void;
/** Record a model call */
recordModelCall(span: TelemetrySpan, model: string, usage: UsageData): void;
/** Record an error */
recordError(span: TelemetrySpan, error: Error): void;
}
interface TelemetrySpan {
/** Create a child span (for sub-agents) */
createChild(name: string): TelemetrySpan;
/** End the span */
end(status?: 'ok' | 'error'): void;
/** Get context for propagation to child executors */
getContext(): TraceContext;
}
Key design questions:
- Standards: Use OpenTelemetry natively? ADK already does. gemini-cli has custom telemetry. Recommendation: define our interface in terms of OTel concepts, but don't require OTel as a dependency at the interface level.
- Privacy: Telemetry should not include user content or tool arguments by default. Only sanitized identifiers and metrics.
4.5 Interface Versioning -- Backward Compatibility
What it is: How we version these interfaces so that agents built against v1 continue to work when the host upgrades to v2.
Proposed approach:
/** Every interface file exports a version constant */
const INTERFACE_VERSION = '1.0.0';
/** Descriptors declare which version they target */
interface AgentMetadata {
interfaceVersion?: string; // e.g., '1.0.0'
}
/** The host checks compatibility at registration time */
function isCompatible(agentVersion: string, hostVersion: string): boolean {
// Semver: major must match, agent minor <= host minor
}
Key design questions:
- Granularity: Version the entire interface layer as one unit, or version each interface independently? One version is simpler and what we should start with.
- Breaking changes: How do we handle breaking changes? A major version bump. The host can support multiple major versions via adapters.
- Feature detection: Instead of version checking, should agents check capabilities?
host.supports('elicitation'),host.supports('streaming'). Both approaches complement each other.
5. Dependency Graph
┌─────────────────┐
│ 2.5 ToolContract│
└────────┬────────┘
│ uses
┌─────────────┐ ┌────▼────────────┐ ┌──────────────┐
│4.3 Content │◄───│ 2.1 AgentEvent │───►│ 4.4 Telemetry│
│ Part │ └────────┬────────┘ └──────────────┘
└─────────────┘ │ consumed by
┌──────▼──────┐
│2.3 Agent │
│ Session │
└──┬───────┬──┘
│ │
┌──────────────▼─┐ ┌─▼──────────────┐
│2.2 Agent │ │2.4 Session │
│ Descriptor │ │ Factory │
└───────┬────────┘ └────────┬────────┘
│ │ requires
│ ┌─────────▼─────────┐
│ │ HostContext │
│ │ ┌─────────────────┐│
│ │ │3.1 PolicyEval ││
│ │ │3.2 Lifecycle Int││
│ │ │3.3 Elicitation ││
│ │ │3.4 ModelInterface││
│ │ │4.1 SessionProv. ││
│ │ └─────────────────┘│
│ └────────────────────┘
│
┌───────▼────────┐
│4.2 Composition │
└────────────────┘
Implementation order:
Phase 2a (foundations):
ContentPart(4.3) -- needed by everythingAgentEvent(2.1) -- the wire formatToolContract(2.5) -- tool descriptors and resultsAgentDescriptor(2.2) -- static agent metadata
Phase 2b (runtime):
5. AgentSession (2.3) -- the execution interface
6. SessionFactory (2.4) -- pluggable creation
7. PolicyEvaluator (3.1) -- authorization
8. LifecycleInterceptor (3.2) -- lifecycle interception
Phase 2c (interaction):
9. Elicitation (3.3) -- user interaction
10. ModelInterface (3.4) -- LLM abstraction
11. SessionContract (4.1) -- persistence
Phase 2d (composition):
12. AgentComposition (4.2) -- multi-agent patterns
13. TelemetryBridge (4.4) -- observability
14. Interface Versioning (4.5) -- compatibility
6. Abhi's Questions: Traceability Matrix
| # | Question | Primary Interface | Secondary Interfaces |
|---|---|---|---|
| 1 | How can I send messages to an agent that's already running? | AgentSession.stream() + steer() (2.3) | Elicitation (3.3), AgentEvent (2.1) |
| 2 | Does it steer the model correctly (function response vs new user input)? | AgentSession.stream() with SessionStreamData union (2.3) |
ModelInterface (3.4) |
| 3 | How can I persist/restart an agent session? | SessionStorage (4.1) | AgentSession.stream() with sessionRef (2.3) |
| 4 | What do I need to provide to get a composable agent usable as a subagent? | AgentDescriptor (2.2) | SessionFactory (2.4), AgentComposition (4.2) |
| 5 | What events are we using for proper UX around subagents? | AgentEvent (2.1) | LifecycleInterceptor (3.2), Composition events (4.2) |
| 6 | How can I isolate tools for a subagent that the main agent doesn't have access to? | ToolContract -- ownTools in descriptor (2.5) |
PolicyEvaluator (3.1), ToolProvider.getToolsForAgent() |
7. Questions the Team Should Be Asking
These are critical questions NOT in Abhi's list that the interface design must address.
7.1 How does the main chat agent itself fit this abstraction?
The main gemini-cli chat agent is ALSO an agent. Today it's special-cased (GeminiClient manages it directly). Should it become an AgentDescriptor + AgentSession?
Recommendation: Yes, eventually. But for Phase 2, the main chat agent can remain special-cased. It becomes the "root executor" that spawns sub-agents via the factory. Formalizing the main agent as a descriptor is Phase 3.
Why this matters: If the main agent isn't an agent in its own interface, we can't compose it (e.g., "run two main agents in parallel on different tasks").
7.2 How do agent completion events propagate?
When a sub-agent completes or errors -- how do these events propagate to the parent? And to the UI?
Answer via the interface:
- The executor emits
stream_endevents with areason(completed, failed, aborted, etc.). - Events carry
threadIdandagentIdlinking them to the sub-agent. - The parent executor (or host) sees these events in the stream from
stream(). - The UI receives them via the standard event rendering pipeline.
- Hooks (BeforeAgent, AfterAgent) fire at the parent level.
- Pausing is implicit — elicitation/tool request events naturally pause the stream.
Edge case: What if a sub-agent starts another sub-agent? Events bubble up through threadId chains: thread-1.thread-2. The parent sees events tagged with the full path and agentId.
7.3 How does cancellation/abort propagate through nested agents?
When the user presses Ctrl+C:
- The host calls
session.abort()on the root session. - The root executor propagates the AbortSignal to its sub-executors.
- Each executor cleans up and emits a
stream_endevent withreason: 'aborted'. - The abort propagates through the
signalinExecutionRequest.signal.
Key concern: Not all executors support AbortSignal. Remote agents may not respond to cancellation. The host should enforce a timeout: if the agent doesn't emit stream_end with reason: 'aborted' within N seconds, the host terminates the connection.
7.4 How does context windowing / compression work across agent boundaries?
gemini-cli compresses history when the context window fills up. When a sub-agent is running via ADK, who manages compression?
Answer: The executor owns its context window. The local executor uses ChatCompressionService. The ADK executor uses ADK's internal context management. The host can hint at compression via SessionUpdate events, but doesn't directly manage it.
Interface impact: We may need a ContextBudget in ExecutionOptions:
interface ExecutionOptions {
contextBudget?: {
maxTokens: number;
compressionStrategy?: 'summarize' | 'truncate' | 'sliding_window';
};
}
7.5 How do agent-to-agent transfers work?
ADK's transfer_to_agent lets an agent hand off to a peer agent. How does this cross executor boundaries?
Answer via the interface:
- Agent A calls
transfer_to_agentas a tool call (ToolRequest event withname: 'transfer_to_agent'). - The host intercepts this tool request (since host controls tool execution).
- The host looks up the target agent's descriptor in the registry.
- The host creates a new executor for the target agent via the factory.
- The host passes relevant context (conversation history, state) to the target agent.
- Agent A receives a
stream_endevent withreason: 'completed'.
Interface impact: Transfer is a tool call, not a separate event type. The host mediates all handoffs — it is the switchboard. This matches ADK's transfer_to_agent tool pattern.
7.6 How does the sandbox/security boundary work for agents with their own tools?
When a sub-agent declares ownTools, those tools run in the agent's context. But the host needs to enforce safety.
Options:
- Option A: Host wraps all ownTools with policy before provisioning them to the executor. Agent never touches raw tools.
- Option B: Agent executes its own tools, but the host audits via events (after-the-fact).
- Option C: Agent executes in a sandbox (process isolation, restricted filesystem).
Recommendation: Option A for Phase 2. Option C for Phase 3 (true sandboxing).
Interface impact: ToolProvider.getToolsForAgent(agentName) returns a merged set of host tools + wrapped ownTools, all policy-checked.
7.7 What's the testing story? How do you mock an agent for unit tests?
Developers need to:
- Test a sub-agent in isolation (without the host).
- Test the host with a mock agent.
- Test an executor adapter without a real LLM.
Interface impact: All interfaces should be plain TypeScript interfaces (not abstract classes) so they can be easily mocked. We should provide:
/** Test utility: mock session that replays a fixed event sequence */
class MockAgentSession implements AgentSession {
constructor(private events: AgentEvent[]) {}
stream(): AgentResponse {
const events = this.events;
return {
stream: (async function*() { for (const e of events) yield e; })(),
controller: { abort: () => {} },
};
}
update() {}
steer() {}
abort() {}
}
/** Test utility: mock host context with no-op implementations */
function createMockHostContext(overrides?: Partial<HostContext>): HostContext;
7.8 How does the interface handle version skew between agent and host?
When a user installs a new version of gemini-cli but has agents built against the old interface:
- Agents that target a compatible interface version continue to work.
- Agents that target an incompatible version are flagged in the registry.
- The host logs a warning and may run the agent in compatibility mode.
See section 4.5 for the versioning proposal.
7.9 How do we handle the "opaque agent" problem?
An opaque agent (e.g., a remote OpenRouter agent running its own tools) doesn't emit tool events, model events, or stream_end events. The host only sees the final result.
Interface impact: The AgentCapabilities in the descriptor declares what the agent supports. The host adjusts:
- No
supportsHostToolExecution=> host can't enforce policy on tools - No
supportsStreaming=> host waits for final result only - No
supportsStreamEndEvents=> hooks can't fire for stream end
The host MUST warn users when running opaque agents: "This agent runs tools outside policy enforcement."
7.10 How does configuration (model, tools, behavior) flow to agents?
Today, model config uses a hierarchical alias system with overrides. Agent-specific model configs are registered as agentName-config aliases. How does this cross the interface boundary?
Answer: The ExecutionOptions in the run request carries resolved configuration:
interface ExecutionOptions {
model?: string; // Resolved model name
generationConfig?: ModelGenerationConfig; // Resolved params
tools?: ToolDescriptor[]; // Resolved tool list
}
The host resolves aliases, applies overrides, and passes the result. The executor doesn't need to know about the config hierarchy.
8. Migration Path
Phase 2a: Define interfaces, implement ContentPart + AgentEvent
- Define and publish
ContentPart,AgentEvent,ToolDescriptor,AgentDescriptortypes - Create adapters: gemini-cli's existing types <-> new interface types
- No behavioral changes yet
Phase 2b: Implement AgentSession + Factory
- Define
AgentSession,SessionFactory,HostContext - Wrap
LocalAgentSessionas the 'local'AgentSessionimplementation - Wrap ADK
Runneras the 'adk'AgentSessionimplementation - Replace
SubagentToolWrapper.createInvocation()with factory lookup - Add
executor: stringfield toAgentDefinition
Phase 2c: Implement P1 interfaces
- Define and implement
PolicyEvaluator(thin wrapper over existing PolicyEngine) - Define and implement
LifecycleInterceptor(thin wrapper over existing HookSystem) - Define and implement
ElicitationChannel(wrapper over MessageBus) - Define
ModelInterface(used by executor adapters, not directly by host)
Phase 2d: Session, composition, telemetry
- Define and implement
SessionContract - Define
AgentCompositionpatterns - Define
TelemetryBridge - Implement interface versioning
Phase 3: Full migration
- Main chat agent as AgentDescriptor + AgentSession
- OpenRouter as an executor type
- True agent sandboxing
- AG-UI compatibility layer
Appendix A: Glossary
| Term | Definition |
|---|---|
| Descriptor | Static metadata about an agent (identity, schemas, capabilities). Serializable. |
| AgentSession | Stateful runtime handle for an agent execution. Three channels: send(), update(), abort(). |
| Host | gemini-cli itself -- owns policy, hooks, confirmation, UI. |
| HostContext | Bag of host services passed to executor factories. |
| Trajectory | Append-only sequence of events that fully describes a session. |
| Elicitation | Any request from agent to user for input (confirmation, question, auth). |
| ownTools | Tools an agent declares and brings with it, not inherited from the host. |
| threadId | Identifier linking events to a specific sub-agent execution. |
| ContentPart | A typed chunk of content (text, thought, media, reference, code). |
Appendix B: Files in gemini-cli That Will Change
| File | Change | Priority |
|---|---|---|
core/src/agents/types.ts |
Add executor field to BaseAgentDefinition, add AgentCapabilities |
P0 |
core/src/agents/subagent-tool-wrapper.ts |
Replace createInvocation() if/else with factory lookup |
P0 |
core/src/agents/registry.ts |
Consume AgentDescriptor, register executor types |
P0 |
core/src/agents/local-executor.ts |
Implement AgentSession interface |
P0 |
core/src/agents/local-invocation.ts |
Adapt to use AgentSession |
P0 |
core/src/agents/remote-invocation.ts |
Adapt to use AgentSession |
P1 |
core/src/agents/browser/browserAgentInvocation.ts |
Adapt to use AgentSession |
P1 |
core/src/tools/tools.ts |
Align ToolResult with ToolResultData interface |
P0 |
core/src/policy/ |
Implement PolicyEvaluator wrapper |
P1 |
core/src/hooks/ |
Implement LifecycleInterceptor wrapper |
P1 |
core/src/confirmation-bus/message-bus.ts |
Implement ElicitationChannel wrapper |
P1 |
core/src/core/geminiChat.ts |
Implement ModelInterface adapter |
P1 |
core/src/services/chatRecordingService.ts |
Implement SessionProvider |
P2 |