From 8ef252f7c9b6127439bc245f698a03c94d00bee5 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Wed, 25 Mar 2026 12:54:28 -0400 Subject: [PATCH 1/4] fix(agents): merge config and per-call onStepStart hooks for sub-agent forwarding The base agent was only forwarding `params.onStepStart` to sub-agents via parentCtx, ignoring `config.onStepStart`. This meant config-level onStepStart hooks were silently dropped when forwarding to sub-agents, unlike onStepFinish which correctly merged both hooks via buildMergedHook. Also adds onStepStart to AgentConfig type for parity with onStepFinish. Co-Authored-By: Claude --- packages/agents/src/core/agents/base/agent.ts | 2 +- packages/agents/src/core/agents/types.ts | 7 ++ .../agents/src/integration/lifecycle.test.ts | 71 +++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/agents/src/core/agents/base/agent.ts b/packages/agents/src/core/agents/base/agent.ts index f9afb59..fd1abc4 100644 --- a/packages/agents/src/core/agents/base/agent.ts +++ b/packages/agents/src/core/agents/base/agent.ts @@ -231,7 +231,7 @@ export function agent< // See packages/agents/docs/core/hooks.md for the full lifecycle. const parentCtx: ParentAgentContext = { log, - onStepStart: params.onStepStart, + onStepStart: buildMergedHook(log, config.onStepStart, params.onStepStart), onStepFinish: buildMergedHook(log, config.onStepFinish, params.onStepFinish), agentChain: currentChain, }; diff --git a/packages/agents/src/core/agents/types.ts b/packages/agents/src/core/agents/types.ts index 214abc7..e286dc9 100644 --- a/packages/agents/src/core/agents/types.ts +++ b/packages/agents/src/core/agents/types.ts @@ -693,6 +693,13 @@ export interface AgentConfig< */ onError?: (event: { input: TInput; error: Error }) => void | Promise; + /** + * Hook: fires when a step starts. + * + * Receives a unified {@link StepStartEvent}. + */ + onStepStart?: (event: StepStartEvent) => void | Promise; + /** * Hook: fires after each step completes. * diff --git a/packages/agents/src/integration/lifecycle.test.ts b/packages/agents/src/integration/lifecycle.test.ts index 0de90c5..98df732 100644 --- a/packages/agents/src/integration/lifecycle.test.ts +++ b/packages/agents/src/integration/lifecycle.test.ts @@ -1561,6 +1561,77 @@ describe("Agent subagent hook forwarding (integration)", () => { } }); + it("parent config and per-call onStepStart both fire for flow sub-agent steps", async () => { + const stepEvents: string[] = []; + + const sub = flowAgent<{ task: string }, string>( + { + name: "sub-flow", + input: z.object({ task: z.string() }), + output: z.string(), + }, + async ({ input, $ }) => { + await $.step({ id: "work", execute: async () => input.task }); + return input.task; + }, + ); + + const toolCallModel = new MockLanguageModelV3({ + // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- mockValues sync/async mismatch + doGenerate: mockValues( + { + content: [ + { + type: "tool-call" as const, + toolCallId: "tc1", + toolName: "agent_sub", + input: JSON.stringify({ task: "do it" }), + }, + ], + finishReason: MOCK_FINISH, + usage: MOCK_USAGE, + warnings: [], + }, + { + content: [{ type: "text" as const, text: "done" }], + finishReason: MOCK_FINISH, + usage: MOCK_USAGE, + warnings: [], + }, + // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- mockValues returns sync fn, MockLanguageModelV3 expects PromiseLike + ) as any, + }); + + const parent = agent({ + name: "parent-agent", + model: toolCallModel, + system: "Delegate to agent_sub.", + agents: { sub }, + onStepStart: (event) => { + stepEvents.push(`config:${event.stepId}`); + }, + }); + + await parent.generate({ + prompt: "go", + logger: createMockLogger(), + onStepStart: (event) => { + stepEvents.push(`call:${event.stepId}`); + }, + }); + + // Sub-flow's step fires both config and per-call onStepStart from parent + const subSteps = stepEvents.filter((e) => e.includes("work")); + expect(subSteps.length).toBeGreaterThan(0); + + // Config hook fires before per-call hook + const subConfigIdx = stepEvents.findIndex((e) => e.startsWith("config:") && e.includes("work")); + const subCallIdx = stepEvents.findIndex((e) => e.startsWith("call:") && e.includes("work")); + expect(subConfigIdx).not.toBe(-1); + expect(subCallIdx).not.toBe(-1); + expect(subConfigIdx).toBeLessThan(subCallIdx); + }); + it("parent onStart does NOT fire for sub-agent events (type safety)", async () => { const startEvents: unknown[] = []; From bb2eb7acaa7bd15a40afc5463c38c357916514e8 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Wed, 25 Mar 2026 12:54:41 -0400 Subject: [PATCH 2/4] chore(agents): add changeset for onStepStart hook fix Co-Authored-By: Claude --- .changeset/fix-merge-onstepstart.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fix-merge-onstepstart.md diff --git a/.changeset/fix-merge-onstepstart.md b/.changeset/fix-merge-onstepstart.md new file mode 100644 index 0000000..41ab228 --- /dev/null +++ b/.changeset/fix-merge-onstepstart.md @@ -0,0 +1,5 @@ +--- +"@funkai/agents": patch +--- + +Fix config-level `onStepStart` hook not being merged when forwarding to sub-agents. Previously only the per-call `onStepStart` was forwarded; the config hook was silently dropped. Also adds `onStepStart` to `AgentConfig` for parity with `onStepFinish`. From b5c81926bb60b3a4b20ed347a1aad6780e1c2e8f Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Wed, 25 Mar 2026 12:59:45 -0400 Subject: [PATCH 3/4] fix(agents): cast FlowAgent to any in test to fix CI type error FlowAgent doesn't structurally satisfy Agent at the type level (missing model property), but works correctly at runtime. Added oxlint-disable comment explaining the cast. Co-Authored-By: Claude --- packages/agents/src/integration/lifecycle.test.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/agents/src/integration/lifecycle.test.ts b/packages/agents/src/integration/lifecycle.test.ts index 98df732..d83f552 100644 --- a/packages/agents/src/integration/lifecycle.test.ts +++ b/packages/agents/src/integration/lifecycle.test.ts @@ -1606,7 +1606,8 @@ describe("Agent subagent hook forwarding (integration)", () => { name: "parent-agent", model: toolCallModel, system: "Delegate to agent_sub.", - agents: { sub }, + // oxlint-disable-next-line @typescript-eslint/no-explicit-any -- FlowAgent satisfies Agent at runtime + agents: { sub: sub as any }, onStepStart: (event) => { stepEvents.push(`config:${event.stepId}`); }, From 33042e2e71367dbfb1453f55d47b0e1a02c1b2bc Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Wed, 25 Mar 2026 13:03:17 -0400 Subject: [PATCH 4/4] fix(agents): tighten onStepStart test assertion to require both hooks Assert >= 2 events instead of > 0 to explicitly verify both config and per-call onStepStart hooks fired for the sub-flow step. Co-Authored-By: Claude --- packages/agents/src/integration/lifecycle.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/agents/src/integration/lifecycle.test.ts b/packages/agents/src/integration/lifecycle.test.ts index d83f552..9fefced 100644 --- a/packages/agents/src/integration/lifecycle.test.ts +++ b/packages/agents/src/integration/lifecycle.test.ts @@ -1623,7 +1623,7 @@ describe("Agent subagent hook forwarding (integration)", () => { // Sub-flow's step fires both config and per-call onStepStart from parent const subSteps = stepEvents.filter((e) => e.includes("work")); - expect(subSteps.length).toBeGreaterThan(0); + expect(subSteps.length).toBeGreaterThanOrEqual(2); // Config hook fires before per-call hook const subConfigIdx = stepEvents.findIndex((e) => e.startsWith("config:") && e.includes("work"));