From af8cda5187bdf4a71d08a6a7d5677904b8918229 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Mon, 9 Mar 2026 17:17:41 -0300 Subject: [PATCH 1/3] feat: add termination conditions for multi-agent workflows Introduces a TerminationCondition system that lets developers control when LoopAgent loops and Runner invocations stop. Built-in conditions: - MaxIterationsTermination: stops after N events - TextMentionTermination: stops when a keyword appears (e.g. 'TERMINATE') - TokenUsageTermination: stops when prompt/completion/total token limits exceeded - TimeoutTermination: stops after a wall-clock duration - FunctionCallTermination: stops when a named tool is executed - ExternalTermination: programmatic stop via .set() (e.g. UI stop button) Conditions compose with .and() / .or() for combined logic: new MaxIterationsTermination(10).or(new TextMentionTermination('DONE')) Integration: - LoopAgentConfig.terminationCondition: checked after every sub-agent event - Runner.runAsync / runEphemeral support terminationCondition param - EventActions gains terminationReason field - All types exported from common.ts / @google/adk - Full test coverage in core/test/termination/ --- core/src/agents/loop_agent.ts | 45 +- core/src/common.ts | 12 + core/src/events/event_actions.ts | 6 + core/src/runner/runner.ts | 42 +- core/src/termination/external_termination.ts | 59 +++ .../termination/function_call_termination.ts | 61 +++ .../termination/max_iterations_termination.ts | 63 +++ core/src/termination/termination_condition.ts | 164 ++++++ .../termination/text_mention_termination.ts | 74 +++ core/src/termination/timeout_termination.ts | 69 +++ .../termination/token_usage_termination.ts | 119 +++++ .../termination_conditions_test.ts | 465 ++++++++++++++++++ 12 files changed, 1173 insertions(+), 6 deletions(-) create mode 100644 core/src/termination/external_termination.ts create mode 100644 core/src/termination/function_call_termination.ts create mode 100644 core/src/termination/max_iterations_termination.ts create mode 100644 core/src/termination/termination_condition.ts create mode 100644 core/src/termination/text_mention_termination.ts create mode 100644 core/src/termination/timeout_termination.ts create mode 100644 core/src/termination/token_usage_termination.ts create mode 100644 core/test/termination/termination_conditions_test.ts diff --git a/core/src/agents/loop_agent.ts b/core/src/agents/loop_agent.ts index 7d3eb67a..77f3f622 100644 --- a/core/src/agents/loop_agent.ts +++ b/core/src/agents/loop_agent.ts @@ -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'; @@ -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; } /** @@ -54,15 +78,18 @@ export class LoopAgent extends BaseAgent { readonly [LOOP_AGENT_SIGNATURE_SYMBOL] = true; private readonly maxIterations: number; + private 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 { + await this.terminationCondition?.reset(); let iteration = 0; while (iteration < this.maxIterations) { @@ -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; + } } } diff --git a/core/src/common.ts b/core/src/common.ts index cb479190..1e768229 100644 --- a/core/src/common.ts +++ b/core/src/common.ts @@ -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'; diff --git a/core/src/events/event_actions.ts b/core/src/events/event_actions.ts index a4f966e8..72183f79 100644 --- a/core/src/events/event_actions.ts +++ b/core/src/events/event_actions.ts @@ -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; } /** diff --git a/core/src/runner/runner.ts b/core/src/runner/runner.ts index 75849dcf..458163a8 100644 --- a/core/src/runner/runner.ts +++ b/core/src/runner/runner.ts @@ -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'; @@ -80,6 +81,8 @@ 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: { @@ -87,6 +90,7 @@ export class Runner { newMessage: Content; stateDelta?: Record; runConfig?: RunConfig; + terminationCondition?: TerminationCondition; }): AsyncGenerator { const session = await this.sessionService.createSession({ appName: this.appName, @@ -101,6 +105,7 @@ export class Runner { newMessage: params.newMessage, stateDelta: params.stateDelta, runConfig: params.runConfig, + terminationCondition: params.terminationCondition, }); } finally { await this.sessionService.deleteSession({ @@ -129,8 +134,15 @@ export class Runner { newMessage: Content; stateDelta?: Record; 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 { - const {userId, sessionId, stateDelta} = params; + const {userId, sessionId, stateDelta, terminationCondition} = params; const runConfig = createRunConfig(params.runConfig); let newMessage = params.newMessage; @@ -268,6 +280,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, )) { @@ -280,10 +293,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. diff --git a/core/src/termination/external_termination.ts b/core/src/termination/external_termination.ts new file mode 100644 index 00000000..9962bc4e --- /dev/null +++ b/core/src/termination/external_termination.ts @@ -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 { + if (this._terminated) { + return { + reason: 'Externally terminated', + }; + } + + return undefined; + } + + async reset(): Promise { + this._terminated = false; + } +} diff --git a/core/src/termination/function_call_termination.ts b/core/src/termination/function_call_termination.ts new file mode 100644 index 00000000..c60f7944 --- /dev/null +++ b/core/src/termination/function_call_termination.ts @@ -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 { + 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 { + this._terminated = false; + } +} diff --git a/core/src/termination/max_iterations_termination.ts b/core/src/termination/max_iterations_termination.ts new file mode 100644 index 00000000..a029656e --- /dev/null +++ b/core/src/termination/max_iterations_termination.ts @@ -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 { + 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 { + this._terminated = false; + this._count = 0; + } +} diff --git a/core/src/termination/termination_condition.ts b/core/src/termination/termination_condition.ts new file mode 100644 index 00000000..7076e577 --- /dev/null +++ b/core/src/termination/termination_condition.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Event} from '../events/event.js'; + +/** + * The result returned by a termination condition when the conversation should + * stop. + */ +export interface TerminationResult { + /** + * A human-readable description of why the conversation was terminated. + */ + reason: string; +} + +/** + * Abstract base class for all termination conditions. + * + * A termination condition is evaluated after each event in the agent loop. + * When `check()` returns a `TerminationResult`, the loop stops and the + * `reason` is surfaced in the final event's `actions.terminationReason`. + * + * Conditions are stateful but reset automatically at the start of each run. + * They can be combined with `.and()` and `.or()` to create compound logic. + * + * @example + * ```typescript + * const condition = new MaxIterationsTermination(10) + * .or(new TextMentionTermination('TERMINATE')); + * + * const agent = new LoopAgent({ ..., terminationCondition: condition }); + * ``` + */ +export abstract class TerminationCondition { + /** + * Whether this termination condition has been reached. + */ + abstract get terminated(): boolean; + + /** + * Checks whether the termination condition is met given the latest events. + * + * Called after each event emitted by the agent. Returns a + * `TerminationResult` if the loop should stop, or `undefined` to continue. + * + * @param events The delta sequence of events since the last check. + */ + abstract check(events: Event[]): Promise; + + /** + * Resets this condition to its initial state so it can be reused across + * multiple runs. Called automatically at the start of each run. + */ + abstract reset(): Promise; + + /** + * Returns a new condition that terminates only when BOTH this condition + * and `other` have been met (logical AND). + * + * @param other The other termination condition. + */ + and(other: TerminationCondition): TerminationCondition { + return new AndTerminationCondition(this, other); + } + + /** + * Returns a new condition that terminates when EITHER this condition or + * `other` is met first (logical OR). + * + * @param other The other termination condition. + */ + or(other: TerminationCondition): TerminationCondition { + return new OrTerminationCondition(this, other); + } +} + +/** + * A compound termination condition that terminates only when ALL of its + * child conditions have been met (across potentially different events). + */ +export class AndTerminationCondition extends TerminationCondition { + private _terminated = false; + + constructor( + private readonly left: TerminationCondition, + private readonly right: TerminationCondition, + ) { + super(); + } + + get terminated(): boolean { + return this._terminated; + } + + async check(events: Event[]): Promise { + if (this._terminated) { + return undefined; + } + // Forward to both children so each accumulates its own state. + await this.left.check(events); + await this.right.check(events); + + if (this.left.terminated && this.right.terminated) { + this._terminated = true; + return { + reason: `All termination conditions met`, + }; + } + + return undefined; + } + + async reset(): Promise { + this._terminated = false; + await Promise.all([this.left.reset(), this.right.reset()]); + } +} + +/** + * A compound termination condition that terminates when ANY of its child + * conditions is met first (logical OR). + */ +export class OrTerminationCondition extends TerminationCondition { + private _terminated = false; + + constructor( + private readonly left: TerminationCondition, + private readonly right: TerminationCondition, + ) { + super(); + } + + get terminated(): boolean { + return this._terminated; + } + + async check(events: Event[]): Promise { + if (this._terminated) { + return undefined; + } + const leftResult = await this.left.check(events); + if (leftResult) { + this._terminated = true; + return leftResult; + } + + const rightResult = await this.right.check(events); + if (rightResult) { + this._terminated = true; + return rightResult; + } + + return undefined; + } + + async reset(): Promise { + this._terminated = false; + await Promise.all([this.left.reset(), this.right.reset()]); + } +} diff --git a/core/src/termination/text_mention_termination.ts b/core/src/termination/text_mention_termination.ts new file mode 100644 index 00000000..1a5dfa15 --- /dev/null +++ b/core/src/termination/text_mention_termination.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import {Event, stringifyContent} from '../events/event.js'; + +import { + TerminationCondition, + TerminationResult, +} from './termination_condition.js'; + +/** + * Terminates the conversation when a specific text string is found in an + * event's content. + * + * @example + * ```typescript + * // Stop when any agent says "TERMINATE" + * const condition = new TextMentionTermination('TERMINATE'); + * + * // Stop only when the "critic" agent says "APPROVE" + * const condition = new TextMentionTermination('APPROVE', { sources: ['critic'] }); + * ``` + */ +export class TextMentionTermination extends TerminationCondition { + private _terminated = false; + + /** + * @param text The text to look for in event content. + * @param options.sources An optional list of agent names to check. If + * omitted, all sources are checked. + */ + constructor( + private readonly text: string, + private readonly options: {sources?: string[]} = {}, + ) { + super(); + } + + get terminated(): boolean { + return this._terminated; + } + + async check(events: Event[]): Promise { + if (this._terminated) { + return undefined; + } + + for (const event of events) { + if ( + this.options.sources && + this.options.sources.length > 0 && + !this.options.sources.includes(event.author ?? '') + ) { + continue; + } + + if (stringifyContent(event).includes(this.text)) { + this._terminated = true; + return { + reason: `Text '${this.text}' mentioned`, + }; + } + } + + return undefined; + } + + async reset(): Promise { + this._terminated = false; + } +} diff --git a/core/src/termination/timeout_termination.ts b/core/src/termination/timeout_termination.ts new file mode 100644 index 00000000..5af473a1 --- /dev/null +++ b/core/src/termination/timeout_termination.ts @@ -0,0 +1,69 @@ +/** + * @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 specified duration has elapsed since + * the first `check()` call (i.e. since the run started). + * + * @example + * ```typescript + * // Stop after 30 seconds + * const condition = new TimeoutTermination(30); + * ``` + */ +export class TimeoutTermination extends TerminationCondition { + private _terminated = false; + private _startTime: number | undefined = undefined; + + /** + * @param timeoutSeconds The maximum duration in seconds before the + * conversation is terminated. + */ + constructor(private readonly timeoutSeconds: number) { + super(); + if (timeoutSeconds <= 0) { + throw new Error('timeoutSeconds must be a positive number.'); + } + } + + get terminated(): boolean { + return this._terminated; + } + + async check(_events: Event[]): Promise { + if (this._terminated) { + return undefined; + } + + if (this._startTime === undefined) { + this._startTime = Date.now(); + } + + const elapsedMs = Date.now() - this._startTime; + const elapsedSeconds = elapsedMs / 1000; + + if (elapsedSeconds >= this.timeoutSeconds) { + this._terminated = true; + return { + reason: `Timeout of ${this.timeoutSeconds}s reached (elapsed: ${elapsedSeconds.toFixed(2)}s)`, + }; + } + + return undefined; + } + + async reset(): Promise { + this._terminated = false; + this._startTime = undefined; + } +} diff --git a/core/src/termination/token_usage_termination.ts b/core/src/termination/token_usage_termination.ts new file mode 100644 index 00000000..18477459 --- /dev/null +++ b/core/src/termination/token_usage_termination.ts @@ -0,0 +1,119 @@ +/** + * @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 when cumulative token usage exceeds a + * configured limit. Token usage is read from `event.usageMetadata`, which is + * present on events that originate from LLM calls. + * + * At least one of the token limits must be provided. + * + * @example + * ```typescript + * // Stop after 10 000 total tokens + * const condition = new TokenUsageTermination({ maxTotalTokens: 10_000 }); + * + * // Stop after 5 000 prompt tokens OR 2 000 completion tokens + * const condition = new TokenUsageTermination({ + * maxPromptTokens: 5_000, + * maxCompletionTokens: 2_000, + * }); + * ``` + */ +export class TokenUsageTermination extends TerminationCondition { + private _terminated = false; + private _totalTokens = 0; + private _promptTokens = 0; + private _completionTokens = 0; + + /** + * @param limits The token usage limits to enforce. At least one must be set. + */ + constructor( + private readonly limits: { + maxTotalTokens?: number; + maxPromptTokens?: number; + maxCompletionTokens?: number; + }, + ) { + super(); + if ( + limits.maxTotalTokens === undefined && + limits.maxPromptTokens === undefined && + limits.maxCompletionTokens === undefined + ) { + throw new Error( + 'At least one of maxTotalTokens, maxPromptTokens, or maxCompletionTokens must be provided.', + ); + } + } + + get terminated(): boolean { + return this._terminated; + } + + async check(events: Event[]): Promise { + if (this._terminated) { + return undefined; + } + + for (const event of events) { + if (!event.usageMetadata) { + continue; + } + + this._totalTokens += event.usageMetadata.totalTokenCount ?? 0; + this._promptTokens += event.usageMetadata.promptTokenCount ?? 0; + this._completionTokens += event.usageMetadata.candidatesTokenCount ?? 0; + + if ( + this.limits.maxTotalTokens !== undefined && + this._totalTokens >= this.limits.maxTotalTokens + ) { + this._terminated = true; + return { + reason: `Token limit exceeded: totalTokens=${this._totalTokens} >= maxTotalTokens=${this.limits.maxTotalTokens}`, + }; + } + + if ( + this.limits.maxPromptTokens !== undefined && + this._promptTokens >= this.limits.maxPromptTokens + ) { + this._terminated = true; + return { + reason: `Token limit exceeded: promptTokens=${this._promptTokens} >= maxPromptTokens=${this.limits.maxPromptTokens}`, + }; + } + + if ( + this.limits.maxCompletionTokens !== undefined && + this._completionTokens >= this.limits.maxCompletionTokens + ) { + this._terminated = true; + return { + reason: `Token limit exceeded: completionTokens=${this._completionTokens} >= maxCompletionTokens=${this.limits.maxCompletionTokens}`, + }; + } + } + + return undefined; + } + + async reset(): Promise { + this._terminated = false; + this._totalTokens = 0; + this._promptTokens = 0; + this._completionTokens = 0; + } +} diff --git a/core/test/termination/termination_conditions_test.ts b/core/test/termination/termination_conditions_test.ts new file mode 100644 index 00000000..f69eb662 --- /dev/null +++ b/core/test/termination/termination_conditions_test.ts @@ -0,0 +1,465 @@ +/** + * @license + * Copyright 2026 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AndTerminationCondition, + Event, + ExternalTermination, + FunctionCallTermination, + MaxIterationsTermination, + OrTerminationCondition, + TextMentionTermination, + TimeoutTermination, + TokenUsageTermination, +} from '@google/adk'; +import { + createPartFromFunctionResponse, + createPartFromText, +} from '@google/genai'; + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function makeTextEvent(text: string, author = 'agent'): Event { + return { + id: 'test-id', + invocationId: 'inv-1', + author, + timestamp: Date.now(), + actions: { + stateDelta: {}, + artifactDelta: {}, + requestedAuthConfigs: {}, + requestedToolConfirmations: {}, + }, + content: { + role: 'model', + parts: [createPartFromText(text)], + }, + }; +} + +function makeTokenEvent( + totalTokens: number, + promptTokens: number, + completionTokens: number, +): Event { + return { + id: 'test-id', + invocationId: 'inv-1', + author: 'agent', + timestamp: Date.now(), + actions: { + stateDelta: {}, + artifactDelta: {}, + requestedAuthConfigs: {}, + requestedToolConfirmations: {}, + }, + usageMetadata: { + totalTokenCount: totalTokens, + promptTokenCount: promptTokens, + candidatesTokenCount: completionTokens, + }, + }; +} + +function makeFunctionResponseEvent(functionName: string): Event { + return { + id: 'test-id', + invocationId: 'inv-1', + author: 'agent', + timestamp: Date.now(), + actions: { + stateDelta: {}, + artifactDelta: {}, + requestedAuthConfigs: {}, + requestedToolConfirmations: {}, + }, + content: { + role: 'tool', + parts: [ + createPartFromFunctionResponse({ + name: functionName, + response: {result: 'ok'}, + }), + ], + }, + }; +} + +// --------------------------------------------------------------------------- +// MaxIterationsTermination +// --------------------------------------------------------------------------- + +describe('MaxIterationsTermination', () => { + it('should throw if maxIterations is not positive', () => { + expect(() => new MaxIterationsTermination(0)).toThrow(); + expect(() => new MaxIterationsTermination(-1)).toThrow(); + }); + + it('should not terminate before reaching maxIterations', async () => { + const condition = new MaxIterationsTermination(3); + const result = await condition.check([makeTextEvent('hello')]); + expect(result).toBeUndefined(); + expect(condition.terminated).toBe(false); + }); + + it('should terminate when maxIterations is reached', async () => { + const condition = new MaxIterationsTermination(3); + await condition.check([makeTextEvent('a'), makeTextEvent('b')]); + const result = await condition.check([makeTextEvent('c')]); + expect(result).toBeDefined(); + expect(result!.reason).toContain('3'); + expect(condition.terminated).toBe(true); + }); + + it('should not fire again after termination', async () => { + const condition = new MaxIterationsTermination(1); + await condition.check([makeTextEvent('first')]); + expect(condition.terminated).toBe(true); + const secondResult = await condition.check([makeTextEvent('second')]); + expect(secondResult).toBeUndefined(); + }); + + it('should reset correctly', async () => { + const condition = new MaxIterationsTermination(1); + await condition.check([makeTextEvent('first')]); + expect(condition.terminated).toBe(true); + + await condition.reset(); + expect(condition.terminated).toBe(false); + + const result = await condition.check([makeTextEvent('first again')]); + expect(result).toBeDefined(); + }); +}); + +// --------------------------------------------------------------------------- +// TextMentionTermination +// --------------------------------------------------------------------------- + +describe('TextMentionTermination', () => { + it('should terminate when text is found in any event', async () => { + const condition = new TextMentionTermination('TERMINATE'); + const result = await condition.check([ + makeTextEvent('Please TERMINATE now.'), + ]); + expect(result).toBeDefined(); + expect(result!.reason).toContain('TERMINATE'); + expect(condition.terminated).toBe(true); + }); + + it('should not terminate when text is absent', async () => { + const condition = new TextMentionTermination('TERMINATE'); + const result = await condition.check([makeTextEvent('Keep going!')]); + expect(result).toBeUndefined(); + expect(condition.terminated).toBe(false); + }); + + it('should respect the sources filter', async () => { + const condition = new TextMentionTermination('APPROVE', { + sources: ['critic'], + }); + + // Wrong source — should NOT fire + const noFire = await condition.check([makeTextEvent('APPROVE', 'primary')]); + expect(noFire).toBeUndefined(); + + // Correct source — should fire + const fire = await condition.check([makeTextEvent('APPROVE', 'critic')]); + expect(fire).toBeDefined(); + }); + + it('should reset correctly', async () => { + const condition = new TextMentionTermination('DONE'); + await condition.check([makeTextEvent('DONE')]); + expect(condition.terminated).toBe(true); + + await condition.reset(); + expect(condition.terminated).toBe(false); + + const result = await condition.check([makeTextEvent('not done yet')]); + expect(result).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// TokenUsageTermination +// --------------------------------------------------------------------------- + +describe('TokenUsageTermination', () => { + it('should throw if no token limit is provided', () => { + expect(() => new TokenUsageTermination({})).toThrow(); + }); + + it('should terminate when total token limit is exceeded', async () => { + const condition = new TokenUsageTermination({maxTotalTokens: 100}); + const result = await condition.check([makeTokenEvent(101, 50, 51)]); + expect(result).toBeDefined(); + expect(result!.reason).toContain('totalTokens'); + expect(condition.terminated).toBe(true); + }); + + it('should terminate when prompt token limit is exceeded', async () => { + const condition = new TokenUsageTermination({maxPromptTokens: 50}); + const result = await condition.check([makeTokenEvent(60, 55, 5)]); + expect(result).toBeDefined(); + expect(result!.reason).toContain('promptTokens'); + }); + + it('should terminate when completion token limit is exceeded', async () => { + const condition = new TokenUsageTermination({maxCompletionTokens: 30}); + const result = await condition.check([makeTokenEvent(40, 5, 35)]); + expect(result).toBeDefined(); + expect(result!.reason).toContain('completionTokens'); + }); + + it('should accumulate tokens across multiple events', async () => { + const condition = new TokenUsageTermination({maxTotalTokens: 100}); + await condition.check([makeTokenEvent(60, 40, 20)]); + expect(condition.terminated).toBe(false); + + const result = await condition.check([makeTokenEvent(50, 30, 20)]); + expect(result).toBeDefined(); + expect(condition.terminated).toBe(true); + }); + + it('should ignore events without usageMetadata', async () => { + const condition = new TokenUsageTermination({maxTotalTokens: 10}); + const result = await condition.check([makeTextEvent('no tokens here')]); + expect(result).toBeUndefined(); + expect(condition.terminated).toBe(false); + }); + + it('should reset correctly', async () => { + const condition = new TokenUsageTermination({maxTotalTokens: 100}); + await condition.check([makeTokenEvent(200, 100, 100)]); + expect(condition.terminated).toBe(true); + + await condition.reset(); + expect(condition.terminated).toBe(false); + const result = await condition.check([makeTokenEvent(50, 30, 20)]); + expect(result).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// TimeoutTermination +// --------------------------------------------------------------------------- + +describe('TimeoutTermination', () => { + it('should throw if timeoutSeconds is not positive', () => { + expect(() => new TimeoutTermination(0)).toThrow(); + expect(() => new TimeoutTermination(-5)).toThrow(); + }); + + it('should not terminate before the timeout elapses', async () => { + const condition = new TimeoutTermination(60); + const result = await condition.check([makeTextEvent('hello')]); + expect(result).toBeUndefined(); + expect(condition.terminated).toBe(false); + }); + + it('should terminate once the timeout has elapsed', async () => { + // Use a very small value so we can wait for it in tests. + const condition = new TimeoutTermination(0.01); // 10ms + // Warm up the start time. + await condition.check([makeTextEvent('trigger start')]); + // Wait slightly longer than the timeout. + await new Promise((resolve) => setTimeout(resolve, 20)); + + const result = await condition.check([makeTextEvent('after timeout')]); + expect(result).toBeDefined(); + expect(result!.reason).toContain('Timeout'); + expect(condition.terminated).toBe(true); + }); + + it('should reset the start time on reset()', async () => { + const condition = new TimeoutTermination(0.01); + await condition.check([makeTextEvent('start')]); + await new Promise((resolve) => setTimeout(resolve, 20)); + await condition.check([makeTextEvent('fires')]); + expect(condition.terminated).toBe(true); + + await condition.reset(); + expect(condition.terminated).toBe(false); + // After reset, a fresh check should start a new timer + const result = await condition.check([makeTextEvent('fresh start')]); + expect(result).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// FunctionCallTermination +// --------------------------------------------------------------------------- + +describe('FunctionCallTermination', () => { + it('should terminate when the named function is executed', async () => { + const condition = new FunctionCallTermination('approve'); + const result = await condition.check([ + makeFunctionResponseEvent('approve'), + ]); + expect(result).toBeDefined(); + expect(result!.reason).toContain('approve'); + expect(condition.terminated).toBe(true); + }); + + it('should not terminate for a different function name', async () => { + const condition = new FunctionCallTermination('approve'); + const result = await condition.check([makeFunctionResponseEvent('search')]); + expect(result).toBeUndefined(); + expect(condition.terminated).toBe(false); + }); + + it('should not terminate on text-only events', async () => { + const condition = new FunctionCallTermination('approve'); + const result = await condition.check([makeTextEvent('approve this')]); + expect(result).toBeUndefined(); + }); + + it('should reset correctly', async () => { + const condition = new FunctionCallTermination('approve'); + await condition.check([makeFunctionResponseEvent('approve')]); + expect(condition.terminated).toBe(true); + + await condition.reset(); + expect(condition.terminated).toBe(false); + }); +}); + +// --------------------------------------------------------------------------- +// ExternalTermination +// --------------------------------------------------------------------------- + +describe('ExternalTermination', () => { + it('should not terminate before set() is called', async () => { + const condition = new ExternalTermination(); + const result = await condition.check([makeTextEvent('anything')]); + expect(result).toBeUndefined(); + expect(condition.terminated).toBe(false); + }); + + it('should terminate immediately after set() is called', async () => { + const condition = new ExternalTermination(); + condition.set(); + const result = await condition.check([makeTextEvent('anything')]); + expect(result).toBeDefined(); + expect(result!.reason).toContain('Externally terminated'); + expect(condition.terminated).toBe(true); + }); + + it('should reset correctly', async () => { + const condition = new ExternalTermination(); + condition.set(); + expect(condition.terminated).toBe(true); + + await condition.reset(); + expect(condition.terminated).toBe(false); + const result = await condition.check([makeTextEvent('should not fire')]); + expect(result).toBeUndefined(); + }); +}); + +// --------------------------------------------------------------------------- +// Combinators: .or() and .and() +// --------------------------------------------------------------------------- + +describe('OrTerminationCondition (.or())', () => { + it('should terminate when the first condition fires', async () => { + const condition = new MaxIterationsTermination(1).or( + new TextMentionTermination('DONE'), + ); + const result = await condition.check([makeTextEvent('any')]); + expect(result).toBeDefined(); + expect(condition.terminated).toBe(true); + }); + + it('should terminate when the second condition fires', async () => { + const condition = new MaxIterationsTermination(100).or( + new TextMentionTermination('DONE'), + ); + const result = await condition.check([makeTextEvent('DONE')]); + expect(result).toBeDefined(); + expect(condition.terminated).toBe(true); + }); + + it('should not terminate when neither condition fires', async () => { + const condition = new MaxIterationsTermination(100).or( + new TextMentionTermination('DONE'), + ); + const result = await condition.check([makeTextEvent('keep going')]); + expect(result).toBeUndefined(); + expect(condition.terminated).toBe(false); + }); + + it('should reset both children on reset()', async () => { + const condition = new MaxIterationsTermination(1).or( + new TextMentionTermination('DONE'), + ); + await condition.check([makeTextEvent('fires')]); + expect(condition.terminated).toBe(true); + + await condition.reset(); + expect(condition.terminated).toBe(false); + }); + + it('should be an instance of OrTerminationCondition', () => { + const condition = new MaxIterationsTermination(1).or( + new TextMentionTermination('X'), + ); + expect(condition).toBeInstanceOf(OrTerminationCondition); + }); +}); + +describe('AndTerminationCondition (.and())', () => { + it('should not terminate when only the first condition fires', async () => { + const left = new MaxIterationsTermination(1); + const condition = left.and(new TextMentionTermination('DONE')); + // Left fires (count=1), right has not fired + const result = await condition.check([makeTextEvent('no keyword here')]); + expect(result).toBeUndefined(); + expect(condition.terminated).toBe(false); + }); + + it('should not terminate when only the second condition fires', async () => { + const condition = new MaxIterationsTermination(100).and( + new TextMentionTermination('DONE'), + ); + const result = await condition.check([makeTextEvent('DONE')]); + expect(result).toBeUndefined(); + expect(condition.terminated).toBe(false); + }); + + it('should terminate when both conditions have fired', async () => { + const left = new MaxIterationsTermination(1); + const right = new TextMentionTermination('DONE'); + const condition = left.and(right); + + // One call: left fires (count hits 1), right fires (text matches) + const result = await condition.check([makeTextEvent('DONE')]); + expect(result).toBeDefined(); + expect(condition.terminated).toBe(true); + }); + + it('should reset both children on reset()', async () => { + const condition = new MaxIterationsTermination(1).and( + new TextMentionTermination('DONE'), + ); + await condition.check([makeTextEvent('DONE')]); + expect(condition.terminated).toBe(true); + + await condition.reset(); + expect(condition.terminated).toBe(false); + }); + + it('should be an instance of AndTerminationCondition', () => { + const condition = new MaxIterationsTermination(1).and( + new TextMentionTermination('X'), + ); + expect(condition).toBeInstanceOf(AndTerminationCondition); + }); +}); From 2712c84331b6bf143ee07c40947c5fad8e84e20b Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Thu, 12 Mar 2026 15:54:52 -0300 Subject: [PATCH 2/3] fix: update makeFunctionResponseEvent to simplify function response creation --- core/test/termination/termination_conditions_test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/core/test/termination/termination_conditions_test.ts b/core/test/termination/termination_conditions_test.ts index f69eb662..89ddf29e 100644 --- a/core/test/termination/termination_conditions_test.ts +++ b/core/test/termination/termination_conditions_test.ts @@ -82,9 +82,8 @@ function makeFunctionResponseEvent(functionName: string): Event { content: { role: 'tool', parts: [ - createPartFromFunctionResponse({ - name: functionName, - response: {result: 'ok'}, + createPartFromFunctionResponse(functionName, functionName, { + result: 'ok', }), ], }, From 84f6ad8b05ab6fd124b861e258f8088a72ae2ae5 Mon Sep 17 00:00:00 2001 From: David Montero Crespo Date: Thu, 12 Mar 2026 16:57:01 -0300 Subject: [PATCH 3/3] Update core/src/termination/termination_condition.ts Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- core/src/termination/termination_condition.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/core/src/termination/termination_condition.ts b/core/src/termination/termination_condition.ts index 7076e577..67d0f501 100644 --- a/core/src/termination/termination_condition.ts +++ b/core/src/termination/termination_condition.ts @@ -101,8 +101,7 @@ export class AndTerminationCondition extends TerminationCondition { return undefined; } // Forward to both children so each accumulates its own state. - await this.left.check(events); - await this.right.check(events); + await Promise.all([this.left.check(events), this.right.check(events)]); if (this.left.terminated && this.right.terminated) { this._terminated = true;