Skip to content
5 changes: 5 additions & 0 deletions .changeset/agent-chain-propagation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@funkai/agents": minor
---

Add `AgentChainEntry` type and `agentChain` field to `StepInfo` and `StepFinishEvent` for agent ancestry tracking. Forward `onStepStart`/`onStepFinish` hooks from flow agent `$.agent()` to sub-agents, enabling full observability of nested agent steps from root hooks.
16 changes: 14 additions & 2 deletions packages/agents/src/core/agents/base/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { resolveOutput } from "@/core/agents/base/output.js";
import type { OutputParam, OutputSpec } from "@/core/agents/base/output.js";
import {
buildAITools,
extractAgentChain,
resolveValue,
resolveOptionalValue,
buildPrompt,
Expand All @@ -26,7 +27,7 @@ import { createDefaultLogger } from "@/core/logger.js";
import type { Logger } from "@/core/logger.js";
import type { LanguageModel } from "@/core/provider/types.js";
import type { Tool } from "@/core/tool.js";
import type { Model, StepFinishEvent, StreamPart } from "@/core/types.js";
import type { AgentChainEntry, Model, StepFinishEvent, StreamPart } from "@/core/types.js";
import { fireHooks, wrapHook } from "@/lib/hooks.js";
import { withModelMiddleware } from "@/lib/middleware.js";
import { AGENT_CONFIG, RUNNABLE_META } from "@/lib/runnable.js";
Expand Down Expand Up @@ -215,6 +216,10 @@ export function agent<
const hasTools = Object.keys(mergedTools).length > 0;
const hasAgents = Object.keys(mergedAgents).length > 0;

// Build agent chain: extend incoming chain with this agent's identity
const incomingChain = extractAgentChain(params);
const currentChain: readonly AgentChainEntry[] = [...incomingChain, { id: config.name }];

// Only fixed-type hooks (onStepStart, onStepFinish) are forwarded to
// Sub-agents. Generic hooks (onStart, onFinish, onError) are NOT
// Forwarded because their event types are parameterized by TInput/TOutput
Expand All @@ -226,6 +231,7 @@ export function agent<
log,
onStepStart: params.onStepStart,
onStepFinish: buildMergedHook(log, config.onStepFinish, params.onStepFinish),
agentChain: currentChain,
};

const aiTools = buildAITools(
Expand Down Expand Up @@ -266,7 +272,13 @@ export function agent<
return { toolName: tr.toolName, resultTextLength: safeSerializedLength(result) };
});
const usage = extractUsage(step.usage);
const event: StepFinishEvent = { stepId, toolCalls, toolResults, usage };
const event: StepFinishEvent = {
stepId,
toolCalls,
toolResults,
usage,
agentChain: currentChain,
};
await fireHooks(
log,
wrapHook(config.onStepFinish, event),
Expand Down
46 changes: 45 additions & 1 deletion packages/agents/src/core/agents/base/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import type { Agent, Message, Resolver } from "@/core/agents/types.js";
import type { Logger } from "@/core/logger.js";
import type { TokenUsage } from "@/core/provider/types.js";
import type { Tool } from "@/core/tool.js";
import type { StepFinishEvent, StepInfo } from "@/core/types.js";
import type { AgentChainEntry, StepFinishEvent, StepInfo } from "@/core/types.js";
import { RUNNABLE_META } from "@/lib/runnable.js";
import type { RunnableMeta } from "@/lib/runnable.js";

Expand Down Expand Up @@ -68,6 +68,13 @@ export interface ParentAgentContext {
* Uses `StepFinishEvent` — a fixed (non-generic) type, safe to forward.
*/
onStepFinish?: (event: StepFinishEvent) => void | Promise<void>;

/**
* Agent ancestry chain from root to the current agent.
*
* @internal Framework-only — not exposed on public `GenerateParams`.
*/
agentChain?: readonly AgentChainEntry[];
}

/**
Expand Down Expand Up @@ -414,6 +421,42 @@ function buildAgentTool(
*
* @private
*/
/**
* Shared empty chain — avoids allocating a new `[]` on every
* top-level agent call where no parent chain exists.
*
* @private
*/
const EMPTY_CHAIN: readonly AgentChainEntry[] = [];

/**
* Extract the internal `agentChain` from raw generate params.
*
* `agentChain` is a framework-internal transport field — it is NOT
* on the public `GenerateParams` type. It's passed via untyped
* spreads from `buildParentParams` and flow agent `$.agent()` calls.
*
* @param params - The raw generate params object.
* @returns The agent chain array, or an empty array if absent.
*
* @example
* ```ts
* const chain = extractAgentChain({ agentChain: [{ id: "root" }] });
* // => [{ id: "root" }]
*
* const empty = extractAgentChain({});
* // => []
* ```
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- agentChain is an internal transport field not on the public type; must access via untyped cast
export function extractAgentChain(params: unknown): readonly AgentChainEntry[] {
const raw = params as Record<string, unknown>;
if (Array.isArray(raw.agentChain)) {
return raw.agentChain as readonly AgentChainEntry[];
}
return EMPTY_CHAIN;
}

function buildParentParams(ctx: ParentAgentContext | undefined): Record<string, unknown> {
if (isNil(ctx)) {
return {};
Expand All @@ -423,6 +466,7 @@ function buildParentParams(ctx: ParentAgentContext | undefined): Record<string,
logger: ctx.log,
onStepStart: ctx.onStepStart,
onStepFinish: ctx.onStepFinish,
agentChain: ctx.agentChain,
},
isNil,
);
Expand Down
9 changes: 7 additions & 2 deletions packages/agents/src/core/agents/flow/flow-agent.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { AsyncIterableStream } from "ai";
import { isNil, isNotNil } from "es-toolkit";

import { resolveOptionalValue } from "@/core/agents/base/utils.js";
import { extractAgentChain, resolveOptionalValue } from "@/core/agents/base/utils.js";
import {
collectTextFromMessages,
createAssistantMessage,
Expand All @@ -24,7 +24,7 @@ import type { GenerateParams, GenerateResult, Message, StreamResult } from "@/co
import { createDefaultLogger } from "@/core/logger.js";
import type { Logger } from "@/core/logger.js";
import type { TokenUsage } from "@/core/provider/types.js";
import type { StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js";
import type { AgentChainEntry, StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js";
import type { Context } from "@/lib/context.js";
import { fireHooks, wrapHook } from "@/lib/hooks.js";
import { FLOW_AGENT_CONFIG, RUNNABLE_META } from "@/lib/runnable.js";
Expand Down Expand Up @@ -326,6 +326,10 @@ export function flowAgent<TInput, TOutput = any>(
const messages: Message[] = [];
const ctx: Context = { signal, log, trace, messages };

// Build agent chain: extend incoming chain with this flow agent's identity
const incomingChain = extractAgentChain(params);
const currentChain: readonly AgentChainEntry[] = [...incomingChain, { id: config.name }];

const mergedOnStepStart = buildMergedStepStartHook(log, config.onStepStart, params.onStepStart);
const mergedOnStepFinish = buildMergedStepFinishHook(
log,
Expand All @@ -340,6 +344,7 @@ export function flowAgent<TInput, TOutput = any>(
onStepFinish: mergedOnStepFinish,
},
writer,
agentChain: currentChain,
});

const $ = augmentStepBuilder(base$, ctx, _internal);
Expand Down
81 changes: 75 additions & 6 deletions packages/agents/src/core/agents/flow/steps/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
/* oxlint-disable import/max-dependencies -- step factory requires many internal modules */
import type { GenerateResult, StreamResult } from "@/core/agents/types.js";
import type { TokenUsage } from "@/core/provider/types.js";
import type { StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js";
import type { AgentChainEntry, StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js";
import type { Context } from "@/lib/context.js";
import { fireHooks } from "@/lib/hooks.js";
import type { TraceEntry, OperationType } from "@/lib/trace.js";
Expand Down Expand Up @@ -55,6 +55,17 @@
* step events through the readable stream.
*/
writer?: WritableStreamDefaultWriter<StreamPart>;

/**
* Agent ancestry chain from root to the current flow agent.
*
* Attached to every `StepInfo` and `StepFinishEvent` so hook
* consumers can trace which agent produced the event. Also
* forwarded to sub-agents called via `$.agent()`.
*
* @internal Framework-only — not exposed to users.
*/
agentChain?: readonly AgentChainEntry[];
}

/**
Expand Down Expand Up @@ -88,7 +99,7 @@
* the same ref so step indices are globally unique.
*/
function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexRef): StepBuilder {
const { ctx, parentHooks, writer } = options;
const { ctx, parentHooks, writer, agentChain } = options;

/**
* Core step primitive — every other method delegates here.
Expand Down Expand Up @@ -120,7 +131,7 @@
}): Promise<StepResult<T>> {
const { id, type, execute, input, onStart, onFinish, onError } = params;

const stepInfo: StepInfo = { id, index: indexRef.current++, type };
const stepInfo: StepInfo = { id, index: indexRef.current++, type, agentChain };
const startedAt = Date.now();

const childTrace: TraceEntry[] = [];
Expand All @@ -130,7 +141,10 @@
trace: childTrace,
messages: ctx.messages,
};
const child$ = createStepBuilderInternal({ ctx: childCtx, parentHooks, writer }, indexRef);
const child$ = createStepBuilderInternal(
{ ctx: childCtx, parentHooks, writer, agentChain },
indexRef,
);

// Build synthetic tool-call message and push to context
const toolCallId = buildToolCallId(id, stepInfo.index);
Expand Down Expand Up @@ -202,7 +216,7 @@
fn({ id, result: value as T, duration }),
);
const parentOnStepFinishHook = buildParentHookCallback(parentHooks, "onStepFinish", (fn) =>
fn({ step: stepInfo, result: value, duration }),
fn({ step: stepInfo, result: value, duration, agentChain }),
);

await fireHooks(ctx.log, onFinishHook, parentOnStepFinishHook);
Expand Down Expand Up @@ -255,7 +269,7 @@

const onErrorHook = buildHookCallback(onError, (fn) => fn({ id, error }));
const parentOnStepFinishHook = buildParentHookCallback(parentHooks, "onStepFinish", (fn) =>
fn({ step: stepInfo, result: undefined, duration }),
fn({ step: stepInfo, result: undefined, duration, agentChain }),
);

await fireHooks(ctx.log, onErrorHook, parentOnStepFinishHook);
Expand All @@ -274,11 +288,16 @@
type: "agent",
input: config.input,
execute: async () => {
// Forward fixed-type step hooks and agent chain to sub-agent.
// Merge parent + child hooks so neither side is clobbered.
const mergedHooks = mergeStepHooks(parentHooks, config.config);
const agentParams = {
...config.config,
input: config.input,
signal: ctx.signal,
logger: ctx.log.child({ stepId: config.id }),
...mergedHooks,
agentChain,
};

// When stream: true and a writer is available, use agent.stream()
Expand Down Expand Up @@ -585,6 +604,56 @@
return (event) => onFinish({ id: event.id, result: event.result, duration: event.duration });
}

/**
* Merge parent flow step hooks with delegated-agent step hooks.
*
* When both parent and child define the same hook, the merged callback
* fires the child hook first, then the parent hook. Only defined hooks
* are included in the result so `undefined` values never clobber
* a child-only hook via object spread.
*
* @private
*/
function mergeStepHooks(
parentHooks: StepBuilderOptions["parentHooks"],
childConfig: Record<string, unknown> | undefined,
): Record<string, unknown> {
const parentStart = isNil(parentHooks) ? undefined : parentHooks.onStepStart;

Check failure on line 621 in packages/agents/src/core/agents/flow/steps/factory.ts

View workflow job for this annotation

GitHub Actions / ci

eslint(no-ternary)

Unexpected use of ternary expression
const parentFinish = isNil(parentHooks) ? undefined : parentHooks.onStepFinish;

Check failure on line 622 in packages/agents/src/core/agents/flow/steps/factory.ts

View workflow job for this annotation

GitHub Actions / ci

eslint(no-ternary)

Unexpected use of ternary expression
const childStart = isNil(childConfig)
? undefined
: (childConfig.onStepStart as typeof parentStart);

Check failure on line 625 in packages/agents/src/core/agents/flow/steps/factory.ts

View workflow job for this annotation

GitHub Actions / ci

eslint(no-ternary)

Unexpected use of ternary expression
const childFinish = isNil(childConfig)
? undefined
: (childConfig.onStepFinish as typeof parentFinish);

Check failure on line 628 in packages/agents/src/core/agents/flow/steps/factory.ts

View workflow job for this annotation

GitHub Actions / ci

eslint(no-ternary)

Unexpected use of ternary expression

const result: Record<string, unknown> = {};

if (isNotNil(parentStart) && isNotNil(childStart)) {
result.onStepStart = async (event: { step: StepInfo }) => {
await childStart(event);
await parentStart(event);
};
} else if (isNotNil(parentStart)) {
result.onStepStart = parentStart;
} else if (isNotNil(childStart)) {
result.onStepStart = childStart;
}

if (isNotNil(parentFinish) && isNotNil(childFinish)) {
result.onStepFinish = async (event: StepFinishEvent) => {
await childFinish(event);
await parentFinish(event);
};
} else if (isNotNil(parentFinish)) {
result.onStepFinish = parentFinish;
} else if (isNotNil(childFinish)) {
result.onStepFinish = childFinish;
}

return result;
}

/**
* Sequentially reduce items with abort support using tail-recursive iteration.
*
Expand Down
49 changes: 49 additions & 0 deletions packages/agents/src/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,28 @@ export type Model = LanguageModel;
*/
export type StreamPart = TextStreamPart<ToolSet>;

/**
* An entry in the agent chain — identifies one agent in the
* ancestry from root to current.
*
* Uses an object (not a bare string) so additional fields can be
* added later without breaking consumers.
*
* @example
* ```typescript
* // Root flow → sub-agent → sub-sub-agent
* const chain: AgentChainEntry[] = [
* { id: 'pipeline' },
* { id: 'researcher' },
* { id: 'search' },
* ]
* ```
*/
export interface AgentChainEntry {
/** Agent name (matches `config.name`). */
readonly id: string;
}

/**
* Information about a step in execution.
*
Expand Down Expand Up @@ -74,6 +96,21 @@ export interface StepInfo {
* Discriminant for filtering or grouping step events.
*/
type: OperationType;

/**
* Agent ancestry chain from root to the agent that owns this step.
*
* Each entry identifies one agent in the chain. The first entry is
* the root agent, the last is the agent that produced this step.
*
* @example
* ```typescript
* // Step inside a sub-agent called by a flow agent:
* event.step.agentChain
* // → [{ id: 'pipeline' }, { id: 'writer' }]
* ```
*/
agentChain?: readonly AgentChainEntry[];
}

/**
Expand Down Expand Up @@ -134,6 +171,18 @@ export interface StepFinishEvent {
* Present on flow orchestration steps. `undefined` on agent steps.
*/
duration?: number;

/**
* Agent ancestry chain from root to the agent that produced this event.
*
* Each entry identifies one agent in the chain. The first entry is
* the root agent, the last is the agent that produced this step.
*
* Present on both agent tool-loop steps and flow orchestration steps.
* For direct top-level executions, the chain contains the current
* agent as a single entry.
*/
agentChain?: readonly AgentChainEntry[];
}

/**
Expand Down
Loading
Loading