Tool Calling

Imagine that you'd like to extend the capabilities of your AI Agent with some new abilities. For example, out of the box, an LLM doesn't know what time it is. That seems pretty basic, but if it did tell you what time it was, all it could do is make something up.

Defining a Tool

So, if you want to teach the LLM to tell the time, you need to give it a tool:

import 'package:dartantic_ai/dartantic_ai.dart';
import 'package:json_schema/json_schema.dart';

Future<void> main() async {
  final agent = Agent(
    'gemini',
    systemPrompt: 'Show the time as local time.',
    tools: [
      Tool(
        name: 'time',
        description: 'Get the current time in a given location',
        inputSchema: JsonSchema.create({
          'type': 'object',
          'properties': {
            'location': {'type': 'string'},
          },
          'required': ['location'],
        }),
        onCall: _onTimeCall,
      ),
    ],
  );

  final result = await agent.run('What time is it in New York City?');
  print(result.output);
}

  Future<Map<String, dynamic>> _onTimeCall(Map<String, dynamic> input) {
  // TODO: Implement the tool call
  return Future.value({'time': '10:00 AM'});
}

Notice that we're manually creating a JSON schema object to define the parameters, which is error-prone and time-consuming. Keep reading.

Automatic Tool Schema with json_serializable and soti_schema

You can define the parameters to your tool with a Dart class and have the JSON Schema and JSON serialization generated for you with the json_serializable and soti_schema packages. See Typed Output for an example of using these packages to define typed output from an LLM response.

@SotiSchema()
@JsonSerializable()
class TimeFunctionInput {
  TimeFunctionInput({required this.timeZoneName});

  /// The name of a location (e.g. "New York City")
  final String location;

  static TimeFunctionInput fromJson(Map<String, dynamic> json) =>
      _$TimeFunctionInputFromJson(json);

  @jsonSchema
  static Map<String, dynamic> get schemaMap => _$TimeFunctionInputSchemaMap;
}

The use of the JSON serializer and Soti Schema annotations causes the creation of a schemaMap property that provides a JSON schema at runtime that defines our tool:

Future<void> toolExample() async {
  final agent = Agent(
    'openai',
    systemPrompt: 'Show the time as local time.',
    tools: [
      Tool(
        name: 'time',
        description: 'Get the current time in a given location',
        inputSchema: TimeFunctionInput.schemaMap.toSchema(),
        onCall: onTimeCall,
      ),
    ],
  );

  final result = await agent.run('What is time is it in New York City?');
  print(result.output);
}

This code defines a tool that gets the current time for a particular location. The tool's input arguments are defined via the generated JSON schema.

The tool doesn't need to define a schema for the output of the tool -- the LLM will take whatever data you give it -- but we may still like to be able to convert the output type to JSON:

@JsonSerializable()
class TimeFunctionOutput {
  TimeFunctionOutput({required this.time});

  /// The time in the given time zone
  final DateTime time;

  Map<String, dynamic> toJson() => _$TimeFunctionOutputToJson(this);
}

We can now use the JSON serialization support in these two types to implement the tool call function:

Future<Map<String, dynamic>?> onTimeCall(Map<String, dynamic> input) async {
  // parse the JSON input into a type-safe object
  final timeInput = TimeFunctionInput.fromJson(input);

  // TODO: do a little geocoding magic with `timeInput.location`
  ...

  // construct a type-safe object, then translate to JSON to return
  return TimeFunctionOutput(time: now).toJson();
}

In this way, we use the tool input type to define the format of the JSON we're expecting from the LLM and to decode the input JSON into a typed object for our implementation of the onTimeCall function. Likewise, we use the tool output type to gather the returned data before encoding that back into JSON for the return to the LLM.

Simplified Tool Output

Since the LLM is a much more lax about the data you return to it, you may decide to define a Dart type for your input parameters and just bundle up the return data manually:

Future<Map<String, dynamic>?> onTimeCall(Map<String, dynamic> input) async {
  // parse the JSON input into a type-safe object
  final timeInput = TimeFunctionInput.fromJson(input);

  // TODO: geocoding
  ...

  // return a JSON map directly as output
  return {'time': now};
}

Not only is this simpler code, but it frees you from maintaining a separate type for output.

For a complete example, see tools.dart.