Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
45 changes: 44 additions & 1 deletion core/src/agents/loop_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,9 @@
* SPDX-License-Identifier: Apache-2.0
*/

import {Event} from '../events/event.js';
import {createEvent, Event} from '../events/event.js';
import {createEventActions} from '../events/event_actions.js';
import {TerminationCondition} from '../termination/termination_condition.js';

import {BaseAgent, BaseAgentConfig} from './base_agent.js';
import {InvocationContext} from './invocation_context.js';
Expand All @@ -19,6 +21,28 @@ export interface LoopAgentConfig extends BaseAgentConfig {
* If not provided, the loop agent will run indefinitely.
*/
maxIterations?: number;

/**
* An optional termination condition that controls when the loop stops.
*
* The condition is evaluated after each event emitted by a sub-agent. When
* it fires, the loop yields a final event with `actions.terminationReason`
* set and `actions.escalate` set to `true`, then stops.
*
* The condition is automatically reset at the start of each `runAsync()`
* call, so the same instance can be reused across multiple runs.
*
* @example
* ```typescript
* const agent = new LoopAgent({
* name: 'my_loop',
* subAgents: [...],
* terminationCondition: new MaxIterationsTermination(10)
* .or(new TextMentionTermination('DONE')),
* });
* ```
*/
terminationCondition?: TerminationCondition;
}

/**
Expand Down Expand Up @@ -54,15 +78,18 @@ export class LoopAgent extends BaseAgent {
readonly [LOOP_AGENT_SIGNATURE_SYMBOL] = true;

readonly maxIterations: number;
readonly terminationCondition?: TerminationCondition;

constructor(config: LoopAgentConfig) {
super(config);
this.maxIterations = config.maxIterations ?? Number.MAX_SAFE_INTEGER;
this.terminationCondition = config.terminationCondition;
}

protected async *runAsyncImpl(
context: InvocationContext,
): AsyncGenerator<Event, void, void> {
await this.terminationCondition?.reset();
let iteration = 0;

while (iteration < this.maxIterations) {
Expand All @@ -73,6 +100,22 @@ export class LoopAgent extends BaseAgent {

if (event.actions.escalate) {
shouldExit = true;
break;
}

if (this.terminationCondition) {
const result = await this.terminationCondition.check([event]);
if (result) {
yield createEvent({
invocationId: context.invocationId,
author: this.name,
actions: createEventActions({
escalate: true,
terminationReason: result.reason,
}),
});
return;
}
}
}

Expand Down
12 changes: 12 additions & 0 deletions core/src/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,18 @@ export {InMemorySessionService} from './sessions/in_memory_session_service.js';
export {createSession} from './sessions/session.js';
export type {Session} from './sessions/session.js';
export {State} from './sessions/state.js';
export {ExternalTermination} from './termination/external_termination.js';
export {FunctionCallTermination} from './termination/function_call_termination.js';
export {MaxIterationsTermination} from './termination/max_iterations_termination.js';
export {
AndTerminationCondition,
OrTerminationCondition,
TerminationCondition,
} from './termination/termination_condition.js';
export type {TerminationResult} from './termination/termination_condition.js';
export {TextMentionTermination} from './termination/text_mention_termination.js';
export {TimeoutTermination} from './termination/timeout_termination.js';
export {TokenUsageTermination} from './termination/token_usage_termination.js';
export {AgentTool, isAgentTool} from './tools/agent_tool.js';
export type {AgentToolConfig} from './tools/agent_tool.js';
export {BaseTool, isBaseTool} from './tools/base_tool.js';
Expand Down
6 changes: 6 additions & 0 deletions core/src/events/event_actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export interface EventActions {
* call id.
*/
requestedToolConfirmations: {[key: string]: ToolConfirmation};

/**
* The human-readable reason the conversation was terminated by a
* TerminationCondition. Only set on synthetic termination events.
*/
terminationReason?: string;
}

/**
Expand Down
42 changes: 37 additions & 5 deletions core/src/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import {
runAsyncGeneratorWithOtelContext,
tracer,
} from '../telemetry/tracing.js';
import {TerminationCondition} from '../termination/termination_condition.js';
import {logger} from '../utils/logger.js';
import {isGemini2OrAbove} from '../utils/model_name.js';

Expand Down Expand Up @@ -101,13 +102,16 @@ export class Runner {
* @param params.newMessage A new message to append to the session.
* @param params.stateDelta An optional state delta to apply to the session.
* @param params.runConfig The run config for the agent.
* @param params.terminationCondition An optional condition that stops the run
* when triggered. Reset automatically before the run begins.
* @yields The Events generated by the agent.
*/
async *runEphemeral(params: {
userId: string;
newMessage: Content;
stateDelta?: Record<string, unknown>;
runConfig?: RunConfig;
terminationCondition?: TerminationCondition;
}): AsyncGenerator<Event, void, undefined> {
const session = await this.sessionService.createSession({
appName: this.appName,
Expand All @@ -122,6 +126,7 @@ export class Runner {
newMessage: params.newMessage,
stateDelta: params.stateDelta,
runConfig: params.runConfig,
terminationCondition: params.terminationCondition,
});
} finally {
await this.sessionService.deleteSession({
Expand Down Expand Up @@ -150,8 +155,15 @@ export class Runner {
newMessage: Content;
stateDelta?: Record<string, unknown>;
runConfig?: RunConfig;
/**
* An optional termination condition that stops the run when triggered.
* The condition is reset automatically at the start of the run and checked
* after each event. When it fires, a final event with
* `actions.terminationReason` is yielded and the run stops.
*/
terminationCondition?: TerminationCondition;
}): AsyncGenerator<Event, void, undefined> {
const {userId, sessionId, stateDelta} = params;
const {userId, sessionId, stateDelta, terminationCondition} = params;
const runConfig = createRunConfig(params.runConfig);
let newMessage = params.newMessage;

Expand Down Expand Up @@ -289,6 +301,7 @@ export class Runner {
yield earlyExitEvent;
} else {
// Step 2: Otherwise continue with normal execution
await terminationCondition?.reset();
for await (const event of invocationContext.agent.runAsync(
invocationContext,
)) {
Expand All @@ -301,10 +314,29 @@ export class Runner {
invocationContext,
event,
});
if (modifiedEvent) {
yield modifiedEvent;
} else {
yield event;
const eventToYield = modifiedEvent ?? event;
yield eventToYield;

if (terminationCondition) {
const terminationResult = await terminationCondition.check([
eventToYield,
]);
if (terminationResult) {
const terminationEvent = createEvent({
invocationId: invocationContext.invocationId,
author: invocationContext.agent.name,
actions: createEventActions({
escalate: true,
terminationReason: terminationResult.reason,
}),
});
await this.sessionService.appendEvent({
session,
event: terminationEvent,
});
yield terminationEvent;
break;
}
}
}
// Step 4: Run the after_run callbacks to optionally modify the context.
Expand Down
59 changes: 59 additions & 0 deletions core/src/termination/external_termination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {Event} from '../events/event.js';

import {
TerminationCondition,
TerminationResult,
} from './termination_condition.js';

/**
* A termination condition that is controlled programmatically by calling
* `set()`. Useful for integrating external stop signals such as a UI
* "Stop" button or application-level logic.
*
* @example
* ```typescript
* const stopButton = new ExternalTermination();
*
* const agent = new LoopAgent({
* ...,
* terminationCondition: stopButton,
* });
*
* // Elsewhere (e.g. from a UI event handler):
* stopButton.set();
* ```
*/
export class ExternalTermination extends TerminationCondition {
private _terminated = false;

get terminated(): boolean {
return this._terminated;
}

/**
* Signals that the conversation should terminate at the next check.
*/
set(): void {
this._terminated = true;
}

async check(_events: Event[]): Promise<TerminationResult | undefined> {
if (this._terminated) {
return {
reason: 'Externally terminated',
};
}

return undefined;
}

async reset(): Promise<void> {
this._terminated = false;
}
}
61 changes: 61 additions & 0 deletions core/src/termination/function_call_termination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {Event, getFunctionResponses} from '../events/event.js';

import {
TerminationCondition,
TerminationResult,
} from './termination_condition.js';

/**
* Terminates the conversation when a tool (function) with a specific name has
* been executed. The condition checks `FunctionResponse` parts in events.
*
* @example
* ```typescript
* // Stop when the "approve" tool is called
* const condition = new FunctionCallTermination('approve');
* ```
*/
export class FunctionCallTermination extends TerminationCondition {
private _terminated = false;

/**
* @param functionName The name of the function whose execution triggers
* termination.
*/
constructor(private readonly functionName: string) {
super();
}

get terminated(): boolean {
return this._terminated;
}

async check(events: Event[]): Promise<TerminationResult | undefined> {
if (this._terminated) {
return undefined;
}

for (const event of events) {
for (const response of getFunctionResponses(event)) {
if (response.name === this.functionName) {
this._terminated = true;
return {
reason: `Function '${this.functionName}' was executed`,
};
}
}
}

return undefined;
}

async reset(): Promise<void> {
this._terminated = false;
}
}
63 changes: 63 additions & 0 deletions core/src/termination/max_iterations_termination.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/

import {Event} from '../events/event.js';

import {
TerminationCondition,
TerminationResult,
} from './termination_condition.js';

/**
* Terminates the conversation after a maximum number of events have been
* processed.
*
* @example
* ```typescript
* // Stop after 10 events
* const condition = new MaxIterationsTermination(10);
* ```
*/
export class MaxIterationsTermination extends TerminationCondition {
private _terminated = false;
private _count = 0;

/**
* @param maxIterations The maximum number of events to process before
* terminating.
*/
constructor(private readonly maxIterations: number) {
super();
if (maxIterations <= 0) {
throw new Error('maxIterations must be a positive integer.');
}
}

get terminated(): boolean {
return this._terminated;
}

async check(events: Event[]): Promise<TerminationResult | undefined> {
if (this._terminated) {
return undefined;
}
this._count += events.length;

if (this._count >= this.maxIterations) {
this._terminated = true;
return {
reason: `Maximum iterations of ${this.maxIterations} reached, current count: ${this._count}`,
};
}

return undefined;
}

async reset(): Promise<void> {
this._terminated = false;
this._count = 0;
}
}
Loading
Loading