Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
7f7e69e
feat(agent): implement tool-controlled display protocol (Steps 2-3)
mbleigh Apr 10, 2026
fae963f
fix(core,cli): handle structured tool display properly and prevent me…
mbleigh Apr 10, 2026
cbff793
Merge branch 'main' into mb/atui/00-display-content
mbleigh Apr 10, 2026
da2fb08
Merge branch 'main' into mb/atui/00-display-content
mbleigh Apr 10, 2026
fbc8767
refactor(cli): consume simplified ToolDisplay property
mbleigh Apr 10, 2026
43f93c3
fix(ui): resolve rebase conflicts and type errors for ToolDisplay
mbleigh Apr 11, 2026
88bebef
fix(ui): hide summary in header when displayed in box
mbleigh Apr 11, 2026
383cb7d
wip: HistoryItemToolGroupDisplay
mbleigh Apr 12, 2026
410e675
revert: remove invasive ToolDisplay logic from legacy UI components
mbleigh Apr 12, 2026
45eabab
feat(cli): refactor tool rendering to declarative ToolDisplay system
mbleigh Apr 12, 2026
9e03476
feat(cli): support 'notice' format and refine declarative tool rendering
mbleigh Apr 12, 2026
8548c66
test(cli): add unit tests for ToolGroupDisplay and implement tool hiding
mbleigh Apr 12, 2026
46377d2
fix(core): restore ReadFolder declarative display and add missing tes…
mbleigh Apr 12, 2026
e2b2621
revert(core): restore intentional ReadFolder display behavior (result…
mbleigh Apr 12, 2026
9802cc7
Merge branch 'main' into mb/atui/00-display-content
mbleigh Apr 13, 2026
ebae075
Merge branch 'mb/atui/00-display-content' into mb/atui/01-ui-rendering
mbleigh Apr 13, 2026
de9a98c
fix(ui): flatten multiline summaries in compact ToolGroupDisplay and …
mbleigh Apr 13, 2026
af5dfc4
feat(cli): refine tool display aesthetics for legacy UI parity
mbleigh Apr 14, 2026
f5e2cf5
merge: catch up mb/atui/01-ui-rendering with main and resolve conflicts
mbleigh May 5, 2026
005e0cf
feat(agent): formalize first-class tool lifecycle states and status m…
mbleigh May 5, 2026
4a4f54c
test(core): harden messageBus subscription for legacy-agent-session t…
mbleigh May 5, 2026
48e9d80
fix(core): filter tool updates by callId to prevent session cross-talk
mbleigh May 6, 2026
93cafc2
fix(core): use CoreToolCallStatus enum for status mapping in LegacyAg…
mbleigh May 7, 2026
7887e38
Merge branch 'origin/main' into mb/atui/02-tool-state
mbleigh May 7, 2026
d857215
Merge branch 'main' into mb/atui/02-tool-state
mbleigh May 11, 2026
a7ae31d
fix: fix type issues from upstream merge
mbleigh May 11, 2026
aa8eca7
fix(agent): implement AgentProtocol disposal to prevent memory leaks
mbleigh May 11, 2026
5956e3c
Merge branch 'main' into mb/atui/02-tool-state
mbleigh May 11, 2026
b6cac32
fix: format
mbleigh May 11, 2026
04386cd
Merge branch 'mb/atui/02-tool-state' of github.com:google-gemini/gemi…
mbleigh May 11, 2026
fbc03b0
Merge branch 'main' into mb/atui/02-tool-state
mbleigh May 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 4 additions & 2 deletions packages/cli/src/nonInteractiveCliAgentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -193,6 +193,7 @@ export async function runNonInteractive({

let errorToHandle: unknown | undefined;
let scheduler: Scheduler | undefined;
let session: LegacyAgentSession | undefined;
let abortSession = () => {};
try {
consolePatcher.patch();
Expand Down Expand Up @@ -296,7 +297,7 @@ export async function runNonInteractive({
}

// Create LegacyAgentSession — owns the agentic loop
const session = new LegacyAgentSession({
session = new LegacyAgentSession({
client: geminiClient,
scheduler,
config,
Expand All @@ -305,7 +306,7 @@ export async function runNonInteractive({

// Wire Ctrl+C to session abort
abortSession = () => {
void session.abort();
void session?.abort();
};
abortController.signal.addEventListener('abort', abortSession);
if (abortController.signal.aborted) {
Expand Down Expand Up @@ -640,6 +641,7 @@ export async function runNonInteractive({
cleanupStdinCancellation();
abortController.signal.removeEventListener('abort', abortSession);

session?.dispose();
scheduler?.dispose();
consolePatcher.cleanup();
coreEvents.off(CoreEvent.UserFeedback, handleUserFeedback);
Expand Down
7 changes: 7 additions & 0 deletions packages/cli/src/ui/AppContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1180,6 +1180,13 @@ Logging in with Google... Restarting Gemini CLI to continue.
[config, getPreferredEditor],
);

useEffect(
() => () => {
streamAgent?.dispose?.();
},
[streamAgent],
);

const activeStream = streamAgent
? // eslint-disable-next-line react-hooks/rules-of-hooks
useAgentStream({
Expand Down
24 changes: 16 additions & 8 deletions packages/cli/src/ui/hooks/useAgentStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -224,14 +224,17 @@ export const useAgentStream = ({
if (tc.callId !== event.requestId) return tc;

const legacyState = event._meta?.legacyState;
const evtStatus = legacyState?.status;

let status = tc.status;
if (evtStatus === 'executing')
if (event.status === 'executing')
status = CoreToolCallStatus.Executing;
else if (evtStatus === 'error') status = CoreToolCallStatus.Error;
else if (evtStatus === 'success')
else if (event.status === 'pending_input')
status = CoreToolCallStatus.AwaitingApproval;
else if (event.status === 'errored')
status = CoreToolCallStatus.Error;
else if (event.status === 'succeeded')
status = CoreToolCallStatus.Success;
else if (event.status === 'aborted')
status = CoreToolCallStatus.Cancelled;

const display = event.display?.result;
const liveOutput =
Expand Down Expand Up @@ -272,11 +275,16 @@ export const useAgentStream = ({
const resultDisplay =
displayContentToString(display) ?? tc.resultDisplay;

let status = CoreToolCallStatus.Success;
if (event.status === 'errored') status = CoreToolCallStatus.Error;
else if (event.status === 'aborted')
status = CoreToolCallStatus.Cancelled;
else if (event.status === 'succeeded')
status = CoreToolCallStatus.Success;

return {
...tc,
status: event.isError
? CoreToolCallStatus.Error
: CoreToolCallStatus.Success,
status,
display: event.display
? { ...tc.display, ...event.display }
: tc.display,
Expand Down
4 changes: 4 additions & 0 deletions packages/core/src/agent/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export class AgentSession implements AgentProtocol {
return this._protocol.abort();
}

dispose(): void {
this._protocol.dispose?.();
}

get events(): readonly AgentEvent[] {
return this._protocol.events;
}
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/agent/event-translator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,7 @@ export function translateEvent(
makeEvent('tool_request', state, {
requestId: event.value.callId,
name: event.value.name,
status: 'pending',
args: event.value.args,
display: event.value.display,
}),
Expand All @@ -257,6 +258,7 @@ export function translateEvent(
makeEvent('tool_response', state, {
requestId: event.value.callId,
name: state.pendingToolNames.get(event.value.callId) ?? 'unknown',
status: event.value.error ? 'errored' : 'succeeded',
content: event.value.error
? [{ type: 'text', text: event.value.error.message }]
: geminiPartsToContentParts(event.value.responseParts),
Expand Down
111 changes: 110 additions & 1 deletion packages/core/src/agent/legacy-agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,10 @@ import { GeminiEventType } from '../core/turn.js';
import type { Part } from '@google/genai';
import type { GeminiClient } from '../core/client.js';
import type { Config } from '../config/config.js';
import type { ToolCallRequestInfo } from '../scheduler/types.js';
import {
type ToolCallRequestInfo,
CoreToolCallStatus,
} from '../scheduler/types.js';
import { Scheduler } from '../scheduler/scheduler.js';
import { recordToolCallInteractions } from '../code_assist/telemetry.js';
import { ToolErrorType, isFatalToolError } from '../tools/tool-error.js';
Expand All @@ -39,7 +42,12 @@ import type {
ContentPart,
StreamEndReason,
Unsubscribe,
ToolEventStatus,
} from './types.js';
import {
MessageBusType,
type ToolCallsUpdateMessage,
} from '../confirmation-bus/types.js';
Comment thread
mbleigh marked this conversation as resolved.

function isAbortLikeError(err: unknown): boolean {
return err instanceof Error && err.name === 'AbortError';
Expand All @@ -63,7 +71,9 @@ export class LegacyAgentProtocol implements AgentProtocol {
private _agentEndEmitted = false;
private _activeStreamId?: string;
private _abortController = new AbortController();
private _disposalController = new AbortController();
private _nextStreamIdOverride?: string;
private _lastToolStatuses = new Map<string, ToolEventStatus>();

private readonly _client: GeminiClient;
private readonly _scheduler: Scheduler;
Expand Down Expand Up @@ -92,6 +102,19 @@ export class LegacyAgentProtocol implements AgentProtocol {
}
this._scheduler = scheduler;
}

if (this._config.messageBus) {
Comment thread
mbleigh marked this conversation as resolved.
this._config.messageBus.subscribe(
MessageBusType.TOOL_CALLS_UPDATE,
this._handleToolCallsUpdate.bind(this),
{ signal: this._disposalController.signal },
);
}
}

dispose(): void {
this._disposalController.abort();
void this.abort();
}

get events(): readonly AgentEvent[] {
Expand Down Expand Up @@ -275,6 +298,11 @@ export class LegacyAgentProtocol implements AgentProtocol {
this._makeToolResponseEvent({
requestId: request.callId,
name: request.name,
status: response.error
? 'errored'
: tc.status === CoreToolCallStatus.Cancelled
? 'aborted'
: 'succeeded',
Comment thread
mbleigh marked this conversation as resolved.
content,
isError: response.error !== undefined,
...(display ? { display } : {}),
Expand Down Expand Up @@ -489,6 +517,87 @@ export class LegacyAgentProtocol implements AgentProtocol {
} satisfies AgentEvent<'error'>;
return event;
}

private _handleToolCallsUpdate(msg: ToolCallsUpdateMessage): void {
if (!this._activeStreamId) {
return;
}

const eventsToEmit: AgentEvent[] = [];

for (const tc of msg.toolCalls) {
const callId = tc.request.callId;

if (!this._translationState.pendingToolNames.has(callId)) {
continue;
}

let status: ToolEventStatus = 'pending';
switch (tc.status) {
case CoreToolCallStatus.Validating:
case CoreToolCallStatus.Scheduled:
status = 'pending';
break;
case CoreToolCallStatus.AwaitingApproval:
status = 'pending_input';
break;
case CoreToolCallStatus.Executing:
status = 'executing';
break;
case CoreToolCallStatus.Success:
status = 'succeeded';
break;
case CoreToolCallStatus.Error:
status = 'errored';
break;
case CoreToolCallStatus.Cancelled:
status = 'aborted';
break;
default:
status = 'pending';
break;
}
Comment thread
mbleigh marked this conversation as resolved.

const lastStatus = this._lastToolStatuses.get(callId);

if (lastStatus !== status) {
this._lastToolStatuses.set(callId, status);

const display = populateToolDisplay({
name: tc.request.name,
invocation: 'invocation' in tc ? tc.invocation : undefined,
displayName: 'tool' in tc ? tc.tool?.displayName : undefined,
display: 'response' in tc ? tc.response?.display : undefined,
});

eventsToEmit.push(
this._makeToolUpdateEvent({
requestId: callId,
status,
...(display ? { display } : {}),
}),
);
}
}

if (eventsToEmit.length > 0) {
this._emit(eventsToEmit);
}
}

private _makeToolUpdateEvent(
payload: Omit<
AgentEvent<'tool_update'>,
'id' | 'timestamp' | 'streamId' | 'type'
>,
): AgentEvent<'tool_update'> {
const event = {
...this._nextEventFields(),
type: 'tool_update',
...payload,
} satisfies AgentEvent<'tool_update'>;
return event;
}
}

export class LegacyAgentSession extends AgentSession {
Expand Down
19 changes: 19 additions & 0 deletions packages/core/src/agent/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,11 @@ export interface AgentProtocol extends Trajectory {
*/
abort(): Promise<void>;

/**
* Disposes of the protocol, cleaning up any long-lived resources.
*/
dispose?(): void;

/**
* AgentProtocol implements the Trajectory interface and can retrieve existing events.
*/
Expand Down Expand Up @@ -227,11 +232,21 @@ export interface ToolDisplay {
format?: ToolDisplayFormat;
}

export type ToolEventStatus =
| 'pending'
| 'pending_input'
| 'executing'
| 'succeeded'
| 'errored'
| 'aborted';

export interface ToolRequest {
/** A unique identifier for this tool request to be correlated by the response. */
requestId: string;
/** The name of the tool being requested. */
name: string;
/** The status of the tool execution. */
status: ToolEventStatus;
/** The arguments for the tool. */
/** Tool-controlled display information. */
display?: ToolDisplay;
Expand All @@ -255,6 +270,8 @@ export interface ToolRequest {
*/
export interface ToolUpdate {
requestId: string;
/** The status of the tool execution. */
status: ToolEventStatus;
/** Tool-controlled display information. */
display?: ToolDisplay;
content?: ContentPart[];
Expand All @@ -276,6 +293,8 @@ export interface ToolUpdate {
export interface ToolResponse {
requestId: string;
name: string;
/** The status of the tool execution. */
status: ToolEventStatus;
/** Tool-controlled display information. */
display?: ToolDisplay;
/** Multi-part content to be sent to the model. */
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/agents/local-subagent-protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,7 @@ class LocalSubagentProtocol implements AgentProtocol {
this._makeEvent('tool_request', {
requestId: callId,
name,
status: 'executing',
args,
}),
];
Expand All @@ -292,6 +293,7 @@ class LocalSubagentProtocol implements AgentProtocol {
this._makeEvent('tool_response', {
requestId,
name,
status: 'succeeded',
content: [{ type: 'text', text: output }],
}),
];
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/tools/shell.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1036,7 +1036,7 @@ export class ShellToolInvocation extends BaseToolInvocation<
return {
llmContent,
display: {
name: 'Shell',
name: this._toolDisplayName,
description: this.getDescription(),
resultSummary: displayResultSummary,
result:
Expand Down
Loading