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/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/agent.ts b/packages/agents/src/core/agents/base/agent.ts index 5fd1d43..dfb0497 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, @@ -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"; @@ -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 @@ -226,6 +231,7 @@ export function agent< log, onStepStart: params.onStepStart, onStepFinish: buildMergedHook(log, config.onStepFinish, params.onStepFinish), + agentChain: currentChain, }; const aiTools = buildAITools( @@ -266,7 +272,13 @@ export function agent< return { toolName: tr.toolName, resultTextLength: safeSerializedLength(result) }; }); const usage = extractUsage(step.usage); - const event: StepFinishEvent = { stepId, toolCalls, toolResults, usage }; + const event: StepFinishEvent = { + stepId, + toolCalls, + toolResults, + usage, + agentChain: currentChain, + }; await fireHooks( log, wrapHook(config.onStepFinish, event), diff --git a/packages/agents/src/core/agents/base/utils.ts b/packages/agents/src/core/agents/base/utils.ts index 8017f61..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"; @@ -9,7 +10,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 +69,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[]; } /** @@ -366,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); } @@ -389,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); } @@ -403,6 +424,46 @@ function buildAgentTool( }); } +/** + * Private field for transporting agent ancestry chain through params. + * + * Uses a Symbol key so it is invisible to `Object.keys()`, + * `JSON.stringify()`, `for...in`, and object spread. + * + * @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. + * + * @private + */ +const EMPTY_CHAIN: readonly AgentChainEntry[] = []; + +/** + * Extract the internal `agentChain` from raw generate params. + * + * 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(params); + * // => [{ id: "root" }] or [] + * ``` + */ +export function extractAgentChain(params: unknown): readonly AgentChainEntry[] { + if (typeof params !== "object" || params === null) { + return EMPTY_CHAIN; + } + return _agentChainField.get(params, EMPTY_CHAIN); +} + /** * Build the per-call params to forward from parent context to sub-agent. * diff --git a/packages/agents/src/core/agents/flow/flow-agent.ts b/packages/agents/src/core/agents/flow/flow-agent.ts index 89a10b4..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, @@ -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"; @@ -326,6 +326,10 @@ export function flowAgent( 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); diff --git a/packages/agents/src/core/agents/flow/steps/factory.ts b/packages/agents/src/core/agents/flow/steps/factory.ts index 0784c67..86b7d09 100644 --- a/packages/agents/src/core/agents/flow/steps/factory.ts +++ b/packages/agents/src/core/agents/flow/steps/factory.ts @@ -1,6 +1,7 @@ import { isNil, isNotNil } from "es-toolkit"; import { isObject } from "es-toolkit/compat"; +import { _agentChainField } from "@/core/agents/base/utils.js"; import { buildToolCallId, createToolCallMessage, @@ -19,7 +20,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 +56,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 +100,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 +132,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 +142,10 @@ 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 +217,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 +270,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); @@ -274,12 +289,20 @@ function createStepBuilderInternal(options: StepBuilderOptions, indexRef: IndexR type: "agent", input: config.input, execute: async () => { + // Forward fixed-type step hooks and agent chain to sub-agent. + // Merge parent + child hooks so neither side is clobbered. + const mergedHooks = mergeStepHooks(parentHooks, config.config); const agentParams = { ...config.config, input: config.input, signal: ctx.signal, logger: ctx.log.child({ stepId: config.id }), + ...mergedHooks, }; + // Stamp after spread — Symbol fields don't survive spread + if (isNotNil(agentChain)) { + _agentChainField.set(agentParams, agentChain); + } // When stream: true and a writer is available, use agent.stream() // To pipe events through the parent flow's stream @@ -585,6 +608,64 @@ function buildOnFinishHandlerRace( return (event) => onFinish({ id: event.id, result: event.result, duration: event.duration }); } +/** + * Merge parent flow step hooks with delegated-agent step hooks. + * + * When both parent and child define the same hook, the merged callback + * fires the child hook first, then the parent hook. Only defined hooks + * are included in the result so `undefined` values never clobber + * a child-only hook via object spread. + * + * @private + */ +function mergeStepHooks( + parentHooks: StepBuilderOptions["parentHooks"], + childConfig: Record | undefined, +): Record { + 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 = {}; + + if (isNotNil(parentStart) && isNotNil(childStart)) { + result.onStepStart = async (event: { step: StepInfo }) => { + await childStart(event); + await parentStart(event); + }; + } else if (isNotNil(parentStart)) { + result.onStepStart = parentStart; + } else if (isNotNil(childStart)) { + result.onStepStart = childStart; + } + + if (isNotNil(parentFinish) && isNotNil(childFinish)) { + result.onStepFinish = async (event: StepFinishEvent) => { + await childFinish(event); + await parentFinish(event); + }; + } else if (isNotNil(parentFinish)) { + result.onStepFinish = parentFinish; + } else if (isNotNil(childFinish)) { + result.onStepFinish = childFinish; + } + + return result; +} + /** * Sequentially reduce items with abort support using tail-recursive iteration. * diff --git a/packages/agents/src/core/types.ts b/packages/agents/src/core/types.ts index 7b6edae..f0e5184 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. + * For direct top-level executions, the chain contains the current + * agent as a single entry. + */ + 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..f1175ea 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,171 @@ 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" }]); + }); +}); diff --git a/packages/utils/README.md b/packages/utils/README.md new file mode 100644 index 0000000..d5a3e27 --- /dev/null +++ b/packages/utils/README.md @@ -0,0 +1,16 @@ +# @funkai/utils + +Internal utilities for the funkai framework. Not published to npm — bundled into consuming packages at build time. + +## Install + +```bash +# In a workspace package's package.json: +"devDependencies": { + "@funkai/utils": "workspace:*" +} +``` + +## Docs + +See [`docs/`](./docs/) for reference documentation. diff --git a/packages/utils/docs/reference/private-field.md b/packages/utils/docs/reference/private-field.md new file mode 100644 index 0000000..0126c47 --- /dev/null +++ b/packages/utils/docs/reference/private-field.md @@ -0,0 +1,109 @@ +# privateField + +Create a type-safe accessor for a Symbol-keyed private 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. + +## API + +```ts +import { privateField } from "@funkai/utils"; +import type { PrivateField } from "@funkai/utils"; +``` + +### `privateField(description: string): PrivateField` + +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. + +**Parameters:** + +| Name | Type | Description | +| ------------- | -------- | ------------------------------------------------------------- | +| `description` | `string` | A namespaced key for the Symbol (e.g. `"funkai:agent-chain"`) | + +**Returns:** A `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 | + +## 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 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. + +## 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..790e1a7 --- /dev/null +++ b/packages/utils/src/private-field.test.ts @@ -0,0 +1,149 @@ +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); + }); + }); + + 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("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..f2fb501 --- /dev/null +++ b/packages/utils/src/private-field.ts @@ -0,0 +1,143 @@ +/** + * 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; +} + +/** + * 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; + } + + return { symbol: sym, get, set, has }; +} 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':