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`. 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..9fefced 100644 --- a/packages/agents/src/integration/lifecycle.test.ts +++ b/packages/agents/src/integration/lifecycle.test.ts @@ -1561,6 +1561,78 @@ 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.", + // 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}`); + }, + }); + + 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).toBeGreaterThanOrEqual(2); + + // 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[] = [];