generateWithTools method

Future<ToolCallingResult> generateWithTools(
  1. String prompt, {
  2. LLMGenerationOptions? llmOptions,
  3. ToolCallingOptions? options,
  4. ToolChoiceMode? toolChoice,
  5. String? forcedToolName,
  6. bool? validateCalls,
})

Generate text with tool calling support. Delegates the full parse-execute-loop to commons via rac_tool_calling_session_create_proto; Dart only runs registered executors when commons emits a tool_call event.

toolChoice mirrors the OpenAI tool_choice knob: callers can pin the LLM to NONE / AUTO / SPECIFIC without having to manually mutate a ToolCallingOptions proto. When non-null it overrides options.toolChoice for this call. forcedToolName is the companion to toolChoice=SPECIFIC — the tool name the LLM is forced to invoke. Overrides options.forcedToolName when non-null.

Mirrors Swift RunAnywhere.generateWithTools(prompt:options:toolOptions:toolChoice:forcedToolName:validateCalls:) (RunAnywhere+ToolCalling.swift:250-280, makeRunLoopRequest:491-548): tool options take precedence per knob, falling back to the LLM generation llmOptions; top_p always comes from the LLM options; validate_calls is left UNSET unless the caller supplies it (commons applies its documented default).

Implementation

Future<ToolCallingResult> generateWithTools(
  String prompt, {
  LLMGenerationOptions? llmOptions,
  ToolCallingOptions? options,
  ToolChoiceMode? toolChoice,
  String? forcedToolName,
  bool? validateCalls,
}) async {
  // Swift default: `options: RALLMGenerationOptions = .defaults()`.
  final llm = llmOptions ?? _defaultLLMOptions();
  // Swift: `toolOptions ?? (options.hasToolCalling ? options.toolCalling
  // : RAToolCallingOptions.defaults())`.
  final opts =
      (options ??
              (llm.hasToolCalling()
                  ? llm.toolCalling
                  : _defaultToolOptions()))
          .deepCopy();
  if (toolChoice != null) {
    opts.toolChoice = toolChoice;
  }
  if (forcedToolName != null) {
    opts.forcedToolName = forcedToolName;
  }
  final tools = opts.tools.isNotEmpty ? opts.tools : getRegisteredTools();
  final autoExecute = opts.hasAutoExecute() ? opts.autoExecute : true;

  // Mirrors Swift makeRunLoopRequest (RunAnywhere+ToolCalling.swift:491-548).
  final request = ToolCallingSessionCreateRequest(
    prompt: prompt,
    tools: tools,
    format: opts.format,
    maxToolCalls: opts.maxToolCallCount,
    keepToolsAvailable: opts.keepToolsAvailable,
    maxTokens: (opts.hasMaxTokens() && opts.maxTokens > 0)
        ? opts.maxTokens
        : llm.maxTokens,
    temperature: opts.hasTemperature() ? opts.temperature : llm.temperature,
    topP: llm.topP,
    // Suppress thinking when either options surface asks for it.
    disableThinking: opts.disableThinking || llm.disableThinking,
    autoExecute: autoExecute,
    replaceSystemPrompt: opts.replaceSystemPrompt,
    requireJsonArguments: opts.requireJsonArguments,
  );
  // `validate_calls` is `optional bool` on the proto — leave it UNSET when
  // the caller did not supply a value so commons applies its documented
  // default (true).
  if (validateCalls != null) {
    request.validateCalls = validateCalls;
  }
  if (opts.toolChoice != ToolChoiceMode.TOOL_CHOICE_MODE_UNSPECIFIED) {
    request.toolChoice = opts.toolChoice;
  }
  if (opts.hasForcedToolName() && opts.forcedToolName.isNotEmpty) {
    request.forcedToolName = opts.forcedToolName;
  }
  // System prompt: tool options win, then the LLM options.
  if (opts.hasSystemPrompt() && opts.systemPrompt.isNotEmpty) {
    request.systemPrompt = opts.systemPrompt;
  } else if (llm.hasSystemPrompt() && llm.systemPrompt.isNotEmpty) {
    request.systemPrompt = llm.systemPrompt;
  }

  final session = DartBridgeToolCalling.shared.createSession(request);
  // Publish the active session handle so consumers can
  // call `RunAnywhereTools.shared.cancelGeneration()` to interrupt the
  // in-flight loop (mirrors RunAnywhereLLM.cancelGeneration). The native
  // session id is published synchronously before native generation starts,
  // so expose it as soon as the worker forwards that callback.
  unawaited(
    session.sessionId
        .then((id) => _activeSessionHandle = id)
        .catchError((Object _) => _activeSessionHandle),
  );
  final collectedCalls = <ToolCall>[];
  final collectedResults = <ToolResult>[];
  final completer = Completer<ToolCallingResult>();

  late final StreamSubscription<ToolCallingSessionEvent> sub;
  sub = session.events.listen(
    (event) async {
      switch (event.whichKind()) {
        case ToolCallingSessionEvent_Kind.toolCall:
          final call = event.toolCall;
          collectedCalls.add(call);
          _logger.info('Tool call detected: ${call.name}');
          if (!autoExecute) {
            if (!completer.isCompleted) {
              completer.complete(
                ToolCallingResult(
                  text: '',
                  toolCalls: collectedCalls,
                  toolResults: collectedResults,
                  isComplete: false,
                ),
              );
            }
            await sub.cancel();
            return;
          }
          // Forward the result to the session worker, which fills in its own
          // session id and runs the next turn off the UI isolate; the turn's
          // events arrive back on this stream.
          try {
            final result = await execute(call);
            collectedResults.add(result);
            session.stepWithResult(
              toolCallId: call.id,
              resultJson: result.resultJson,
              error: result.error,
            );
          } catch (e) {
            _logger.error('Tool executor threw: $e');
            session.stepWithResult(
              toolCallId: call.id,
              resultJson: '',
              error: e.toString(),
            );
          }
          break;
        case ToolCallingSessionEvent_Kind.finalResult:
          if (!completer.isCompleted) {
            completer.complete(event.finalResult);
          }
          await sub.cancel();
          break;
        case ToolCallingSessionEvent_Kind.errorBytes:
          if (!completer.isCompleted) {
            completer.completeError(
              StateError('Tool calling session error bytes received'),
            );
          }
          await sub.cancel();
          break;
        case ToolCallingSessionEvent_Kind.llmStreamEventBytes:
        case ToolCallingSessionEvent_Kind.notSet:
          break;
      }
    },
    onError: (Object error, StackTrace stackTrace) async {
      if (!completer.isCompleted) {
        completer.completeError(error, stackTrace);
      }
      await sub.cancel();
    },
  );

  try {
    return await completer.future;
  } finally {
    // Clear the published handle BEFORE close — once close
    // returns, any pending cancelGeneration() call would race a freshly
    // started session.
    if (_activeSessionHandle == session.resolvedSessionId) {
      _activeSessionHandle = 0;
    }
    await session.close();
  }
}