Skip to content
Closed
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
63 changes: 62 additions & 1 deletion core/src/runner/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,7 +296,9 @@ export class Runner {
sessionService: this.sessionService,
memoryService: this.memoryService,
credentialService: this.credentialService,
invocationId: newInvocationContextId(),
invocationId:
resolveResumedInvocationId(session.events, newMessage) ??
newInvocationContextId(),
agent: this.agent,
session,
userContent: newMessage,
Expand Down Expand Up @@ -663,6 +665,65 @@ export function isRoutableLlmAgent(agentToRun: BaseAgent): boolean {
return true;
}

/**
* Resolves the invocation that `newMessage` resumes, if any.
*
* Mirrors `google/adk-python` `runners.py`, which resolves the invocation id
* from a resume message before building the invocation context instead of
* always minting a new one. Workflow node rehydration is scoped by invocation
* id, so this is what lets a genuine resume see the nodes it already ran while
* an unrelated new message in the same session cannot.
*
* Returns `undefined` when the message does not continue anything, in which
* case the caller mints a fresh invocation id as before.
*/
export function resolveResumedInvocationId(
events: Event[],
newMessage?: Content,
): string | undefined {
// 1. The message explicitly answers a pending interrupt.
const responseIds = new Set<string>();
for (const part of newMessage?.parts ?? []) {
const id = part.functionResponse?.id;
if (id) {
responseIds.add(id);
}
}
if (responseIds.size > 0) {
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
for (const id of event.longRunningToolIds ?? []) {
if (responseIds.has(id) && event.invocationId) {
return event.invocationId;
}
}
}
}

// 2. An invocation is still waiting on an unresolved interrupt, so a reply
// that carries no function response (a plain-text answer to a single
// pending request) still continues it.
const answered = new Set<string>();
for (const event of events) {
for (const part of event.content?.parts ?? []) {
const id = part.functionResponse?.id;
if (id) {
answered.add(id);
}
}
}
for (let i = events.length - 1; i >= 0; i--) {
const event = events[i];
for (const id of event.longRunningToolIds ?? []) {
if (!answered.has(id) && event.invocationId) {
return event.invocationId;
}
}
}

return undefined;
}

/**
* It iterates through the events in reverse order, and returns the event
* containing a function call with a functionCall.id matching the
Expand Down
2 changes: 1 addition & 1 deletion core/src/workflow/base_node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ export abstract class BaseNode<TInput = unknown, TOutput = unknown> {
for await (const item of this.runImpl(ctx, validatedInput)) {
if (isRequestInput(item)) {
// HITL: convert a request-for-input into an interrupt event.
yield createRequestInputEvent(item);
yield createRequestInputEvent(item, ctx.invocationContext.invocationId);
continue;
}
const event = this.toEvent(ctx, item);
Expand Down
7 changes: 4 additions & 3 deletions core/src/workflow/dynamic_node_scheduler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,9 +62,10 @@ export class DynamicNodeScheduler implements ScheduleDynamicNode {

// Cross-turn resume: rehydrate this dynamic run from prior session events.
if (!this.state.runs.has(nodePath)) {
const prior = reconstructNodeStatesByPath(ctx.session?.events ?? []).get(
nodePath,
);
const prior = reconstructNodeStatesByPath(
ctx.session?.events ?? [],
ctx.invocationId,
).get(nodePath);
if (prior && !node.rerunOnResume && isFastForwardable(prior)) {
// Completed in a prior turn -> return cached output, do not re-execute.
this.state.runs.set(nodePath, {
Expand Down
6 changes: 5 additions & 1 deletion core/src/workflow/nodes/function_node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,11 @@ export class FunctionNode<TInput = unknown, TOutput = unknown> extends BaseNode<
}
// The credential key doubles as a deterministic interrupt id so the resume
// response matches across turns.
return createAuthRequestEvent(authConfig, authConfig.credentialKey);
return createAuthRequestEvent(
authConfig,
authConfig.credentialKey,
ctx.invocationId,
);
}

/**
Expand Down
8 changes: 7 additions & 1 deletion core/src/workflow/utils/hitl_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ export const REQUEST_CREDENTIAL_FUNCTION_CALL_NAME = 'adk_request_credential';
* carries an `adk_request_input` function call and marks the interrupt id as a
* long-running tool id.
*/
export function createRequestInputEvent(requestInput: RequestInput): Event {
export function createRequestInputEvent(
requestInput: RequestInput,
invocationId?: string,
): Event {
const args: Record<string, unknown> = {
interruptId: requestInput.interruptId,
payload: requestInput.payload ?? null,
Expand All @@ -58,6 +61,7 @@ export function createRequestInputEvent(requestInput: RequestInput): Event {
],
},
longRunningToolIds: [requestInput.interruptId],
invocationId,
});
}

Expand Down Expand Up @@ -125,6 +129,7 @@ export function hasAuthCredential(
export function createAuthRequestEvent(
authConfig: AuthConfig,
interruptId: string,
invocationId?: string,
): Event {
const authRequest = new AuthHandler(authConfig).generateAuthRequest();
const args: Record<string, unknown> = {
Expand All @@ -146,6 +151,7 @@ export function createAuthRequestEvent(
],
},
longRunningToolIds: [interruptId],
invocationId,
});
}

Expand Down
33 changes: 26 additions & 7 deletions core/src/workflow/utils/rehydration_utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,25 @@ export interface RehydratedNode {
export function reconstructNodeStates(
events: Event[],
parentPath?: string,
invocationId?: string,
): Map<string, RehydratedNode> {
if (parentPath) {
return reconstruct(events, (event) =>
event.nodeInfo?.path
? directChildName(event.nodeInfo.path, parentPath)
: undefined,
return reconstruct(
events,
(event) =>
event.nodeInfo?.path
? directChildName(event.nodeInfo.path, parentPath)
: undefined,
invocationId,
);
}
return reconstruct(events, (event) =>
event.nodeInfo?.path ? nodeNameFromPath(event.nodeInfo.path) : event.author,
return reconstruct(
events,
(event) =>
event.nodeInfo?.path
? nodeNameFromPath(event.nodeInfo.path)
: event.author,
invocationId,
);
}

Expand All @@ -67,14 +76,20 @@ export function reconstructNodeStates(
*/
export function reconstructNodeStatesByPath(
events: Event[],
invocationId?: string,
): Map<string, RehydratedNode> {
return reconstruct(events, (event) => event.nodeInfo?.path ?? event.author);
return reconstruct(
events,
(event) => event.nodeInfo?.path ?? event.author,
invocationId,
);
}

/** Shared scan that groups node events by the key returned by `keyFor`. */
function reconstruct(
events: Event[],
keyFor: (event: Event) => string | undefined,
invocationId?: string,
): Map<string, RehydratedNode> {
const nodes = new Map<string, RehydratedNode>();
const interruptOwner = new Map<string, string>();
Expand All @@ -89,6 +104,10 @@ function reconstruct(
};

for (const event of events) {
if (invocationId && event.invocationId !== invocationId) {
continue;
}

// 1. User function responses resolving prior interrupts.
if (event.author === 'user' && event.content?.parts) {
for (const part of event.content.parts) {
Expand Down
1 change: 1 addition & 0 deletions core/src/workflow/workflow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,7 @@ export class Workflow extends BaseNode {
const rehydrated = reconstructNodeStates(
ctx.session?.events ?? [],
ctx.nodePath || undefined,
ctx.invocationId,
);
this.applyResumeInputs(ctx, rehydrated);

Expand Down
6 changes: 5 additions & 1 deletion core/src/workflow/workflow_agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,11 @@ function resumeInputsFromPlainText(
const text = parts.map((p) => p.text).join('');

const pending = new Set<string>();
for (const node of reconstructNodeStates(ic.session?.events ?? []).values()) {
for (const node of reconstructNodeStates(
ic.session?.events ?? [],
undefined,
ic.invocationId,
).values()) {
for (const id of node.interruptIds) {
if (!node.resolvedResponses.has(id)) {
pending.add(id);
Expand Down
Loading
Loading