Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
5a80d06
feat(workflow): implement Python ADK workflow parity with strict Type…
kalenkevich Jul 15, 2026
bb615bf
test(workflows): add comprehensive workflow integration tests and par…
kalenkevich Jul 15, 2026
e8b8288
fix(workflow): resolve strict TypeScript build and EventAction errors…
kalenkevich Jul 15, 2026
3387e35
feat(workflow-next): Phase 0 foundations for workflow rewrite
kalenkevich Jul 22, 2026
7886de5
feat(workflow-next): Phase 1 core execution model
kalenkevich Jul 22, 2026
fd18460
feat(workflow-next): Phase 2 graph orchestration engine
kalenkevich Jul 22, 2026
ea2f548
feat(workflow-next): Phase 3 user-facing node API
kalenkevich Jul 22, 2026
3e16d7f
feat(workflow-next): Phase 4 dynamic node scheduling
kalenkevich Jul 22, 2026
31349cb
feat(workflow-next): Phase 5a human-in-the-loop core
kalenkevich Jul 22, 2026
f49dd8a
feat(workflow-next): Phase 6 parallelism and branches
kalenkevich Jul 22, 2026
42c18e1
feat(workflow-next): Phase 7a run LlmAgent as a node (single_turn)
kalenkevich Jul 22, 2026
61db53d
feat(workflow-next): Phase 8 Runner integration and public barrel
kalenkevich Jul 22, 2026
7ecf669
refactor(workflow)!: cut over to the new BaseNode/Context workflow en…
kalenkevich Jul 22, 2026
b7c4887
feat(workflow): Phase 5b static-graph resume via session rehydration
kalenkevich Jul 22, 2026
4572020
feat(workflow): Phase 5b-cont dynamic (ctx.runNode) resume + dedup
kalenkevich Jul 23, 2026
4645c11
feat(workflow): Phase 5b-cont auth gate on FunctionNode
kalenkevich Jul 23, 2026
8908f12
feat(workflow): Phase 7b multi-agent hand-off (transfer_to_agent)
kalenkevich Jul 23, 2026
020729c
test(workflow): add integration tests for major workflow use cases
kalenkevich Jul 23, 2026
aabcc0c
fix(workflow): resume a waiting node with its original input
kalenkevich Jul 23, 2026
9724e34
test(workflow): add essential coverage for parsing, validation, routi…
kalenkevich Jul 23, 2026
a5a1a28
test(workflow): add integration tests for dynamic, HITL-chain, and LL…
kalenkevich Jul 23, 2026
4f5869b
test(workflow): add integration tests for tools, retry exhaustion, ne…
kalenkevich Jul 23, 2026
ccca076
test(workflow): add integration tests for auth, routed loops, multi-t…
kalenkevich Jul 23, 2026
53a7578
docs(workflow): document public workflow types and suppress internal …
kalenkevich Jul 23, 2026
6f93edd
samples(workflow): add runnable ports of all Python workflow samples
kalenkevich Jul 24, 2026
180df60
feat(workflow): expose current attempt count on NodeContext
kalenkevich Jul 24, 2026
28f67ad
samples(workflow): port workflow samples faithfully from adk-python
kalenkevich Jul 24, 2026
6b08239
feat(workflow): complete an interrupted node with its resume value on…
kalenkevich Jul 24, 2026
b7104d0
test(workflow): cover resume-value completion and plain-text resume
kalenkevich Jul 24, 2026
459914a
samples(workflow): port HITL, auth, and routing samples faithfully fr…
kalenkevich Jul 24, 2026
2a01c46
feat(tools): add FunctionTool require_confirmation for HITL tool appr…
kalenkevich Jul 27, 2026
9efb28c
feat(agents): run nodes/workflows as agent tools and add LlmAgent tas…
kalenkevich Jul 27, 2026
06501d3
samples(workflow): faithfully port node_as_tool and agent_in_workflow
kalenkevich Jul 27, 2026
eec1870
fix(workflow): boolean/multi-value routes and serialization fidelity
kalenkevich Jul 27, 2026
95348c1
fix(workflow): actually cancel a node when its timeout elapses
kalenkevich Jul 27, 2026
dfeb412
feat(workflow): resolve {Class.field} and <field from node> instructi…
kalenkevich Jul 27, 2026
e8ccf98
fix(workflow): assign ParallelWorker child run ids by item index
kalenkevich Jul 27, 2026
fee379e
test(workflow): guard resume against DB serialization key-mangling
kalenkevich Jul 27, 2026
da541a7
fix(workflow): persist LLMAgentWrapper's injected user turn
kalenkevich Jul 27, 2026
1cfdb63
fix(workflow): scope resume rehydration to a workflow's own child nodes
kalenkevich Jul 27, 2026
6fab873
refactor(events)!: drop the EventActions catch-all index signature
kalenkevich Jul 27, 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
68 changes: 58 additions & 10 deletions core/src/agents/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,36 @@
*/

import {State} from '../sessions/state.js';
import type {WorkflowInstructionScope} from './invocation_context.js';
import {ReadonlyContext} from './readonly_context.js';

const ARTIFACT_PREFIX = 'artifact.';

/** Matches a `{Class.field}` workflow placeholder key (dotted identifier pair). */
const WORKFLOW_FIELD_KEY = /^[A-Za-z_]\w*\.[A-Za-z_]\w*$/;

/** Matches a `<Class.field from source_node>` workflow placeholder. */
const SOURCE_NODE_PLACEHOLDER =
/<\s*[A-Za-z_]\w*\.([A-Za-z_]\w*)\s+from\s+([A-Za-z_]\w*)\s*>/g;

/**
* Resolves `<Class.field from source_node>` placeholders against a workflow
* scope (predecessor outputs by node name). Synchronous; unresolved placeholders
* are left untouched. Mirrors Python's source-node-qualified data selection.
*/
function resolveSourceNodePlaceholders(
template: string,
scope: WorkflowInstructionScope,
): string {
return template.replace(SOURCE_NODE_PLACEHOLDER, (raw, field, nodeName) => {
const out = scope.outputsByNode?.[nodeName];
if (out && typeof out === 'object' && field in (out as object)) {
return formatValue((out as Record<string, unknown>)[field], false);
}
return raw;
});
}

/**
* Resolves a single key from the context (state or artifact).
*/
Expand Down Expand Up @@ -39,19 +65,30 @@ async function resolveKey(
}

// Step 3: Handle state variable injection.
if (!isValidStateName(key)) {
return rawMatch;
}

if (key in invocationContext.session.state) {
return formatValue(invocationContext.session.state[key], false);
if (isValidStateName(key)) {
if (key in invocationContext.session.state) {
return formatValue(invocationContext.session.state[key], false);
}
if (isOptional) {
return '';
}
throw new Error(`Context variable not found: \`${key}\`.`);
}

if (isOptional) {
return '';
// Step 4: Workflow — resolve `{Class.field}` from the current node input.
const scope = invocationContext.workflowInstructionScope;
if (scope && WORKFLOW_FIELD_KEY.test(key)) {
const field = key.slice(key.indexOf('.') + 1);
const input = scope.input;
if (input && typeof input === 'object' && field in (input as object)) {
return formatValue((input as Record<string, unknown>)[field], false);
}
if (isOptional) {
return '';
}
}

throw new Error(`Context variable not found: \`${key}\`.`);
return rawMatch;
}

/**
Expand Down Expand Up @@ -115,6 +152,14 @@ export async function injectSessionState(
template: string,
readonlyContext: ReadonlyContext,
): Promise<string> {
// Workflow: first resolve `<Class.field from source_node>` placeholders, and
// enable `{Class.field}` resolution below. Both are no-ops (placeholders left
// untouched) for ordinary agents, which have no workflow scope.
const scope = readonlyContext.invocationContext.workflowInstructionScope;
if (scope) {
template = resolveSourceNodePlaceholders(template, scope);
}

const pattern = /\{+[^{}]*}+/g;
const matches = Array.from(template.matchAll(pattern));

Expand All @@ -130,7 +175,10 @@ export async function injectSessionState(
if (isOptional) {
key = key.slice(0, -1);
}
const isValid = key.startsWith(ARTIFACT_PREFIX) || isValidStateName(key);
const isValid =
key.startsWith(ARTIFACT_PREFIX) ||
isValidStateName(key) ||
(!!scope && WORKFLOW_FIELD_KEY.test(key));
return {
raw,
key,
Expand Down
48 changes: 48 additions & 0 deletions core/src/agents/invocation_context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,32 @@ import {Content} from '@google/genai';

import {SessionArtifactService} from '../artifacts/session_artifact_service.js';
import {BaseCredentialService} from '../auth/credential_service/base_credential_service.js';
import {Event} from '../events/event.js';
import {BaseMemoryService} from '../memory/base_memory_service.js';
import {PluginManager} from '../plugins/plugin_manager.js';
import {BaseSessionService} from '../sessions/base_session_service.js';
import {Session} from '../sessions/session.js';
import {randomUUID} from '../utils/env_aware_utils.js';
import {EventChannel} from '../workflow/utils/event_channel.js';

import {ActiveStreamingTool} from './active_streaming_tool.js';
import {BaseAgent} from './base_agent.js';
import {RunConfig} from './run_config.js';
import {TranscriptionEntry} from './transcription_entry.js';

/**
* Workflow: data exposed to `{Class.field}` and `<Class.field from source_node>`
* instruction placeholders when an LlmAgent runs as a workflow node. Populated by
* `LLMAgentWrapper`; absent for ordinary (non-workflow) agent runs, in which case
* those placeholders are left untouched.
*/
export interface WorkflowInstructionScope {
/** The current node's input, exposing fields for `{Class.field}`. */
input?: unknown;
/** Predecessor node outputs keyed by node name, for `<Class.field from node>`. */
outputsByNode?: Record<string, unknown>;
}

/**
* The parameters for creating an invocation context.
*/
Expand All @@ -38,6 +53,9 @@ export interface InvocationContextParams {
activeStreamingTools?: Record<string, ActiveStreamingTool>;
pluginManager: PluginManager;
abortSignal?: AbortSignal;
agentStates?: Record<string, unknown>;
endOfAgents?: Record<string, boolean>;
workflowInstructionScope?: WorkflowInstructionScope;
}

/**
Expand Down Expand Up @@ -185,6 +203,32 @@ export class InvocationContext {

readonly abortSignal?: AbortSignal;

/**
* An optional channel into which a running tool can push events to be
* interleaved into the agent's output stream. Set by the LLM flow around tool
* execution so a {@link NodeTool} (running a node/workflow) can surface the
* node's intermediate and interrupt events. Cleared once tools finish.
*/
eventQueue?: EventChannel<Event>;

/**
* Checkpointed states for workflow nodes under this invocation.
*/
agentStates: Record<string, unknown>;

/**
* Tracks whether specific agents or workflows have reached the end of their execution.
*/

endOfAgents: Record<string, boolean>;

/**
* Workflow: field-resolution scope for `{Class.field}` /
* `<Class.field from node>` instruction placeholders (set by
* `LLMAgentWrapper`).
*/
workflowInstructionScope?: WorkflowInstructionScope;

/**
* @param params The parameters for creating an invocation context.
*/
Expand All @@ -203,7 +247,11 @@ export class InvocationContext {
this.activeStreamingTools = params.activeStreamingTools;
this.pluginManager = params.pluginManager;
this.abortSignal = params.abortSignal;
this.agentStates = params.agentStates ?? {};
this.endOfAgents = params.endOfAgents ?? {};
this.workflowInstructionScope = params.workflowInstructionScope;
// Inherit the parent invocation's cost manager when one is available.

// Child contexts created for sub-agents, agent transfers and loop
// iterations (via createInvocationContext / createBranchCtxForSubAgent)
// carry the parent context's fields over, so reusing its cost manager
Expand Down
84 changes: 75 additions & 9 deletions core/src/agents/llm_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,11 @@

import {GenerateContentConfig, Schema} from '@google/genai';
import {context, trace} from '@opentelemetry/api';
import {FinishTaskTool} from '../tools/finish_task_tool.js';
import {FunctionTool} from '../tools/function_tool.js';
import {BaseNode} from '../workflow/base_node.js';
import {NodeTool} from '../workflow/nodes/node_tool.js';
import {EventChannel} from '../workflow/utils/event_channel.js';

import {z as z3} from 'zod/v3';
import {z as z4} from 'zod/v4';
Expand Down Expand Up @@ -67,6 +71,7 @@ import {IDENTITY_LLM_REQUEST_PROCESSOR} from './processors/identity_llm_request_
import {INSTRUCTIONS_LLM_REQUEST_PROCESSOR} from './processors/instructions_llm_request_processor.js';
import {INTERACTIONS_REQUEST_PROCESSOR} from './processors/interactions_request_processor.js';
import {REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR} from './processors/request_confirmation_llm_request_processor.js';
import {REQUEST_INPUT_LLM_REQUEST_PROCESSOR} from './processors/request_input_llm_request_processor.js';
import {TOOL_FILTER_REQUEST_PROCESSOR} from './processors/tool_filter_request_processor.js';
import {ReadonlyContext} from './readonly_context.js';
import {StreamingMode} from './run_config.js';
Expand Down Expand Up @@ -192,7 +197,7 @@ export type AfterToolCallback =
export type ExamplesUnion = Example[] | BaseExampleProvider;

/** A union of tool types that can be provided to an agent. */
export type ToolUnion = BaseTool | BaseToolset;
export type ToolUnion = BaseTool | BaseToolset | BaseNode;

const ADK_AGENT_NAME_LABEL_KEY = 'adk_agent_name';

Expand Down Expand Up @@ -258,6 +263,16 @@ export interface LlmAgentConfig extends BaseAgentConfig {
*/
includeContents?: 'default' | 'none';

/**
* The agent's execution mode when run as a workflow node.
*
* - `single_turn` (default): the agent runs once against the node input.
* - `task`: the agent is given a `finish_task` tool and runs a multi-round
* loop until it calls `finish_task`, whose arguments (conforming to
* `outputSchema`) become the node output. Mirrors Python's `Agent(mode=...)`.
*/
mode?: 'single_turn' | 'task';

/** The input schema when agent is used as a tool. */
inputSchema?: LlmAgentSchema;

Expand Down Expand Up @@ -322,6 +337,11 @@ async function convertToolUnionToTools(
if (isBaseTool(toolUnion)) {
return [toolUnion];
}
if (toolUnion instanceof BaseNode) {
// A node/Workflow passed as a tool is auto-wrapped as a NodeTool so the
// model can call it (mirrors Python's Agent(tools=[node/workflow])).
return [new NodeTool(toolUnion)];
}
return await toolUnion.getTools(context);
}

Expand Down Expand Up @@ -361,9 +381,11 @@ export class LlmAgent extends BaseAgent {
disallowTransferToParent: boolean;
disallowTransferToPeers: boolean;
includeContents: 'default' | 'none';
mode?: 'single_turn' | 'task';
inputSchema?: Schema;
outputSchema?: Schema;
outputKey?: string;
private _finishTaskTool?: FinishTaskTool;
beforeModelCallback?: BeforeModelCallback;
afterModelCallback?: AfterModelCallback;
beforeToolCallback?: BeforeToolCallback;
Expand All @@ -388,6 +410,7 @@ export class LlmAgent extends BaseAgent {
this.outputSchema = isZodObject(config.outputSchema)
? zodObjectToSchema(config.outputSchema)
: config.outputSchema;
this.mode = config.mode;
this.outputKey = config.outputKey;
this.beforeModelCallback = config.beforeModelCallback;
this.afterModelCallback = config.afterModelCallback;
Expand All @@ -403,6 +426,7 @@ export class LlmAgent extends BaseAgent {
IDENTITY_LLM_REQUEST_PROCESSOR,
INSTRUCTIONS_LLM_REQUEST_PROCESSOR,
REQUEST_CONFIRMATION_LLM_REQUEST_PROCESSOR,
REQUEST_INPUT_LLM_REQUEST_PROCESSOR,
CONTENT_REQUEST_PROCESSOR,
INTERACTIONS_REQUEST_PROCESSOR,
CODE_EXECUTION_REQUEST_PROCESSOR,
Expand Down Expand Up @@ -499,6 +523,17 @@ export class LlmAgent extends BaseAgent {
throw new Error(`No model found for ${this.name}.`);
}

/**
* The `finish_task` tool for this agent (task mode). Lazily created and cached
* so its declaration (derived from `outputSchema`) is stable across turns.
*/
get finishTaskTool(): FinishTaskTool {
if (!this._finishTaskTool) {
this._finishTaskTool = new FinishTaskTool(this.outputSchema);
}
return this._finishTaskTool;
}

/**
* The resolved instruction field to construct instruction for this
* agent.
Expand Down Expand Up @@ -787,7 +822,11 @@ export class LlmAgent extends BaseAgent {
// TODO - b/425992518: check if tool preprocessors can be simplified.
// Run pre-processors for tools.
const allTools = [...this.tools];
if (this.outputSchema && allTools.length > 0) {
if (this.mode === 'task') {
// Task mode: the agent completes by calling `finish_task` (whose params
// mirror the output schema) rather than emitting structured output.
allTools.push(this.finishTaskTool);
} else if (this.outputSchema && allTools.length > 0) {
const setModelResponseTool = new FunctionTool({
name: 'set_model_response',
description:
Expand Down Expand Up @@ -973,13 +1012,40 @@ export class LlmAgent extends BaseAgent {
// Call functions
// TODO - b/425992518: bloated funciton input, fix.
// Tool callback passed to get rid of cyclic dependency.
const functionResponseEvent = await handleFunctionCallsAsync({
invocationContext: invocationContext,
functionCallEvent: mergedEvent,
toolsDict: llmRequest.toolsDict,
beforeToolCallbacks: this.canonicalBeforeToolCallbacks,
afterToolCallbacks: this.canonicalAfterToolCallbacks,
});
// A NodeTool (running a node/workflow) streams the node's intermediate and
// interrupt events into `invocationContext.eventQueue`; drain it concurrently
// so those events interleave into this agent's output stream. The tool runs
// in a self-contained task that captures its result/error and always closes
// the queue, so there is a single error path (no unhandled rejection).
const eventQueue = new EventChannel<Event>();
invocationContext.eventQueue = eventQueue;
const toolTask = (async (): Promise<{
event: Event | null;
error?: unknown;
}> => {
try {
const event = await handleFunctionCallsAsync({
invocationContext: invocationContext,
functionCallEvent: mergedEvent,
toolsDict: llmRequest.toolsDict,
beforeToolCallbacks: this.canonicalBeforeToolCallbacks,
afterToolCallbacks: this.canonicalAfterToolCallbacks,
});
return {event};
} catch (error) {
return {event: null, error};
} finally {
eventQueue.close();
}
})();
for await (const queuedEvent of eventQueue) {
yield queuedEvent;
}
const {event: functionResponseEvent, error: toolError} = await toolTask;
invocationContext.eventQueue = undefined;
if (toolError) {
throw toolError;
}

if (!functionResponseEvent || invocationContext.abortSignal?.aborted) {
return;
Expand Down
9 changes: 8 additions & 1 deletion core/src/agents/processors/basic_llm_request_processor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ export class BasicLlmRequestProcessor extends BaseLlmRequestProcessor {
llmRequest.model = agent.canonicalModel.model;

llmRequest.config = {...(agent.generateContentConfig ?? {})};
if (agent.outputSchema && (!agent.tools || agent.tools.length === 0)) {
// Task-mode agents complete via the `finish_task` tool, so the JSON response
// mode must not be set (function calling is incompatible with a JSON
// response mime type).
if (
agent.outputSchema &&
agent.mode !== 'task' &&
(!agent.tools || agent.tools.length === 0)
) {
setOutputSchema(llmRequest, agent.outputSchema);
}

Expand Down
Loading