complete method

  1. @override
Future<AiReply> complete({
  1. required String context,
  2. required String prompt,
})
override

Asks the model to map prompt to an action.

context is the project-specific brief: the available task and job keys plus the command reference. Throws AiException on transport or protocol failure.

Implementation

@override
Future<AiReply> complete({
  required String context,
  required String prompt,
}) async {
  final body = await post(
    '${config.baseUrl}/v1/messages',
    {
      'model': config.model,
      'max_tokens': config.maxTokens,
      // Deliberately no `output_config`/`effort` here. It would suit this
      // task — mapping one sentence to one command needs little reasoning —
      // but it is rejected outright by older Claude models, and the model is
      // the user's choice. Correctness across the range beats the saving.
      'system': context,
      'messages': [
        {'role': 'user', 'content': prompt},
      ],
      'tools': [
        {
          'name': AiProvider.toolName,
          'description': AiProvider.toolDescription,
          'input_schema': AiProvider.toolSchema,
        },
      ],
    },
    {
      'Content-Type': 'application/json',
      'anthropic-version': '2023-06-01',
      if (config.hasApiKey) 'x-api-key': config.apiKey,
    },
  );

  // Safety classifiers can decline a request with a successful HTTP 200, so
  // the stop reason has to be checked before reading any content.
  if (body['stop_reason'] == 'refusal') {
    final details = body['stop_details'];
    final category = details is Map ? details['category']?.toString() : null;
    return AiReply(
      refusal: category == null
          ? 'the model declined this request'
          : 'the model declined this request ($category)',
    );
  }

  // A response cut short by the token ceiling still arrives as HTTP 200 with
  // a `tool_use` block, but its `input` is whatever had been emitted so far.
  // Acting on it means running a command the model never finished choosing —
  // a half-written `{"command":"run"}` is a full-configuration run.
  final truncated = body['stop_reason'] == 'max_tokens';

  final content = body['content'];
  if (content is! List) {
    throw AiException('$name returned no content');
  }

  final buffer = StringBuffer();
  for (final block in content) {
    if (block is! Map) continue;
    switch (block['type']) {
      case 'tool_use':
        final input = block['input'];
        if (input is Map) {
          if (truncated) {
            throw AiException(
              'the reply was cut off by the token limit, so the command it '
              'was choosing is incomplete — raise `max-tokens` (currently '
              '${config.maxTokens}) and try again',
            );
          }
          return AiReply(
              action: AiAction.fromJson(
            Map<String, dynamic>.from(input),
          ));
        }
      case 'text':
        buffer.write(block['text'] ?? '');
    }
  }

  final text = buffer.toString().trim();
  return AiReply(text: text.isEmpty ? null : text);
}