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.
11 changes: 9 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,7 @@ 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 Expand Up @@ -698,3 +704,4 @@ function buildMergedHook<E>(
await fireHooks(log, wrapHook(configHook, event), wrapHook(callHook, event));
};
}

37 changes: 36 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,33 @@ 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.
*/
// 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 +457,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
49 changes: 43 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 @@ import type { WhileConfig } from "@/core/agents/flow/steps/while.js";
/* 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 @@ export interface StepBuilderOptions {
* 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 @@ export function createStepBuilder(options: StepBuilderOptions): StepBuilder {
* 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 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR
}): 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,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR
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 +213,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR
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 +266,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR

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 +285,15 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR
type: "agent",
input: config.input,
execute: async () => {
// Forward fixed-type step hooks and agent chain to sub-agent
const forwardedHooks = resolveParentHooks(parentHooks);
const agentParams = {
...config.config,
input: config.input,
signal: ctx.signal,
logger: ctx.log.child({ stepId: config.id }),
...forwardedHooks,
agentChain,
};

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

/**
* Extract step hooks from the parent hook bag without optional
* chaining or ternaries (both disallowed by oxlint).
*
* Returns an object with `onStepStart` / `onStepFinish` suitable
* for spreading into sub-agent params. When `parentHooks` is nil,
* returns an empty object so the spread is a no-op.
*
* @private
*/
function resolveParentHooks(
parentHooks: StepBuilderOptions["parentHooks"],
): Record<string, unknown> {
if (isNil(parentHooks)) {
return {};
}
return {
onStepStart: parentHooks.onStepStart,
onStepFinish: parentHooks.onStepFinish,
};
}

/**
* 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
* when the agent is part of a chain. `undefined` for top-level agents
* called directly by the user without a parent.
*/
agentChain?: readonly AgentChainEntry[];
}

/**
Expand Down
9 changes: 8 additions & 1 deletion packages/agents/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,14 @@ export { ok, err, isOk, isErr } from "@/utils/result.js";
export { usage, usageByAgent, usageByModel } from "@/core/provider/usage.js";
export { collectUsages } from "@/lib/trace.js";

export type { Runnable, Model, StepFinishEvent, StepInfo, StreamPart } from "@/core/types.js";
export type {
Runnable,
Model,
AgentChainEntry,
StepFinishEvent,
StepInfo,
StreamPart,
} from "@/core/types.js";
export type {
TextStreamPart,
AsyncIterableStream,
Expand Down
Loading
Loading