Skip to content
This repository was archived by the owner on Jun 3, 2026. It is now read-only.
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions strands-ts/src/tools/__tests__/tool-factory.test-d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { describe, it, expectTypeOf } from 'vitest'
import { z } from 'zod'
import { tool } from '../tool-factory.js'

describe('tool()', () => {
describe('derived tool', () => {
const baseTool = tool({
name: 'base',
inputSchema: z.object({ url: z.string(), method: z.enum(['GET', 'POST']) }),
callback: (input) => ({ fetched: input.url }),
})

it('infers input and return types from source tool', () => {
const derived = tool({
name: 'derived',
inputSchema: baseTool,
callback: (input) => ({ combined: `${input.method} ${input.url}` }),
})

expectTypeOf(derived.invoke).parameter(0).toEqualTypeOf<{ url: string; method: 'GET' | 'POST' }>()
expectTypeOf(derived.invoke).returns.resolves.toEqualTypeOf<{ combined: string }>()
})
})
})
75 changes: 75 additions & 0 deletions strands-ts/src/tools/__tests__/tool-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,4 +110,79 @@ describe('tool factory', () => {
expect(myTool.description).toBe('')
})
})

describe('DerivedTool (inputSchema as existing tool)', () => {
const zodTool = tool({
name: 'zod_tool',
description: 'A Zod schema tool',
inputSchema: z.object({ url: z.string().url(), method: z.enum(['GET', 'POST']) }),
callback: async (input) => ({ fetched: input.url, method: input.method }),
})

const functionTool = tool({
name: 'function_tool',
description: 'A JSON schema tool',
inputSchema: { type: 'object', properties: { x: { type: 'number' } } },
callback: (input) => `got ${(input as { x: number }).x}`,
})

it('inherits input schema and defaults description from source tool', () => {
const derived = tool({
name: 'derived_tool',
inputSchema: zodTool,
callback: async (input) => input.url,
})

expect(derived).toBeInstanceOf(Tool)
expect(derived.name).toBe('derived_tool')
expect(derived.description).toBe('A Zod schema tool')
expect(derived.toolSpec.inputSchema).toStrictEqual(zodTool.toolSpec.inputSchema)
})

it('overrides description when provided', () => {
const derived = tool({
name: 'derived',
description: 'Custom description',
inputSchema: zodTool,
callback: async (input) => input.url,
})

expect(derived.description).toBe('Custom description')
})

it('delegates to source tool with typed input', async () => {
const derived = tool({
name: 'derived',
inputSchema: zodTool,
callback: async (input, context) => {
const result = await zodTool.invoke(input, context)
return { ...result, wrapped: true }
},
})

const result = await derived.invoke({ url: 'https://example.com', method: 'POST' })
expect(result).toStrictEqual({ fetched: 'https://example.com', method: 'POST', wrapped: true })

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Missing test for Zod validation preservation in derived tools.

The PR description states "When the source tool is a ZodTool, the derived tool preserves Zod runtime validation," but there's no test that passes invalid input to a derived ZodTool and asserts that it throws a Zod validation error. This is the key differentiator from the FunctionTool path and should be explicitly tested.

Suggestion: Add a test like:

it('preserves Zod validation when derived from a ZodTool', async () => {
  const derived = tool({
    name: 'derived',
    inputSchema: zodTool,
    callback: async (input) => input.url,
  })

  await expect(derived.invoke({ url: 'not-a-url', method: 'GET' })).rejects.toThrow()
})

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Missing test for Zod validation preservation in derived tools.

The PR description states "When the source tool is a ZodTool, the derived tool preserves Zod runtime validation," but there's no test verifying that invalid input is rejected. This is the key behavioral claim of the ZodTool derivation path.

Suggestion: Add a test that passes invalid input and asserts the Zod error:

it('preserves Zod validation from source ZodTool', async () => {
  const derived = tool({
    name: 'derived',
    inputSchema: zodTool,
    callback: async (input) => input.url,
  })

  await expect(derived.invoke({ url: 'not-a-url', method: 'GET' })).rejects.toThrow()
})

})

it('inherits input schema from a FunctionTool source', async () => {
const derived = tool({
name: 'derived_json',
inputSchema: functionTool,
callback: (input) => `wrapped: ${(input as { x: number }).x}`,
})

expect(derived.toolSpec.inputSchema).toStrictEqual(functionTool.toolSpec.inputSchema)
expect(await derived.invoke({ x: 42 })).toBe('wrapped: 42')
})

it('preserves Zod validation from source ZodTool', async () => {
const derived = tool({
name: 'derived',
inputSchema: zodTool,
callback: async (input) => input.url,
})

await expect(derived.invoke({ url: 'not-a-url', method: 'GET' })).rejects.toThrow()
})
})
})
80 changes: 75 additions & 5 deletions strands-ts/src/tools/tool-factory.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type { InvokableTool } from './tool.js'
import type { InvokableTool, ToolContext } from './tool.js'
import { Tool } from './tool.js'
import { FunctionTool } from './function-tool.js'
import type { FunctionToolConfig } from './function-tool.js'
import type { JSONValue } from '../types/json.js'
Expand All @@ -15,6 +16,29 @@ function isZodType(value: unknown): value is z.ZodType {
return value instanceof z.ZodType
}

/**
* Configuration for creating a tool that reuses another tool's input schema.
*
* @typeParam TInput - Input type inferred from the source tool
* @typeParam TReturn - Return type of the callback function
*/
interface DerivedToolConfig<TInput, TReturn extends JSONValue> {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The DerivedToolConfig interface is not exported, which means users cannot type their configuration objects or create utility functions that accept/return this config shape.

Suggestion: Export DerivedToolConfig from the module (and potentially from src/index.ts) so that users building helper functions around derived tools have access to the type. This aligns with the SDK tenet of being "extensible by design."

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: DerivedToolConfig is not exported, which means customers who want to type-annotate a variable holding this config or pass it to helper functions can't reference the type.

Suggestion: Export DerivedToolConfig from the module (and src/index.ts) if it's intended as a public API pattern. If it's intentionally internal-only, document that decision.

/** The unique name of the tool */
name: string

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason not to inherit name as well?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I considered it but figured it would be good to force it at least for now since a derived tool is something different.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that it's agent-visible my gut says that most would want to keep the name. Like I don't expect both tools being passed to an agent being a common use-case.

Not a blocker FWIW.


/** Human-readable description of the tool's purpose. Defaults to the source tool's description. */
description?: string

/** An existing tool whose input schema and types will be reused */
inputSchema: InvokableTool<TInput, unknown>

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The inputSchema field is overloaded to mean three different things across the overloads: a Zod schema, a JSON schema object, or an existing tool instance. This polymorphism on a single property name may confuse users.

The existing reviewers raised a similar point about whether a wrapTool function would be clearer. From an API design perspective, having inputSchema accept a Tool instance stretches the semantic meaning of "schema" — a tool is not a schema, it's something that has a schema.

Suggestion: Consider whether a separate field name (e.g., sourceTool or fromTool) or a separate factory function (e.g., tool.from(existingTool, { ... })) would make the intent clearer. This would avoid the semantic overload while still providing the typed derivation ergonomics.


/** Function that implements the tool logic with typed input from the source tool */
callback: (
input: TInput,
context?: ToolContext
) => AsyncGenerator<JSONValue, TReturn, never> | Promise<TReturn> | TReturn
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Each tool type has a config defined like this (example).


/**
* Creates an InvokableTool from a Zod schema and callback function.
*
Expand All @@ -36,11 +60,26 @@ export function tool<TInput extends z.ZodType, TReturn extends JSONValue = JSONV
export function tool(config: FunctionToolConfig): InvokableTool<unknown, JSONValue>

/**
* Creates an InvokableTool from either a Zod schema or JSON schema configuration.
* Creates an InvokableTool that reuses another tool's input schema.
* The callback receives the same typed input as the source tool.
*
* @typeParam TInput - Input type inferred from the source tool
* @typeParam TReturn - Return type of the callback function
* @param config - Tool configuration with a source tool as inputSchema
* @returns An InvokableTool with input typed from the source tool
*/
export function tool<TInput, TReturn extends JSONValue = JSONValue>(

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Did we consider anything like a wrapTool for clarity?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh I guess we can't have a helper method on tools because they're interfaces could we? otherwise this devx would be cool and fairly cool:

httpRequest.wrap({
  ....
})

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just didn't want to give the users a separate export. I thought it might be simpler to overload. But definitely open to suggestions here.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Of the options that fit our criteria,

tool.wrap(httpTool, { ... }

Might be the most clear - it guides you towards thinking of it as "inheriting" or "wrapping".

But either way, I don't expect most folks to self-discover this, so I think it's going to be a docs thing; but inputSpec: tool seems a little weird IMHO

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The overload ordering places DerivedToolConfig after FunctionToolConfig. Because FunctionToolConfig.description is required (non-optional) while DerivedToolConfig.description is optional, there shouldn't be an ambiguity issue in practice. However, when a user omits description, TypeScript may surface confusing error messages pointing at the wrong overload.

Consider placing the DerivedToolConfig overload before the FunctionToolConfig overload, since the instanceof Tool check in the implementation already handles runtime disambiguation, and this would give users better error messages when they pass a tool as inputSchema but forget another required field.

config: DerivedToolConfig<TInput, TReturn>
): InvokableTool<TInput, TReturn>

/**
* Creates an InvokableTool from a Zod schema, JSON schema, or existing tool.
*
* When a Zod schema is provided as `inputSchema`, input is validated at runtime and
* the callback receives typed input. When a JSON schema (or no schema) is provided,
* the callback receives `unknown` input with no runtime validation.
* the callback receives typed input. When an existing tool is provided as `inputSchema`,
* the callback receives input typed from that tool's generic parameters. When a JSON
* schema (or no schema) is provided, the callback receives `unknown` input with no
* runtime validation.
*
* @example
* ```typescript
Expand All @@ -66,14 +105,45 @@ export function tool(config: FunctionToolConfig): InvokableTool<unknown, JSONVal
* },
* callback: (input) => `Hello, ${(input as { name: string }).name}!`,
* })
*
* // With existing tool (typed from source tool)
* const positiveCalculator = tool({
* name: 'positive_calculator',
* description: 'Adds two positive numbers',
* inputSchema: calculator,
* callback: (input) => {
* if (input.a < 0 || input.b < 0) throw new Error('No negatives')
* return calculator.invoke(input)
* },
* })
* ```
*
* @param config - Tool configuration
* @returns An InvokableTool that implements the Tool interface with invoke() method
*/
export function tool(
config: ZodToolConfig<z.ZodType | undefined, JSONValue> | FunctionToolConfig
config: ZodToolConfig<z.ZodType | undefined, JSONValue> | FunctionToolConfig | DerivedToolConfig<unknown, JSONValue>
): InvokableTool<unknown, JSONValue> {
if (config.inputSchema && config.inputSchema instanceof Tool) {
const sourceTool = config.inputSchema

if (sourceTool instanceof ZodTool) {
return new ZodTool({
name: config.name,
description: config.description ?? sourceTool.description,
inputSchema: sourceTool.inputSchema,
callback: config.callback,
} as ZodToolConfig<z.ZodType, JSONValue>)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The as ZodToolConfig<z.ZodType, JSONValue> and as FunctionToolConfig casts bypass type checking on the callback field.

When deriving from a source tool, the config.callback has the DerivedToolConfig signature (input: TInput, context?: ToolContext) => ..., which differs from ZodToolConfig's callback signature (input: ZodInferred<TInput>, context?: ToolContext) => .... The as cast silently reconciles this, but if the signatures ever diverge (e.g., a required context parameter), this would produce a runtime error with no compile-time warning.

Suggestion: Consider extracting the callback assignment explicitly so the type relationship is clear, or add a comment explaining why the cast is safe here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: The as ZodToolConfig<z.ZodType, JSONValue> and as FunctionToolConfig casts suppress type checking at the implementation boundary.

The DerivedToolConfig.callback has signature (input: TInput, context?: ToolContext) => ... while FunctionToolCallback expects (input: unknown, toolContext: ToolContext) => .... The cast papers over this mismatch. While it works at runtime (JS doesn't enforce parameter arity), these casts would hide future type regressions.

Suggestion: Consider a thin wrapper function that explicitly adapts the callback signature instead of relying on as casts:

const wrappedCallback: FunctionToolCallback = (input, toolContext) =>
  (config.callback as DerivedToolConfig<unknown, JSONValue>['callback'])(input, toolContext)

This makes the adaptation explicit rather than relying on structural compatibility hidden behind a cast.

}

return new FunctionTool({
name: config.name,
description: config.description ?? sourceTool.description,
inputSchema: sourceTool.toolSpec.inputSchema,
callback: config.callback,
} as FunctionToolConfig)
}

if (config.inputSchema && isZodType(config.inputSchema)) {
return new ZodTool(config as ZodToolConfig<z.ZodType, JSONValue>)
}
Expand Down
13 changes: 6 additions & 7 deletions strands-ts/src/tools/zod-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,9 +58,8 @@ export class ZodTool<TInput extends z.ZodType | undefined, TReturn extends JSONV

/**
* Zod schema for input validation.
* Note: undefined is normalized to z.void() in constructor, so this is always defined.
*/
private readonly _inputSchema: z.ZodType
readonly inputSchema: TInput extends z.ZodType ? TInput : z.ZodVoid

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Exposing inputSchema as a public property is a public API change that broadens ZodTool's surface area.

Previously _inputSchema was private, meaning consumers couldn't depend on it. Making it readonly inputSchema means it becomes part of the public contract that customers (and downstream libraries) can rely on. This warrants the needs-api-review label per the API bar-raising process.

Additionally, the property type (TInput extends z.ZodType ? TInput : z.ZodVoid) exposes Zod types directly in the public API, coupling the SDK's public surface to Zod's type system. Consider whether this should remain an internal detail accessed only by the factory, rather than a public property on the tool instance.

Suggestion: If the only consumer of this property is the tool() factory, consider keeping it private/internal (e.g., a package-internal access pattern or a symbol-keyed property) rather than making it fully public. If you do want it public, add the needs-api-review label to the PR.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Users might want to access it for custom type inference, testing, or building their own composition utilities. Also, we already expose Zod through the public contract. This is the ZodTool after all.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issue: Exposing inputSchema as a public readonly property is a public API surface change.

Previously _inputSchema was private, meaning customers couldn't depend on it. Making it readonly inputSchema makes it part of the public contract. Per the API Bar Raising process, this should have the needs-api-review label since it's a new public property on a class customers use.

Additionally, the property type (TInput extends z.ZodType ? TInput : z.ZodVoid) couples the public API surface directly to Zod's type system.

Suggestion: If the only consumer is the tool() factory (internal to the SDK), consider keeping it package-internal rather than fully public. If you do want it public, add the needs-api-review label.


/**
* User callback function.
Expand All @@ -75,20 +74,20 @@ export class ZodTool<TInput extends z.ZodType | undefined, TReturn extends JSONV
const { name, description = '', inputSchema, callback } = config

// Normalize undefined to z.void() to simplify logic throughout
this._inputSchema = inputSchema ?? z.void()
this.inputSchema = (inputSchema ?? z.void()) as TInput extends z.ZodType ? TInput : z.ZodVoid
this._callback = callback

let generatedSchema: JSONSchema

// Handle z.void() - use default empty object schema
if (this._inputSchema instanceof ZodVoid) {
if (this.inputSchema instanceof ZodVoid) {
generatedSchema = {
type: 'object',
properties: {},
additionalProperties: false,
}
} else {
generatedSchema = zodSchemaToJsonSchema(this._inputSchema)
generatedSchema = zodSchemaToJsonSchema(this.inputSchema)
}

// Create a FunctionTool with a validation wrapper
Expand All @@ -101,7 +100,7 @@ export class ZodTool<TInput extends z.ZodType | undefined, TReturn extends JSONV
toolContext: ToolContext
): AsyncGenerator<JSONValue, JSONValue, never> | Promise<JSONValue> | JSONValue => {
// Only validate if schema is not z.void() (after normalization, it's never undefined)
const validatedInput = this._inputSchema instanceof ZodVoid ? input : this._inputSchema.parse(input)
const validatedInput = this.inputSchema instanceof ZodVoid ? input : this.inputSchema.parse(input)
// Execute user callback with validated input
return callback(validatedInput as ZodInferred<TInput>, toolContext) as
| AsyncGenerator<JSONValue, JSONValue, never>
Expand Down Expand Up @@ -157,7 +156,7 @@ export class ZodTool<TInput extends z.ZodType | undefined, TReturn extends JSONV
*/
async invoke(input: ZodInferred<TInput>, context?: ToolContext): Promise<TReturn> {
// Only validate if schema is not z.void() (after normalization, it's never undefined)
const validatedInput = this._inputSchema instanceof ZodVoid ? input : this._inputSchema.parse(input)
const validatedInput = this.inputSchema instanceof ZodVoid ? input : this.inputSchema.parse(input)

// Execute callback with validated input
const result = this._callback(validatedInput as ZodInferred<TInput>, context)
Expand Down
Loading