From d9dbf35918a1fb47b6e5e1f3cbf5d9db4935044f Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Tue, 24 Mar 2026 18:58:16 -0400 Subject: [PATCH 1/8] feat(agents): add agentChain propagation and fix flow agent step hook forwarding - Add AgentChainEntry type and agentChain field to StepInfo and StepFinishEvent for agent ancestry tracking across nested agents - Forward onStepStart/onStepFinish hooks from flow agent $.agent() to sub-agents, enabling full observability of nested agent steps - Thread agentChain internally through ParentAgentContext and StepBuilderOptions without exposing it on public GenerateParams - Add integration tests for chain propagation and hook forwarding Co-Authored-By: Claude Code --- .changeset/agent-chain-propagation.md | 5 + packages/agents/src/core/agents/base/agent.ts | 27 ++- packages/agents/src/core/agents/base/utils.ts | 10 +- .../agents/src/core/agents/flow/flow-agent.ts | 25 ++- .../src/core/agents/flow/steps/factory.ts | 31 +++- packages/agents/src/core/types.ts | 49 +++++ packages/agents/src/index.ts | 9 +- .../agents/src/integration/lifecycle.test.ts | 171 ++++++++++++++++++ 8 files changed, 316 insertions(+), 11 deletions(-) create mode 100644 .changeset/agent-chain-propagation.md diff --git a/.changeset/agent-chain-propagation.md b/.changeset/agent-chain-propagation.md new file mode 100644 index 0000000..a5dc3ee --- /dev/null +++ b/.changeset/agent-chain-propagation.md @@ -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. diff --git a/packages/agents/src/core/agents/base/agent.ts b/packages/agents/src/core/agents/base/agent.ts index 5fd1d43..b58536d 100644 --- a/packages/agents/src/core/agents/base/agent.ts +++ b/packages/agents/src/core/agents/base/agent.ts @@ -26,7 +26,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"; @@ -215,6 +215,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 @@ -226,6 +230,7 @@ export function agent< log, onStepStart: params.onStepStart, onStepFinish: buildMergedHook(log, config.onStepFinish, params.onStepFinish), + agentChain: currentChain, }; const aiTools = buildAITools( @@ -266,7 +271,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), @@ -698,3 +703,21 @@ function buildMergedHook( await fireHooks(log, wrapHook(configHook, event), wrapHook(callHook, event)); }; } + +/** + * 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. + * + * @private + */ +// 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 +function extractAgentChain(params: unknown): readonly AgentChainEntry[] { + const raw = params as Record; + if (Array.isArray(raw.agentChain)) { + return raw.agentChain as readonly AgentChainEntry[]; + } + return []; +} diff --git a/packages/agents/src/core/agents/base/utils.ts b/packages/agents/src/core/agents/base/utils.ts index 8017f61..7419736 100644 --- a/packages/agents/src/core/agents/base/utils.ts +++ b/packages/agents/src/core/agents/base/utils.ts @@ -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"; @@ -68,6 +68,13 @@ export interface ParentAgentContext { * Uses `StepFinishEvent` — a fixed (non-generic) type, safe to forward. */ onStepFinish?: (event: StepFinishEvent) => void | Promise; + + /** + * Agent ancestry chain from root to the current agent. + * + * @internal Framework-only — not exposed on public `GenerateParams`. + */ + agentChain?: readonly AgentChainEntry[]; } /** @@ -423,6 +430,7 @@ function buildParentParams(ctx: ParentAgentContext | undefined): Record( 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, @@ -340,6 +344,7 @@ export function flowAgent( onStepFinish: mergedOnStepFinish, }, writer, + agentChain: currentChain, }); const $ = augmentStepBuilder(base$, ctx, _internal); @@ -617,6 +622,24 @@ export function flowAgent( * * @private */ +/** + * 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 flow agent `$.agent()` calls. + * + * @private + */ +// 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 +function extractAgentChain(params: unknown): readonly AgentChainEntry[] { + const raw = params as Record; + if (Array.isArray(raw.agentChain)) { + return raw.agentChain as readonly AgentChainEntry[]; + } + return []; +} + function sumTokenUsages(usages: TokenUsage[]): TokenUsage { const sum = (fn: (u: TokenUsage) => number): number => usages.reduce((acc, u) => acc + fn(u), 0); return { diff --git a/packages/agents/src/core/agents/flow/steps/factory.ts b/packages/agents/src/core/agents/flow/steps/factory.ts index 0784c67..255e48d 100644 --- a/packages/agents/src/core/agents/flow/steps/factory.ts +++ b/packages/agents/src/core/agents/flow/steps/factory.ts @@ -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"; @@ -55,6 +55,17 @@ export interface StepBuilderOptions { * step events through the readable stream. */ writer?: WritableStreamDefaultWriter; + + /** + * 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[]; } /** @@ -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. @@ -120,7 +131,7 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR }): Promise> { 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[] = []; @@ -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); @@ -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); @@ -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); @@ -279,6 +290,14 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR input: config.input, signal: ctx.signal, logger: ctx.log.child({ stepId: config.id }), + // Forward fixed-type step hooks so sub-agent internal steps + // (tool-loop iterations, nested flow steps) are visible to + // the root flow's onStepStart/onStepFinish hooks. + onStepStart: parentHooks?.onStepStart, + onStepFinish: parentHooks?.onStepFinish, + // Internal-only: thread the agent chain so sub-agents can + // extend it and attach to their own step events. + agentChain, }; // When stream: true and a writer is available, use agent.stream() diff --git a/packages/agents/src/core/types.ts b/packages/agents/src/core/types.ts index 7b6edae..d28da1c 100644 --- a/packages/agents/src/core/types.ts +++ b/packages/agents/src/core/types.ts @@ -44,6 +44,28 @@ export type Model = LanguageModel; */ export type StreamPart = TextStreamPart; +/** + * 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. * @@ -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[]; } /** @@ -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[]; } /** diff --git a/packages/agents/src/index.ts b/packages/agents/src/index.ts index 11e598a..9b90d7b 100644 --- a/packages/agents/src/index.ts +++ b/packages/agents/src/index.ts @@ -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, diff --git a/packages/agents/src/integration/lifecycle.test.ts b/packages/agents/src/integration/lifecycle.test.ts index d221321..0f16c6a 100644 --- a/packages/agents/src/integration/lifecycle.test.ts +++ b/packages/agents/src/integration/lifecycle.test.ts @@ -531,6 +531,8 @@ describe("FlowAgent with $.agent() (integration)", () => { expect(tracker.events).toEqual([ { type: "onStart" }, { type: "onStepStart", detail: "write" }, + // Sub-agent internal tool-loop step (forwarded via hook propagation) + { type: "onStepFinish", detail: "writer:0" }, { type: "onStepFinish", detail: "write" }, { type: "onFinish" }, ]); @@ -589,8 +591,10 @@ describe("FlowAgent with $.agent() (integration)", () => { expect(result.ok).toBe(true); expect(tracker.events).toEqual([ { type: "onStepStart", detail: "research" }, + { type: "onStepFinish", detail: "researcher:0" }, { type: "onStepFinish", detail: "research" }, { type: "onStepStart", detail: "write" }, + { type: "onStepFinish", detail: "writer:0" }, { type: "onStepFinish", detail: "write" }, ]); }); @@ -703,6 +707,7 @@ describe("FlowAgent agents dependency lifecycle (integration)", () => { expect(tracker.events).toEqual([ { type: "onStepStart", detail: "run-core" }, + { type: "onStepFinish", detail: "core:0" }, { type: "onStepFinish", detail: "run-core" }, ]); }); @@ -965,6 +970,7 @@ describe("FlowAgent streaming lifecycle (integration)", () => { expect(tracker.events).toEqual([ { type: "onStepStart", detail: "write" }, + { type: "onStepFinish", detail: "writer:0" }, { type: "onStepFinish", detail: "write" }, ]); }); @@ -1717,3 +1723,168 @@ describe("Step index uniqueness (integration)", () => { expect(indices).toEqual([0, 1, 2, 3]); }); }); + +// --------------------------------------------------------------------------- +// Agent chain propagation +// --------------------------------------------------------------------------- + +describe("Agent chain propagation (integration)", () => { + const Input = z.object({ topic: z.string() }); + const Output = z.object({ summary: z.string() }); + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("flow agent step events carry agentChain with flow agent id", async () => { + const chains: (readonly { id: string }[] | undefined)[] = []; + + const fa = flowAgent<{ topic: string }, { summary: string }>( + { + name: "my-flow", + input: Input, + output: Output, + logger: createMockLogger(), + onStepFinish: (event) => { + chains.push(event.agentChain); + }, + }, + async ({ input, $ }) => { + await $.step({ id: "work", execute: async () => input.topic }); + return { summary: input.topic }; + }, + ); + + await fa.generate({ input: { topic: "test" } }); + + expect(chains).toEqual([[{ id: "my-flow" }]]); + }); + + it("$.agent() sub-agent internal steps carry extended agentChain", async () => { + const chains: (readonly { id: string }[] | undefined)[] = []; + const stepIds: string[] = []; + + const writer = agent({ + name: "writer", + model: createMockModel("written content"), + logger: createMockLogger(), + }); + + const fa = flowAgent<{ topic: string }, { summary: string }>( + { + name: "pipeline", + input: Input, + output: Output, + logger: createMockLogger(), + onStepFinish: (event) => { + const id = event.step?.id ?? event.stepId ?? "unknown"; + stepIds.push(id); + chains.push(event.agentChain); + }, + }, + async ({ input, $ }) => { + const r = await $.agent({ + id: "write", + agent: writer, + input: `Write about ${input.topic}`, + }); + if (r.ok) { + return { summary: String(r.value.output) }; + } + return { summary: "failed" }; + }, + ); + + await fa.generate({ input: { topic: "TypeScript" } }); + + // Sub-agent internal step carries extended chain + expect(stepIds[0]).toBe("writer:0"); + expect(chains[0]).toEqual([{ id: "pipeline" }, { id: "writer" }]); + + // Flow-level $.agent() step carries flow chain + expect(stepIds[1]).toBe("write"); + expect(chains[1]).toEqual([{ id: "pipeline" }]); + }); + + it("base agent step events carry agentChain", async () => { + const chains: (readonly { id: string }[] | undefined)[] = []; + + const myAgent = agent({ + name: "solo-agent", + model: createMockModel("response"), + logger: createMockLogger(), + }); + + await myAgent.generate({ + prompt: "hello", + onStepFinish: (event) => { + chains.push(event.agentChain); + }, + }); + + // Direct agent call: chain is just this agent + expect(chains).toEqual([[{ id: "solo-agent" }]]); + }); + + it("agent chain cascades through agent → sub-agent tool calls", async () => { + const stepEvents: { stepId: string | undefined; chain: readonly { id: string }[] | undefined }[] = []; + + const sub = agent({ + name: "sub-agent", + model: createMockModel("sub response"), + input: z.object({ task: z.string() }), + prompt: ({ input }) => 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.", + agents: { sub }, + }); + + await parent.generate({ + prompt: "go", + logger: createMockLogger(), + onStepFinish: (event) => { + stepEvents.push({ stepId: event.stepId, chain: event.agentChain }); + }, + }); + + // Sub-agent internal step: chain = [parent, sub] + const subEvents = stepEvents.filter((e) => e.stepId?.startsWith("sub-agent")); + expect(subEvents.length).toBeGreaterThan(0); + expect(subEvents[0]?.chain).toEqual([{ id: "parent-agent" }, { id: "sub-agent" }]); + + // Parent's own steps: chain = [parent] + const parentEvents = stepEvents.filter((e) => e.stepId?.startsWith("parent-agent")); + expect(parentEvents.length).toBeGreaterThan(0); + expect(parentEvents[0]?.chain).toEqual([{ id: "parent-agent" }]); + }); +}); From 18e8766fb7a56f303993aa7d7ac92abfdfe4aa06 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Tue, 24 Mar 2026 19:03:48 -0400 Subject: [PATCH 2/8] refactor(agents): deduplicate extractAgentChain into shared utils Move `extractAgentChain` from agent.ts and flow-agent.ts into base/utils.ts to eliminate duplication. Use a shared empty array constant to avoid per-call allocations for top-level agents. Co-Authored-By: Claude Code --- packages/agents/src/core/agents/base/agent.ts | 18 +------------ packages/agents/src/core/agents/base/utils.ts | 27 +++++++++++++++++++ .../agents/src/core/agents/flow/flow-agent.ts | 20 +------------- 3 files changed, 29 insertions(+), 36 deletions(-) diff --git a/packages/agents/src/core/agents/base/agent.ts b/packages/agents/src/core/agents/base/agent.ts index b58536d..c9d5769 100644 --- a/packages/agents/src/core/agents/base/agent.ts +++ b/packages/agents/src/core/agents/base/agent.ts @@ -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, @@ -704,20 +705,3 @@ function buildMergedHook( }; } -/** - * 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. - * - * @private - */ -// 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 -function extractAgentChain(params: unknown): readonly AgentChainEntry[] { - const raw = params as Record; - if (Array.isArray(raw.agentChain)) { - return raw.agentChain as readonly AgentChainEntry[]; - } - return []; -} diff --git a/packages/agents/src/core/agents/base/utils.ts b/packages/agents/src/core/agents/base/utils.ts index 7419736..7a61762 100644 --- a/packages/agents/src/core/agents/base/utils.ts +++ b/packages/agents/src/core/agents/base/utils.ts @@ -421,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; + if (Array.isArray(raw.agentChain)) { + return raw.agentChain as readonly AgentChainEntry[]; + } + return EMPTY_CHAIN; +} + function buildParentParams(ctx: ParentAgentContext | undefined): Record { if (isNil(ctx)) { return {}; diff --git a/packages/agents/src/core/agents/flow/flow-agent.ts b/packages/agents/src/core/agents/flow/flow-agent.ts index fa97370..850cc72 100644 --- a/packages/agents/src/core/agents/flow/flow-agent.ts +++ b/packages/agents/src/core/agents/flow/flow-agent.ts @@ -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, @@ -622,24 +622,6 @@ export function flowAgent( * * @private */ -/** - * 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 flow agent `$.agent()` calls. - * - * @private - */ -// 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 -function extractAgentChain(params: unknown): readonly AgentChainEntry[] { - const raw = params as Record; - if (Array.isArray(raw.agentChain)) { - return raw.agentChain as readonly AgentChainEntry[]; - } - return []; -} - function sumTokenUsages(usages: TokenUsage[]): TokenUsage { const sum = (fn: (u: TokenUsage) => number): number => usages.reduce((acc, u) => acc + fn(u), 0); return { From 6d6b34c684b648d4bebac3b14cd7691b622e35c7 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Tue, 24 Mar 2026 19:11:13 -0400 Subject: [PATCH 3/8] style(agents): fix oxlint errors in step factory Replace optional chaining and ternaries (disallowed by oxlint) with explicit nil checks via resolveParentHooks helper. Capitalize continuation comments. Co-Authored-By: Claude Code --- .../src/core/agents/flow/steps/factory.ts | 32 +++++++++++++++---- 1 file changed, 25 insertions(+), 7 deletions(-) diff --git a/packages/agents/src/core/agents/flow/steps/factory.ts b/packages/agents/src/core/agents/flow/steps/factory.ts index 255e48d..0393a5d 100644 --- a/packages/agents/src/core/agents/flow/steps/factory.ts +++ b/packages/agents/src/core/agents/flow/steps/factory.ts @@ -285,18 +285,14 @@ 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 }), - // Forward fixed-type step hooks so sub-agent internal steps - // (tool-loop iterations, nested flow steps) are visible to - // the root flow's onStepStart/onStepFinish hooks. - onStepStart: parentHooks?.onStepStart, - onStepFinish: parentHooks?.onStepFinish, - // Internal-only: thread the agent chain so sub-agents can - // extend it and attach to their own step events. + ...forwardedHooks, agentChain, }; @@ -604,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 { + if (isNil(parentHooks)) { + return {}; + } + return { + onStepStart: parentHooks.onStepStart, + onStepFinish: parentHooks.onStepFinish, + }; +} + /** * Sequentially reduce items with abort support using tail-recursive iteration. * From 5d8cc2dd5663139d583fdc7c88f6565669b9c45a Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Tue, 24 Mar 2026 19:25:14 -0400 Subject: [PATCH 4/8] style(agents): fix formatting in agent, step factory, and lifecycle test Co-Authored-By: Claude --- packages/agents/src/core/agents/base/agent.ts | 9 +++++++-- packages/agents/src/core/agents/flow/steps/factory.ts | 5 ++++- packages/agents/src/integration/lifecycle.test.ts | 5 ++++- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/packages/agents/src/core/agents/base/agent.ts b/packages/agents/src/core/agents/base/agent.ts index c9d5769..dfb0497 100644 --- a/packages/agents/src/core/agents/base/agent.ts +++ b/packages/agents/src/core/agents/base/agent.ts @@ -272,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, agentChain: currentChain }; + const event: StepFinishEvent = { + stepId, + toolCalls, + toolResults, + usage, + agentChain: currentChain, + }; await fireHooks( log, wrapHook(config.onStepFinish, event), @@ -704,4 +710,3 @@ function buildMergedHook( await fireHooks(log, wrapHook(configHook, event), wrapHook(callHook, event)); }; } - diff --git a/packages/agents/src/core/agents/flow/steps/factory.ts b/packages/agents/src/core/agents/flow/steps/factory.ts index 0393a5d..fc6d4bf 100644 --- a/packages/agents/src/core/agents/flow/steps/factory.ts +++ b/packages/agents/src/core/agents/flow/steps/factory.ts @@ -141,7 +141,10 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR trace: childTrace, messages: ctx.messages, }; - const child$ = createStepBuilderInternal({ ctx: childCtx, parentHooks, writer, agentChain }, 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); diff --git a/packages/agents/src/integration/lifecycle.test.ts b/packages/agents/src/integration/lifecycle.test.ts index 0f16c6a..f1175ea 100644 --- a/packages/agents/src/integration/lifecycle.test.ts +++ b/packages/agents/src/integration/lifecycle.test.ts @@ -1827,7 +1827,10 @@ describe("Agent chain propagation (integration)", () => { }); it("agent chain cascades through agent → sub-agent tool calls", async () => { - const stepEvents: { stepId: string | undefined; chain: readonly { id: string }[] | undefined }[] = []; + const stepEvents: { + stepId: string | undefined; + chain: readonly { id: string }[] | undefined; + }[] = []; const sub = agent({ name: "sub-agent", From 8762600b43bc93a7a6f62716fd6c13de1fd428b9 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Tue, 24 Mar 2026 20:30:10 -0400 Subject: [PATCH 5/8] fix(agents): address PR review feedback - Fix agentChain JSDoc in types.ts to reflect that top-level agents get a single-entry chain (not undefined) - Add @example tag to extractAgentChain export in utils.ts - Replace resolveParentHooks with mergeStepHooks in factory.ts to merge parent + child step hooks instead of clobbering, and only include defined hook keys in the spread Co-Authored-By: Claude --- packages/agents/src/core/agents/base/utils.ts | 9 +++ .../src/core/agents/flow/steps/factory.ts | 59 ++++++++++++++----- packages/agents/src/core/types.ts | 6 +- 3 files changed, 56 insertions(+), 18 deletions(-) diff --git a/packages/agents/src/core/agents/base/utils.ts b/packages/agents/src/core/agents/base/utils.ts index 7a61762..5ae2246 100644 --- a/packages/agents/src/core/agents/base/utils.ts +++ b/packages/agents/src/core/agents/base/utils.ts @@ -438,6 +438,15 @@ const EMPTY_CHAIN: readonly AgentChainEntry[] = []; * * @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[] { diff --git a/packages/agents/src/core/agents/flow/steps/factory.ts b/packages/agents/src/core/agents/flow/steps/factory.ts index fc6d4bf..601bbe0 100644 --- a/packages/agents/src/core/agents/flow/steps/factory.ts +++ b/packages/agents/src/core/agents/flow/steps/factory.ts @@ -288,14 +288,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); + // 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 }), - ...forwardedHooks, + ...mergedHooks, agentChain, }; @@ -604,25 +605,53 @@ function buildOnFinishHandlerRace( } /** - * Extract step hooks from the parent hook bag without optional - * chaining or ternaries (both disallowed by oxlint). + * Merge parent flow step hooks with delegated-agent step hooks. * - * 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. + * 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 resolveParentHooks( +function mergeStepHooks( parentHooks: StepBuilderOptions["parentHooks"], + childConfig: Record | undefined, ): Record { - if (isNil(parentHooks)) { - return {}; + const parentStart = isNil(parentHooks) ? undefined : parentHooks.onStepStart; + const parentFinish = isNil(parentHooks) ? undefined : parentHooks.onStepFinish; + const childStart = isNil(childConfig) + ? undefined + : (childConfig.onStepStart as typeof parentStart); + const childFinish = isNil(childConfig) + ? undefined + : (childConfig.onStepFinish as typeof parentFinish); + + const result: Record = {}; + + 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; } - return { - onStepStart: parentHooks.onStepStart, - onStepFinish: parentHooks.onStepFinish, - }; + + 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; } /** diff --git a/packages/agents/src/core/types.ts b/packages/agents/src/core/types.ts index d28da1c..f0e5184 100644 --- a/packages/agents/src/core/types.ts +++ b/packages/agents/src/core/types.ts @@ -178,9 +178,9 @@ export interface StepFinishEvent { * 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. + * 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[]; } From 05d3f87ed26d1f903e377776699ac76c7a2b197a Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Tue, 24 Mar 2026 20:50:37 -0400 Subject: [PATCH 6/8] style(agents): fix oxlint no-ternary errors in mergeStepHooks Co-Authored-By: Claude --- .../src/core/agents/flow/steps/factory.ts | 24 ++++++++++++------- 1 file changed, 16 insertions(+), 8 deletions(-) diff --git a/packages/agents/src/core/agents/flow/steps/factory.ts b/packages/agents/src/core/agents/flow/steps/factory.ts index 601bbe0..4ad3072 100644 --- a/packages/agents/src/core/agents/flow/steps/factory.ts +++ b/packages/agents/src/core/agents/flow/steps/factory.ts @@ -618,14 +618,22 @@ function mergeStepHooks( parentHooks: StepBuilderOptions["parentHooks"], childConfig: Record | undefined, ): Record { - const parentStart = isNil(parentHooks) ? undefined : parentHooks.onStepStart; - const parentFinish = isNil(parentHooks) ? undefined : parentHooks.onStepFinish; - const childStart = isNil(childConfig) - ? undefined - : (childConfig.onStepStart as typeof parentStart); - const childFinish = isNil(childConfig) - ? undefined - : (childConfig.onStepFinish as typeof parentFinish); + type StepStartHook = (event: { step: StepInfo }) => void | Promise; + type StepFinishHook = (event: StepFinishEvent) => void | Promise; + + let parentStart: StepStartHook | undefined; + let parentFinish: StepFinishHook | undefined; + if (isNotNil(parentHooks)) { + parentStart = parentHooks.onStepStart; + parentFinish = parentHooks.onStepFinish; + } + + let childStart: StepStartHook | undefined; + let childFinish: StepFinishHook | undefined; + if (isNotNil(childConfig)) { + childStart = childConfig.onStepStart as StepStartHook | undefined; + childFinish = childConfig.onStepFinish as StepFinishHook | undefined; + } const result: Record = {}; From 22222fc44da2aa1e0511870b86a6da77245e2325 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Tue, 24 Mar 2026 21:29:14 -0400 Subject: [PATCH 7/8] feat(utils): add @funkai/utils with privateField and migrate agentChain - Add @funkai/utils package (private, workspace-only) with privateField utility for Symbol-keyed non-enumerable fields on plain objects - Migrate agentChain transport from string key to Symbol-keyed private field using _agentChainField, making it invisible to Object.keys(), JSON.stringify(), for...in, and object spread - Update extractAgentChain to read via private field accessor - Update buildAgentTool and factory.ts to stamp private field after spread (Symbol fields don't survive spread) Co-Authored-By: Claude --- packages/agents/package.json | 1 + packages/agents/src/core/agents/base/utils.ts | 67 ++++--- .../src/core/agents/flow/steps/factory.ts | 6 +- packages/utils/README.md | 16 ++ .../utils/docs/reference/private-field.md | 110 +++++++++++ packages/utils/package.json | 28 +++ packages/utils/src/index.ts | 2 + packages/utils/src/private-field.test.ts | 179 ++++++++++++++++++ packages/utils/src/private-field.ts | 163 ++++++++++++++++ packages/utils/tsconfig.json | 23 +++ packages/utils/tsdown.config.ts | 12 ++ packages/utils/vitest.config.ts | 14 ++ pnpm-lock.yaml | 21 ++ 13 files changed, 616 insertions(+), 26 deletions(-) create mode 100644 packages/utils/README.md create mode 100644 packages/utils/docs/reference/private-field.md create mode 100644 packages/utils/package.json create mode 100644 packages/utils/src/index.ts create mode 100644 packages/utils/src/private-field.test.ts create mode 100644 packages/utils/src/private-field.ts create mode 100644 packages/utils/tsconfig.json create mode 100644 packages/utils/tsdown.config.ts create mode 100644 packages/utils/vitest.config.ts diff --git a/packages/agents/package.json b/packages/agents/package.json index d9fd9d7..d829952 100644 --- a/packages/agents/package.json +++ b/packages/agents/package.json @@ -47,6 +47,7 @@ }, "devDependencies": { "@ai-sdk/devtools": "^0.0.15", + "@funkai/utils": "workspace:*", "@types/node": "catalog:", "@vitest/coverage-v8": "catalog:", "tsdown": "catalog:", diff --git a/packages/agents/src/core/agents/base/utils.ts b/packages/agents/src/core/agents/base/utils.ts index 5ae2246..4e77e44 100644 --- a/packages/agents/src/core/agents/base/utils.ts +++ b/packages/agents/src/core/agents/base/utils.ts @@ -1,3 +1,4 @@ +import { privateField } from "@funkai/utils"; import type { LanguageModelUsage } from "ai"; import { tool } from "ai"; import { isFunction, isNil, isNotNil, isString, omitBy } from "es-toolkit"; @@ -373,18 +374,27 @@ function buildAgentTool( // eslint-disable-next-line @typescript-eslint/no-explicit-any -- ToolSet requires `any` values; `unknown` breaks assignability with AI SDK ): ReturnType> { const parentParams = buildParentParams(parentCtx); + let parentChain: readonly AgentChainEntry[] | undefined; + if (isNotNil(parentCtx)) { + parentChain = parentCtx.agentChain; + } if (isNotNil(meta) && isNotNil(meta.inputSchema)) { return tool({ description: `Delegate to ${toolName}`, inputSchema: meta.inputSchema, execute: async (input, { abortSignal }) => { - const r = await runnable.generate({ + const generateParams = { input, signal: abortSignal, tools, ...parentParams, - }); + }; + // Stamp after spread — Symbol fields don't survive spread + if (isNotNil(parentChain)) { + _agentChainField.set(generateParams, parentChain); + } + const r = await runnable.generate(generateParams); if (!r.ok) { throw new Error(r.error.message); } @@ -396,12 +406,16 @@ function buildAgentTool( description: `Delegate to ${toolName}`, inputSchema: z.object({ prompt: z.string().describe("The prompt to send") }), execute: async (input: { prompt: string }, { abortSignal }) => { - const r = await runnable.generate({ + const generateParams = { prompt: input.prompt, signal: abortSignal, tools, ...parentParams, - }); + }; + if (isNotNil(parentChain)) { + _agentChainField.set(generateParams, parentChain); + } + const r = await runnable.generate(generateParams); if (!r.ok) { throw new Error(r.error.message); } @@ -411,16 +425,15 @@ function buildAgentTool( } /** - * Build the per-call params to forward from parent context to sub-agent. - * - * Only forwards the parent logger and **fixed-type** step hooks. - * Generic hooks (`onStart`, `onFinish`, `onError`) are intentionally - * excluded — see {@link ParentAgentContext} for the rationale. + * Private field for transporting agent ancestry chain through params. * - * Omits `undefined` values so they don't override sub-agent defaults. + * Uses a Symbol key so it is invisible to `Object.keys()`, + * `JSON.stringify()`, `for...in`, and object spread. * - * @private + * @internal */ +export const _agentChainField = privateField("funkai:agent-chain"); + /** * Shared empty chain — avoids allocating a new `[]` on every * top-level agent call where no parent chain exists. @@ -432,31 +445,36 @@ 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. + * Reads the agent chain from a Symbol-keyed private field on + * the params object. Returns an empty array when absent. * * @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({}); - * // => [] + * const chain = extractAgentChain(params); + * // => [{ id: "root" }] or [] * ``` */ -// 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; - if (Array.isArray(raw.agentChain)) { - return raw.agentChain as readonly AgentChainEntry[]; + if (typeof params !== "object" || params === null) { + return EMPTY_CHAIN; } - return EMPTY_CHAIN; + return _agentChainField.get(params, EMPTY_CHAIN); } +/** + * Build the per-call params to forward from parent context to sub-agent. + * + * Only forwards the parent logger and **fixed-type** step hooks. + * Generic hooks (`onStart`, `onFinish`, `onError`) are intentionally + * excluded — see {@link ParentAgentContext} for the rationale. + * + * Omits `undefined` values so they don't override sub-agent defaults. + * + * @private + */ function buildParentParams(ctx: ParentAgentContext | undefined): Record { if (isNil(ctx)) { return {}; @@ -466,7 +484,6 @@ function buildParentParams(ctx: ParentAgentContext | undefined): Record(description: string): PrivateField` + +Creates a frozen accessor for a private Symbol-keyed property. + +Uses `Symbol.for(description)` internally — the same description string produces the same Symbol across package boundaries. + +**Parameters:** + +| Name | Type | Description | +| ------------- | -------- | ------------------------------------------------------------- | +| `description` | `string` | A namespaced key for the Symbol (e.g. `"funkai:agent-chain"`) | + +**Returns:** A frozen `PrivateField` accessor. + +### `PrivateField` + +| Method | Signature | Description | +| -------- | ------------------------------------------- | --------------------------------------------- | +| `symbol` | `readonly symbol` | The underlying Symbol | +| `get` | `(obj: object) => T \| undefined` | Read the field. Returns `undefined` if absent | +| `get` | `(obj: object, defaultValue: T) => T` | Read the field with a fallback | +| `set` | `(obj: O, value: T) => O` | Attach the field. Returns the same object | +| `has` | `(obj: object) => boolean` | Check if the field is present | +| `remove` | `(obj: object) => boolean` | Delete the field. Returns `true` if removed | + +## Usage + +```ts +const _nameField = privateField("my-lib:name"); + +const obj = { visible: true }; +_nameField.set(obj, "hidden-value"); + +_nameField.get(obj); // => "hidden-value" +_nameField.get({}, "fallback"); // => "fallback" +_nameField.has(obj); // => true + +Object.keys(obj); // => ["visible"] +JSON.stringify(obj); // => '{"visible":true}' +{ ...obj }; // => { visible: true } — no private field +``` + +## Visibility + +| Operation | Visible? | +| -------------------------------- | -------- | +| `Object.keys()` | No | +| `JSON.stringify()` | No | +| `for...in` | No | +| `{ ...obj }` spread | No | +| `Object.assign()` | No | +| `Object.getOwnPropertySymbols()` | Yes | +| `Reflect.ownKeys()` | Yes | + +## Naming Convention + +Prefix private field accessors with `_` to signal internal use: + +```ts +// Good +const _agentChainField = privateField("funkai:agent-chain"); +const _parentCtxField = privateField("funkai:parent-ctx"); + +// Bad +const AGENT_CHAIN = privateField<...>(...); +const agentChain = privateField<...>(...); +``` + +## Property Descriptor + +Fields are defined with: + +```ts +{ enumerable: false, writable: true, configurable: true } +``` + +- **Non-enumerable** — hidden from iteration and spread +- **Writable** — allows `set()` to overwrite without delete+redefine +- **Configurable** — allows `remove()` to delete the property + +The Symbol itself is the access control mechanism. Without the Symbol reference, the field cannot be read or written. + +## Cross-Package Sharing + +`Symbol.for()` creates global Symbols — two accessors with the same description share the same Symbol: + +```ts +// package-a +const _field = privateField("funkai:shared"); + +// package-b +const _field = privateField("funkai:shared"); + +// Both read/write the same property +``` + +Use namespaced descriptions (e.g. `"funkai:agent-chain"`) to avoid collisions. diff --git a/packages/utils/package.json b/packages/utils/package.json new file mode 100644 index 0000000..4a74ee7 --- /dev/null +++ b/packages/utils/package.json @@ -0,0 +1,28 @@ +{ + "name": "@funkai/utils", + "version": "0.0.0", + "private": true, + "description": "Internal utilities for the funkai framework", + "type": "module", + "exports": { + ".": { + "types": "./dist/index.d.mts", + "import": "./dist/index.mjs" + } + }, + "scripts": { + "build": "tsdown", + "typecheck": "tsc --noEmit", + "test": "vitest run" + }, + "devDependencies": { + "@types/node": "catalog:", + "@vitest/coverage-v8": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=24.0.0" + } +} diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts new file mode 100644 index 0000000..8345225 --- /dev/null +++ b/packages/utils/src/index.ts @@ -0,0 +1,2 @@ +export { privateField } from "./private-field.js"; +export type { PrivateField } from "./private-field.js"; diff --git a/packages/utils/src/private-field.test.ts b/packages/utils/src/private-field.test.ts new file mode 100644 index 0000000..b083fa5 --- /dev/null +++ b/packages/utils/src/private-field.test.ts @@ -0,0 +1,179 @@ +import { describe, expect, it } from "vitest"; + +import { privateField } from "./private-field.js"; + +describe("private field accessor", () => { + describe("get", () => { + it("returns undefined when field is absent", () => { + const _field = privateField("test:get-absent"); + expect(_field.get({})).toBeUndefined(); + }); + + it("returns the stored value when present", () => { + const _field = privateField("test:get-present"); + const obj = {}; + _field.set(obj, 42); + expect(_field.get(obj)).toBe(42); + }); + + it("returns defaultValue when field is absent", () => { + const _field = privateField("test:get-default-absent"); + expect(_field.get({}, "fallback")).toBe("fallback"); + }); + + it("returns the stored value over defaultValue when present", () => { + const _field = privateField("test:get-default-present"); + const obj = {}; + _field.set(obj, "real"); + expect(_field.get(obj, "fallback")).toBe("real"); + }); + }); + + describe("set", () => { + it("returns the same object reference", () => { + const _field = privateField("test:set-ref"); + const obj = { a: 1 }; + const result = _field.set(obj, "hello"); + expect(result).toBe(obj); + }); + + it("overwrites a previously set value", () => { + const _field = privateField("test:set-overwrite"); + const obj = {}; + _field.set(obj, 1); + _field.set(obj, 2); + expect(_field.get(obj)).toBe(2); + }); + + it("preserves existing object properties", () => { + const _field = privateField("test:set-preserve"); + const obj = { visible: true, count: 5 }; + _field.set(obj, "hidden"); + expect(obj.visible).toBe(true); + expect(obj.count).toBe(5); + }); + }); + + describe("has", () => { + it("returns false when field is absent", () => { + const _field = privateField("test:has-absent"); + expect(_field.has({})).toBe(false); + }); + + it("returns true when field is present", () => { + const _field = privateField("test:has-present"); + const obj = {}; + _field.set(obj, "value"); + expect(_field.has(obj)).toBe(true); + }); + + it("returns false after remove", () => { + const _field = privateField("test:has-removed"); + const obj = {}; + _field.set(obj, "value"); + _field.remove(obj); + expect(_field.has(obj)).toBe(false); + }); + }); + + describe("remove", () => { + it("returns false when field is absent", () => { + const _field = privateField("test:remove-absent"); + expect(_field.remove({})).toBe(false); + }); + + it("returns true and removes when field is present", () => { + const _field = privateField("test:remove-present"); + const obj = {}; + _field.set(obj, "value"); + expect(_field.remove(obj)).toBe(true); + expect(_field.get(obj)).toBeUndefined(); + }); + }); + + describe("symbol", () => { + it("exposes the underlying Symbol", () => { + const _field = privateField("test:symbol-exposed"); + expect(typeof _field.symbol).toBe("symbol"); + }); + + it("uses Symbol.for so same description yields same Symbol", () => { + const _a = privateField("test:symbol-shared"); + const _b = privateField("test:symbol-shared"); + expect(_a.symbol).toBe(_b.symbol); + }); + + it("can cross-read between accessors with the same description", () => { + const _writer = privateField("test:symbol-cross"); + const _reader = privateField("test:symbol-cross"); + const obj = {}; + _writer.set(obj, 99); + expect(_reader.get(obj)).toBe(99); + }); + }); + + describe("invisibility", () => { + it("is not visible in Object.keys", () => { + const _field = privateField("test:invis-keys"); + const obj = { visible: true }; + _field.set(obj, "hidden"); + expect(Object.keys(obj)).toEqual(["visible"]); + }); + + it("is not visible in JSON.stringify", () => { + const _field = privateField("test:invis-json"); + const obj = { visible: true }; + _field.set(obj, "hidden"); + expect(JSON.stringify(obj)).toBe('{"visible":true}'); + }); + + it("is not visible in for...in", () => { + const _field = privateField("test:invis-forin"); + const obj: Record = { visible: true }; + _field.set(obj, "hidden"); + const keys: string[] = []; + for (const key in obj) { + if (Object.hasOwn(obj, key)) { + keys.push(key); + } + } + expect(keys).toEqual(["visible"]); + }); + + it("does not survive object spread", () => { + const _field = privateField("test:invis-spread"); + const obj = { visible: true }; + _field.set(obj, "hidden"); + const copy = { ...obj }; + expect(_field.has(copy)).toBe(false); + expect(_field.get(copy)).toBeUndefined(); + }); + + it("is discoverable via Object.getOwnPropertySymbols", () => { + const _field = privateField("test:invis-symbols"); + const obj = {}; + _field.set(obj, "hidden"); + const symbols = Object.getOwnPropertySymbols(obj); + expect(symbols).toContain(_field.symbol); + }); + }); + + describe("accessor is frozen", () => { + it("cannot be modified", () => { + const _field = privateField("test:frozen"); + expect(Object.isFrozen(_field)).toBe(true); + }); + }); + + describe("multiple fields on one object", () => { + it("supports independent private fields", () => { + const _name = privateField("test:multi-name"); + const _age = privateField("test:multi-age"); + const obj = {}; + _name.set(obj, "alice"); + _age.set(obj, 30); + expect(_name.get(obj)).toBe("alice"); + expect(_age.get(obj)).toBe(30); + }); + }); +}); diff --git a/packages/utils/src/private-field.ts b/packages/utils/src/private-field.ts new file mode 100644 index 0000000..57f89e0 --- /dev/null +++ b/packages/utils/src/private-field.ts @@ -0,0 +1,163 @@ +/** + * A type-safe accessor for a Symbol-keyed, non-enumerable field on plain objects. + * + * Private fields are invisible to `Object.keys()`, `JSON.stringify()`, + * `for...in`, and object spread. The Symbol is the sole access key. + * + * @typeParam T - The type of the stored value. + */ +export interface PrivateField { + /** + * The underlying Symbol used as the property key. + * + * Exposed for advanced use cases (e.g. debugging, custom descriptors). + * Prefer the accessor methods for normal usage. + */ + readonly symbol: symbol; + + /** + * Read the private field from an object. + * + * @param obj - The object to read from. + * @returns The stored value, or `undefined` if not present. + * + * @example + * ```ts + * const name = _nameField.get(params); + * // => string | undefined + * ``` + */ + get(obj: object): T | undefined; + + /** + * Read the private field from an object with a fallback. + * + * @param obj - The object to read from. + * @param defaultValue - Value to return when the field is absent. + * @returns The stored value, or `defaultValue` if not present. + * + * @example + * ```ts + * const chain = _chainField.get(params, []); + * // => always returns an array, never undefined + * ``` + */ + get(obj: object, defaultValue: T): T; + + /** + * Attach the private field to an object. + * + * Defines a non-enumerable property keyed by the internal Symbol. + * Returns the same object reference for chaining. If the field + * already exists, the value is overwritten. + * + * @param obj - The target object (must be owned by the caller). + * @param value - The value to store. + * @returns The same object reference. + * + * @example + * ```ts + * const params = _chainField.set({ prompt: "hello" }, [{ id: "root" }]); + * ``` + */ + set(obj: O, value: T): O; + + /** + * Check whether an object carries this private field. + * + * @param obj - The object to check. + * @returns `true` if the Symbol-keyed property exists on the object. + * + * @example + * ```ts + * if (_chainField.has(params)) { + * // field is present + * } + * ``` + */ + has(obj: object): boolean; + + /** + * Remove the private field from an object. + * + * @param obj - The object to remove from. + * @returns `true` if the field was present and removed, `false` otherwise. + * + * @example + * ```ts + * _chainField.remove(params); + * ``` + */ + remove(obj: object): boolean; +} + +/** + * Create a type-safe accessor for a Symbol-keyed private field. + * + * The returned accessor provides `.get()`, `.set()`, `.has()`, and + * `.remove()` methods. The field is stored as a non-enumerable + * property, invisible to `Object.keys()`, `JSON.stringify()`, + * `for...in`, and object spread (`{ ...obj }`). + * + * Uses `Symbol.for(description)` internally so the same description + * produces the same Symbol across package boundaries. + * + * @typeParam T - The type of the stored value. + * @param description - A namespaced key for the Symbol (e.g. `"funkai:agent-chain"`). + * @returns A frozen {@link PrivateField} accessor. + * + * @example + * ```ts + * const _nameField = privateField("my-lib:name"); + * + * const obj = { visible: true }; + * _nameField.set(obj, "hidden-value"); + * + * _nameField.get(obj); // => "hidden-value" + * _nameField.get({}, "fallback"); // => "fallback" + * _nameField.has(obj); // => true + * Object.keys(obj); // => ["visible"] + * JSON.stringify(obj); // => '{"visible":true}' + * ``` + */ +export function privateField(description: string): PrivateField { + const sym = Symbol.for(description); + + function get(obj: object): T | undefined; + function get(obj: object, defaultValue: T): T; + function get(obj: object, defaultValue?: T): T | undefined { + const record = obj as Record; + if (sym in obj) { + return record[sym] as T; + } + return defaultValue; + } + + function set(obj: O, value: T): O { + // First call defines the descriptor; subsequent calls overwrite via assignment + if (sym in obj) { + (obj as Record)[sym] = value; + } else { + Object.defineProperty(obj, sym, { + value, + enumerable: false, + writable: true, + configurable: true, + }); + } + return obj; + } + + function has(obj: object): boolean { + return sym in obj; + } + + function remove(obj: object): boolean { + if (!(sym in obj)) { + return false; + } + return Reflect.deleteProperty(obj, sym); + } + + return Object.freeze({ symbol: sym, get, set, has, remove }); +} diff --git a/packages/utils/tsconfig.json b/packages/utils/tsconfig.json new file mode 100644 index 0000000..5e6ee02 --- /dev/null +++ b/packages/utils/tsconfig.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "ES2024", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2024"], + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": ".", + "types": ["node"] + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/packages/utils/tsdown.config.ts b/packages/utils/tsdown.config.ts new file mode 100644 index 0000000..b12058c --- /dev/null +++ b/packages/utils/tsdown.config.ts @@ -0,0 +1,12 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: ["src/index.ts"], + outDir: "dist", + format: ["esm"], + dts: true, + clean: true, + unbundle: false, + platform: "node", + target: "node22", +}); diff --git a/packages/utils/vitest.config.ts b/packages/utils/vitest.config.ts new file mode 100644 index 0000000..e91b673 --- /dev/null +++ b/packages/utils/vitest.config.ts @@ -0,0 +1,14 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.{ts,tsx}"], + passWithNoTests: true, + coverage: { + provider: "v8", + include: ["src/**/*.ts"], + exclude: ["src/**/*.test.ts", "src/**/index.ts"], + reporter: ["text", "lcov"], + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 62a182d..d183f00 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -322,6 +322,9 @@ importers: '@ai-sdk/devtools': specifier: ^0.0.15 version: 0.0.15 + '@funkai/utils': + specifier: workspace:* + version: link:../utils '@types/node': specifier: 'catalog:' version: 25.5.0 @@ -467,6 +470,24 @@ importers: packages/tsconfig: {} + packages/utils: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 25.5.0 + '@vitest/coverage-v8': + specifier: 'catalog:' + version: 4.1.0(vitest@4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))) + tsdown: + specifier: 'catalog:' + version: 0.21.4(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.0(@opentelemetry/api@1.9.0)(@types/node@25.5.0)(vite@7.3.1(@types/node@25.5.0)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages: '@ai-sdk/devtools@0.0.15': From db8909ef47498fb4c81895910f1d75addcf23f45 Mon Sep 17 00:00:00 2001 From: Zac Rosenbauer Date: Tue, 24 Mar 2026 21:35:25 -0400 Subject: [PATCH 8/8] refactor(utils): remove freeze and remove() from privateField MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Simplify the privateField accessor — drop Object.freeze() on the returned accessor and remove the remove() method since neither is needed for the current use case. Co-Authored-By: Claude --- .../utils/docs/reference/private-field.md | 7 ++--- packages/utils/src/private-field.test.ts | 30 ------------------- packages/utils/src/private-field.ts | 22 +------------- 3 files changed, 4 insertions(+), 55 deletions(-) diff --git a/packages/utils/docs/reference/private-field.md b/packages/utils/docs/reference/private-field.md index d6eb467..0126c47 100644 --- a/packages/utils/docs/reference/private-field.md +++ b/packages/utils/docs/reference/private-field.md @@ -13,7 +13,7 @@ import type { PrivateField } from "@funkai/utils"; ### `privateField(description: string): PrivateField` -Creates a frozen accessor for a private Symbol-keyed property. +Creates an accessor for a private Symbol-keyed property. Uses `Symbol.for(description)` internally — the same description string produces the same Symbol across package boundaries. @@ -23,7 +23,7 @@ Uses `Symbol.for(description)` internally — the same description string produc | ------------- | -------- | ------------------------------------------------------------- | | `description` | `string` | A namespaced key for the Symbol (e.g. `"funkai:agent-chain"`) | -**Returns:** A frozen `PrivateField` accessor. +**Returns:** A `PrivateField` accessor. ### `PrivateField` @@ -34,7 +34,6 @@ Uses `Symbol.for(description)` internally — the same description string produc | `get` | `(obj: object, defaultValue: T) => T` | Read the field with a fallback | | `set` | `(obj: O, value: T) => O` | Attach the field. Returns the same object | | `has` | `(obj: object) => boolean` | Check if the field is present | -| `remove` | `(obj: object) => boolean` | Delete the field. Returns `true` if removed | ## Usage @@ -89,7 +88,7 @@ Fields are defined with: - **Non-enumerable** — hidden from iteration and spread - **Writable** — allows `set()` to overwrite without delete+redefine -- **Configurable** — allows `remove()` to delete the property +- **Configurable** — allows property descriptor to be changed if needed The Symbol itself is the access control mechanism. Without the Symbol reference, the field cannot be read or written. diff --git a/packages/utils/src/private-field.test.ts b/packages/utils/src/private-field.test.ts index b083fa5..790e1a7 100644 --- a/packages/utils/src/private-field.test.ts +++ b/packages/utils/src/private-field.test.ts @@ -66,29 +66,6 @@ describe("private field accessor", () => { _field.set(obj, "value"); expect(_field.has(obj)).toBe(true); }); - - it("returns false after remove", () => { - const _field = privateField("test:has-removed"); - const obj = {}; - _field.set(obj, "value"); - _field.remove(obj); - expect(_field.has(obj)).toBe(false); - }); - }); - - describe("remove", () => { - it("returns false when field is absent", () => { - const _field = privateField("test:remove-absent"); - expect(_field.remove({})).toBe(false); - }); - - it("returns true and removes when field is present", () => { - const _field = privateField("test:remove-present"); - const obj = {}; - _field.set(obj, "value"); - expect(_field.remove(obj)).toBe(true); - expect(_field.get(obj)).toBeUndefined(); - }); }); describe("symbol", () => { @@ -158,13 +135,6 @@ describe("private field accessor", () => { }); }); - describe("accessor is frozen", () => { - it("cannot be modified", () => { - const _field = privateField("test:frozen"); - expect(Object.isFrozen(_field)).toBe(true); - }); - }); - describe("multiple fields on one object", () => { it("supports independent private fields", () => { const _name = privateField("test:multi-name"); diff --git a/packages/utils/src/private-field.ts b/packages/utils/src/private-field.ts index 57f89e0..f2fb501 100644 --- a/packages/utils/src/private-field.ts +++ b/packages/utils/src/private-field.ts @@ -76,19 +76,6 @@ export interface PrivateField { * ``` */ has(obj: object): boolean; - - /** - * Remove the private field from an object. - * - * @param obj - The object to remove from. - * @returns `true` if the field was present and removed, `false` otherwise. - * - * @example - * ```ts - * _chainField.remove(params); - * ``` - */ - remove(obj: object): boolean; } /** @@ -152,12 +139,5 @@ export function privateField(description: string): PrivateField { return sym in obj; } - function remove(obj: object): boolean { - if (!(sym in obj)) { - return false; - } - return Reflect.deleteProperty(obj, sym); - } - - return Object.freeze({ symbol: sym, get, set, has, remove }); + return { symbol: sym, get, set, has }; }